Skip to content

Live clients

Portal scrapers for the official eCourts and court websites. All are async context managers and accept a pluggable CaptchaSolver.

HCServicesClient

HCServicesClient

HCServicesClient(
    config: BharatCourtsConfig | None = None,
    captcha_solver: CaptchaSolver | None = None,
    http_client: RateLimitedClient | None = None,
)

Async client for HC Services (hcservices.ecourts.gov.in).

Usage::

async with HCServicesClient() as client:
    cases = await client.case_status(
        court=get_court("delhi"),
        case_type="WP(C)",
        case_number="12345",
        year="2024",
    )
Source code in src/bharat_courts/hcservices/client.py
def __init__(
    self,
    config: BharatCourtsConfig | None = None,
    captcha_solver: CaptchaSolver | None = None,
    http_client: RateLimitedClient | None = None,
):
    self._config = config or default_config
    self._captcha_solver = captcha_solver or default_solver()
    self._http = http_client or RateLimitedClient(self._config)
    self._owns_http = http_client is None

case_status async

case_status(
    court: Court,
    *,
    case_type: str,
    case_number: str,
    year: str,
    bench_code: str = "1"
) -> list[CaseInfo]

Look up case status by case number.

Parameters:

Name Type Description Default
court Court

Court object (use get_court() to obtain).

required
case_type str

Numeric case type code (e.g. "134" for W.P.(C) in Delhi). Use :meth:list_case_types to discover available codes.

required
case_number str

Case number without type/year.

required
year str

Registration year (e.g. "2024").

required
bench_code str

Bench code from :meth:list_benches (default "1").

'1'

Returns:

Type Description
list[CaseInfo]

List of matching CaseInfo objects.

Source code in src/bharat_courts/hcservices/client.py
async def case_status(
    self,
    court: Court,
    *,
    case_type: str,
    case_number: str,
    year: str,
    bench_code: str = "1",
) -> list[CaseInfo]:
    """Look up case status by case number.

    Args:
        court: Court object (use get_court() to obtain).
        case_type: Numeric case type code (e.g. "134" for W.P.(C) in Delhi).
            Use :meth:`list_case_types` to discover available codes.
        case_number: Case number without type/year.
        year: Registration year (e.g. "2024").
        bench_code: Bench code from :meth:`list_benches` (default "1").

    Returns:
        List of matching CaseInfo objects.
    """

    def build_form(captcha: str) -> dict:
        return endpoints.case_status_form(
            state_code=court.state_code,
            court_code=bench_code,
            case_type=case_type,
            case_number=case_number,
            year=year,
            captcha=captcha,
        )

    resp = await self._post_with_captcha_retry(endpoints.SHOW_RECORDS_URL, build_form)
    results = parse_case_status(resp.text)
    for r in results:
        r.court_name = court.name
    return results

case_status_by_party async

case_status_by_party(
    court: Court,
    *,
    party_name: str,
    year: str,
    bench_code: str = "1",
    status_filter: str = "Both"
) -> list[CaseInfo]

Search cases by party name.

Parameters:

Name Type Description Default
court Court

Court object.

required
party_name str

Petitioner or respondent name (min 3 chars).

required
year str

Registration year (mandatory, e.g. "2024").

required
bench_code str

Bench code from :meth:list_benches (default "1").

'1'
status_filter str

"Pending", "Disposed", or "Both".

'Both'

Returns:

Type Description
list[CaseInfo]

List of matching CaseInfo objects.

Source code in src/bharat_courts/hcservices/client.py
async def case_status_by_party(
    self,
    court: Court,
    *,
    party_name: str,
    year: str,
    bench_code: str = "1",
    status_filter: str = "Both",
) -> list[CaseInfo]:
    """Search cases by party name.

    Args:
        court: Court object.
        party_name: Petitioner or respondent name (min 3 chars).
        year: Registration year (**mandatory**, e.g. "2024").
        bench_code: Bench code from :meth:`list_benches` (default "1").
        status_filter: "Pending", "Disposed", or "Both".

    Returns:
        List of matching CaseInfo objects.
    """

    def build_form(captcha: str) -> dict:
        return endpoints.case_status_by_party_form(
            state_code=court.state_code,
            court_code=bench_code,
            petres_name=party_name,
            rgyear=year,
            captcha=captcha,
            status_filter=status_filter,
        )

    resp = await self._post_with_captcha_retry(endpoints.SHOW_RECORDS_URL, build_form)
    results = parse_case_status(resp.text)
    for r in results:
        r.court_name = court.name
    return results

court_orders async

court_orders(
    court: Court,
    *,
    case_type: str,
    case_number: str,
    year: str,
    bench_code: str = "1"
) -> list[CaseOrder]

Get court orders for a case.

Uses a case number search to get the encrypted order URL path, then constructs the PDF download URL from display_pdf.php.

Parameters:

Name Type Description Default
court Court

Court object.

required
case_type str

Numeric case type code (e.g. "134").

required
case_number str

Case number.

required
year str

Registration year.

required
bench_code str

Bench code from :meth:list_benches (default "1").

'1'

Returns:

Type Description
list[CaseOrder]

List of CaseOrder objects with PDF URLs.

Source code in src/bharat_courts/hcservices/client.py
async def court_orders(
    self,
    court: Court,
    *,
    case_type: str,
    case_number: str,
    year: str,
    bench_code: str = "1",
) -> list[CaseOrder]:
    """Get court orders for a case.

    Uses a case number search to get the encrypted order URL path,
    then constructs the PDF download URL from display_pdf.php.

    Args:
        court: Court object.
        case_type: Numeric case type code (e.g. "134").
        case_number: Case number.
        year: Registration year.
        bench_code: Bench code from :meth:`list_benches` (default "1").

    Returns:
        List of CaseOrder objects with PDF URLs.
    """

    def build_form(captcha: str) -> dict:
        return endpoints.case_status_form(
            state_code=court.state_code,
            court_code=bench_code,
            case_type=case_type,
            case_number=case_number,
            year=year,
            captcha=captcha,
        )

    resp = await self._post_with_captcha_retry(endpoints.SHOW_RECORDS_URL, build_form)
    return parse_orders(
        resp.text,
        base_url=endpoints.BASE_URL,
        bench_code=bench_code,
        state_code=court.state_code,
    )

cause_list async

cause_list(
    court: Court,
    *,
    civil: bool = True,
    bench_code: str = "1",
    causelist_date: str = ""
) -> list[CauseListPDF]

Get cause list PDFs for a court.

The HC Services portal returns a table of PDF links, one per bench/judge. Each entry contains the bench name, cause list type, and PDF URL.

Parameters:

Name Type Description Default
court Court

Court object.

required
civil bool

True for civil cases, False for criminal.

True
bench_code str

Bench code from list_benches() (default "1" = principal).

'1'
causelist_date str

Date in DD-MM-YYYY format (defaults to today).

''

Returns:

Type Description
list[CauseListPDF]

List of CauseListPDF objects with bench info and PDF URLs.

Source code in src/bharat_courts/hcservices/client.py
async def cause_list(
    self,
    court: Court,
    *,
    civil: bool = True,
    bench_code: str = "1",
    causelist_date: str = "",
) -> list[CauseListPDF]:
    """Get cause list PDFs for a court.

    The HC Services portal returns a table of PDF links, one per bench/judge.
    Each entry contains the bench name, cause list type, and PDF URL.

    Args:
        court: Court object.
        civil: True for civil cases, False for criminal.
        bench_code: Bench code from list_benches() (default "1" = principal).
        causelist_date: Date in DD-MM-YYYY format (defaults to today).

    Returns:
        List of CauseListPDF objects with bench info and PDF URLs.
    """
    # Determine selprevdays: "1" if date is in the past, "0" otherwise
    selprevdays = "0"
    if causelist_date:
        from datetime import date, datetime

        try:
            sel = datetime.strptime(causelist_date, "%d-%m-%Y").date()
            if sel < date.today():
                selprevdays = "1"
        except ValueError:
            pass

    def build_form(captcha: str) -> dict:
        return endpoints.cause_list_form(
            state_code=court.state_code,
            court_code=bench_code,
            captcha=captcha,
            causelist_date=causelist_date,
            flag="civ_t" if civil else "cri_t",
            selprevdays=selprevdays,
        )

    resp = await self._post_with_captcha_retry(endpoints.INDEX_QRY_URL, build_form)
    return parse_cause_list(resp.text, base_url=endpoints.BASE_URL)

list_benches async

list_benches(court: Court) -> dict[str, str]

Get available benches for a High Court.

Returns:

Type Description
dict[str, str]

Dict mapping bench code to bench name, e.g.

dict[str, str]

{"1": "Principal Bench at Delhi", "2": "Lucknow Bench"}.

Source code in src/bharat_courts/hcservices/client.py
async def list_benches(self, court: Court) -> dict[str, str]:
    """Get available benches for a High Court.

    Returns:
        Dict mapping bench code to bench name, e.g.
        {"1": "Principal Bench at Delhi", "2": "Lucknow Bench"}.
    """
    await self._init_session()
    form = endpoints.fill_bench_form(state_code=court.state_code)
    resp = await self._http.post(endpoints.INDEX_QRY_URL, data=form)
    benches = {}
    for entry in resp.text.split("#"):
        entry = entry.strip()
        if "~" in entry:
            code, name = entry.split("~", 1)
            # Strip BOM (\ufeff) and whitespace from portal response
            code = code.strip().strip("\ufeff")
            name = name.strip().strip("\ufeff")
            if code and code != "0" and name and "select" not in name.lower():
                benches[code] = name
    return benches

list_case_types async

list_case_types(
    court: Court, *, bench_code: str = "1"
) -> dict[str, str]

Get available case types for a High Court bench.

Returns:

Type Description
dict[str, str]

Dict mapping case type code to name, e.g.

dict[str, str]

{"134": "W.P.(C)(CIVIL WRITS)-134", "27": "W.P.(CRL)..."}.

Source code in src/bharat_courts/hcservices/client.py
async def list_case_types(self, court: Court, *, bench_code: str = "1") -> dict[str, str]:
    """Get available case types for a High Court bench.

    Returns:
        Dict mapping case type code to name, e.g.
        {"134": "W.P.(C)(CIVIL WRITS)-134", "27": "W.P.(CRL)..."}.
    """
    await self._init_session()
    form = endpoints.fill_case_type_form(
        state_code=court.state_code,
        court_code=bench_code,
    )
    resp = await self._http.post(endpoints.FILL_CASE_TYPE_URL, data=form)
    case_types = {}
    for entry in resp.text.split("#"):
        entry = entry.strip().strip("\ufeff")
        if "~" in entry:
            code, name = entry.split("~", 1)
            code = code.strip()
            name = name.strip()
            if code and code != "0" and name and "select" not in name.lower():
                case_types[code] = name
    return case_types

download_order_pdf async

download_order_pdf(pdf_url: str) -> bytes

Download an order/judgment PDF.

The display_pdf.php endpoint requires a valid Referer header and session cookies from the same client that performed the search.

Parameters:

Name Type Description Default
pdf_url str

URL from CaseOrder.pdf_url.

required

Returns:

Type Description
bytes

Raw PDF bytes.

Source code in src/bharat_courts/hcservices/client.py
async def download_order_pdf(self, pdf_url: str) -> bytes:
    """Download an order/judgment PDF.

    The display_pdf.php endpoint requires a valid Referer header
    and session cookies from the same client that performed the search.

    Args:
        pdf_url: URL from CaseOrder.pdf_url.

    Returns:
        Raw PDF bytes.
    """
    resp = await self._http.get(
        pdf_url,
        headers={
            "Referer": endpoints.MAIN_PAGE_URL,
            "Accept": "application/pdf,*/*",
        },
    )
    content = resp.content
    if content[:4] != b"%PDF":
        raise RuntimeError(
            f"PDF download did not return a valid PDF "
            f"(got {len(content)} bytes; head={content[:64]!r})"
        )
    return content

DistrictCourtClient

DistrictCourtClient

DistrictCourtClient(
    config: BharatCourtsConfig | None = None,
    captcha_solver: CaptchaSolver | None = None,
    http_client: RateLimitedClient | None = None,
)

Async client for District Courts (services.ecourts.gov.in).

Usage::

async with DistrictCourtClient() as client:
    districts = await client.list_districts("8")  # Bihar
    complexes = await client.list_complexes("8", "1")  # Patna
    cases = await client.case_status(
        state_code="8", dist_code="1",
        court_complex_code="1080010", est_code="2",
        case_type="1", case_number="1", year="2024",
    )
Source code in src/bharat_courts/districtcourts/client.py
def __init__(
    self,
    config: BharatCourtsConfig | None = None,
    captcha_solver: CaptchaSolver | None = None,
    http_client: RateLimitedClient | None = None,
):
    self._config = config or default_config
    self._captcha_solver = captcha_solver or default_solver()
    self._http = http_client or RateLimitedClient(self._config)
    self._owns_http = http_client is None
    self._app_token: str = ""
    self._delimeter: str = ""
    self._header_spec: dict[str, str | None] = {}

list_states async

list_states() -> dict[str, str]

Get available states/UTs.

Returns:

Type Description
dict[str, str]

Dict mapping state code to state name.

Source code in src/bharat_courts/districtcourts/client.py
async def list_states(self) -> dict[str, str]:
    """Get available states/UTs.

    Returns:
        Dict mapping state code to state name.
    """
    # States are static, from the portal dropdown
    return {v: k for k, v in endpoints.DISTRICT_STATES.items()}

list_districts async

list_districts(state_code: str) -> dict[str, str]

Get districts for a state.

Parameters:

Name Type Description Default
state_code str

State code (e.g. "8" for Bihar).

required

Returns:

Type Description
dict[str, str]

Dict mapping district code to district name.

Source code in src/bharat_courts/districtcourts/client.py
async def list_districts(self, state_code: str) -> dict[str, str]:
    """Get districts for a state.

    Args:
        state_code: State code (e.g. "8" for Bihar).

    Returns:
        Dict mapping district code to district name.
    """
    await self._init_session()
    form = endpoints.fill_district_form(state_code=state_code)
    result = await self._post_ajax("casestatus/fillDistrict", form)
    dist_html = result.get("dist_list", "")
    return parse_option_tags(dist_html)

list_complexes async

list_complexes(
    state_code: str, dist_code: str
) -> dict[str, str]

Get court complexes for a district.

Parameters:

Name Type Description Default
state_code str

State code.

required
dist_code str

District code.

required

Returns:

Name Type Description
dict[str, str]

Dict mapping complex value (code@ests@flag) to complex name.

Use dict[str, str]

func:parse_complex_value to extract the complex code

dict[str, str]

and determine if establishment selection is needed.

Source code in src/bharat_courts/districtcourts/client.py
async def list_complexes(self, state_code: str, dist_code: str) -> dict[str, str]:
    """Get court complexes for a district.

    Args:
        state_code: State code.
        dist_code: District code.

    Returns:
        Dict mapping complex value (``code@ests@flag``) to complex name.
        Use :func:`parse_complex_value` to extract the complex code
        and determine if establishment selection is needed.
    """
    await self._init_session()
    # Ensure district is filled first
    await self._post_ajax(
        "casestatus/fillDistrict",
        endpoints.fill_district_form(state_code=state_code),
    )
    form = endpoints.fill_complex_form(state_code=state_code, dist_code=dist_code)
    result = await self._post_ajax("casestatus/fillcomplex", form)
    complex_html = result.get("complex_list", "")
    return parse_option_tags(complex_html)

list_establishments async

list_establishments(
    state_code: str, dist_code: str, court_complex_code: str
) -> dict[str, str]

Get establishments for a court complex.

Only needed when the complex flag is 'Y'.

Parameters:

Name Type Description Default
state_code str

State code.

required
dist_code str

District code.

required
court_complex_code str

Raw complex code (without @ests@flag).

required

Returns:

Type Description
dict[str, str]

Dict mapping establishment code to name.

Source code in src/bharat_courts/districtcourts/client.py
async def list_establishments(
    self,
    state_code: str,
    dist_code: str,
    court_complex_code: str,
) -> dict[str, str]:
    """Get establishments for a court complex.

    Only needed when the complex flag is 'Y'.

    Args:
        state_code: State code.
        dist_code: District code.
        court_complex_code: Raw complex code (without @ests@flag).

    Returns:
        Dict mapping establishment code to name.
    """
    await self._init_session()
    form = endpoints.fill_establishment_form(
        state_code=state_code,
        dist_code=dist_code,
        court_complex_code=court_complex_code,
    )
    result = await self._post_ajax("casestatus/fillCourtEstablishment", form)
    est_html = result.get("est_list", result.get("establishment_list", ""))
    return parse_option_tags(est_html)

list_cause_list_courts async

list_cause_list_courts(
    state_code: str,
    dist_code: str,
    court_complex_code: str,
    est_code: str = "",
) -> dict[str, str]

Get the courts dropdown for cause-list lookup.

The cause-list form requires both court_no (the option's value, e.g. "1@2") and court_name (the option's display text, e.g. "District & Sessions Judge - DJ Div. Patna Sadar"). Use this method to discover them; pass either the code through directly to :meth:cause_list, which will look up the matching name automatically.

Source code in src/bharat_courts/districtcourts/client.py
async def list_cause_list_courts(
    self,
    state_code: str,
    dist_code: str,
    court_complex_code: str,
    est_code: str = "",
) -> dict[str, str]:
    """Get the courts dropdown for cause-list lookup.

    The cause-list form requires both ``court_no`` (the option's
    value, e.g. ``"1@2"``) and ``court_name`` (the option's display
    text, e.g. ``"District & Sessions Judge - DJ Div. Patna Sadar"``).
    Use this method to discover them; pass either the code through
    directly to :meth:`cause_list`, which will look up the matching
    name automatically.
    """
    await self._init_session()
    form = endpoints.fill_cause_list_form(
        state_code=state_code,
        dist_code=dist_code,
        court_complex_code=court_complex_code,
        est_code=est_code,
    )
    result = await self._post_ajax("cause_list/fillCauseList", form)
    html = result.get("cause_list", "")
    return parse_option_tags(html)

list_case_types async

list_case_types(
    state_code: str,
    dist_code: str,
    court_complex_code: str,
    est_code: str = "",
) -> dict[str, str]

Get available case types for a court.

Parameters:

Name Type Description Default
state_code str

State code.

required
dist_code str

District code.

required
court_complex_code str

Court complex code.

required
est_code str

Establishment code (if needed).

''

Returns:

Type Description
dict[str, str]

Dict mapping case type code to name. Codes are returned in the

dict[str, str]

portal's compound "<case_type>^<est_code>" format (e.g.

dict[str, str]

"89^2": "ADMINISTRATIVE SUITE"). Pass the full compound

dict[str, str]

string back as case_type to :meth:case_status /

dict[str, str]

meth:court_orders; do not strip the suffix.

Source code in src/bharat_courts/districtcourts/client.py
async def list_case_types(
    self,
    state_code: str,
    dist_code: str,
    court_complex_code: str,
    est_code: str = "",
) -> dict[str, str]:
    """Get available case types for a court.

    Args:
        state_code: State code.
        dist_code: District code.
        court_complex_code: Court complex code.
        est_code: Establishment code (if needed).

    Returns:
        Dict mapping case type code to name. Codes are returned in the
        portal's compound ``"<case_type>^<est_code>"`` format (e.g.
        ``"89^2": "ADMINISTRATIVE SUITE"``). Pass the full compound
        string back as ``case_type`` to :meth:`case_status` /
        :meth:`court_orders`; do not strip the suffix.
    """
    await self._init_session()
    await self._setup_court(
        state_code=state_code,
        dist_code=dist_code,
        court_complex_code=court_complex_code,
        est_code=est_code,
    )
    form = endpoints.fill_case_type_form(
        state_code=state_code,
        dist_code=dist_code,
        court_complex_code=court_complex_code,
        est_code=est_code,
        search_type="c_no",
    )
    result = await self._post_ajax("casestatus/fillCaseType", form)
    ct_html = result.get("casetype_list", "")
    return parse_option_tags(ct_html)

case_status async

case_status(
    *,
    state_code: str,
    dist_code: str,
    court_complex_code: str,
    est_code: str = "",
    case_type: str,
    case_number: str,
    year: str
) -> list[CaseInfo]

Look up case status by case number.

Parameters:

Name Type Description Default
state_code str

State code (e.g. "8" for Bihar).

required
dist_code str

District code (e.g. "1" for Patna).

required
court_complex_code str

Court complex code (e.g. "1080010").

required
est_code str

Establishment code (if needed).

''
case_type str

Case type code from :meth:list_case_types.

required
case_number str

Case number.

required
year str

Registration year.

required

Returns:

Type Description
list[CaseInfo]

List of matching CaseInfo objects.

Source code in src/bharat_courts/districtcourts/client.py
async def case_status(
    self,
    *,
    state_code: str,
    dist_code: str,
    court_complex_code: str,
    est_code: str = "",
    case_type: str,
    case_number: str,
    year: str,
) -> list[CaseInfo]:
    """Look up case status by case number.

    Args:
        state_code: State code (e.g. "8" for Bihar).
        dist_code: District code (e.g. "1" for Patna).
        court_complex_code: Court complex code (e.g. "1080010").
        est_code: Establishment code (if needed).
        case_type: Case type code from :meth:`list_case_types`.
        case_number: Case number.
        year: Registration year.

    Returns:
        List of matching CaseInfo objects.
    """

    def build_form(captcha: str) -> dict:
        return endpoints.case_status_by_number_form(
            state_code=state_code,
            dist_code=dist_code,
            court_complex_code=court_complex_code,
            est_code=est_code,
            case_type=case_type,
            case_number=case_number,
            year=year,
            captcha=captcha,
        )

    result = await self._post_with_captcha_retry(
        "casestatus/submitCaseNo",
        build_form,
        state_code=state_code,
        dist_code=dist_code,
        court_complex_code=court_complex_code,
        est_code=est_code,
    )
    html = result.get("case_data", "")
    return parse_case_status_html(html)

case_status_by_party async

case_status_by_party(
    *,
    state_code: str,
    dist_code: str,
    court_complex_code: str,
    est_code: str = "",
    party_name: str,
    year: str,
    status_filter: str = "Both"
) -> list[CaseInfo]

Search cases by party name.

Parameters:

Name Type Description Default
state_code str

State code.

required
dist_code str

District code.

required
court_complex_code str

Court complex code.

required
est_code str

Establishment code (if needed).

''
party_name str

Petitioner/respondent name (min 3 chars).

required
year str

Registration year (mandatory).

required
status_filter str

"Pending", "Disposed", or "Both".

'Both'

Returns:

Type Description
list[CaseInfo]

List of matching CaseInfo objects.

Source code in src/bharat_courts/districtcourts/client.py
async def case_status_by_party(
    self,
    *,
    state_code: str,
    dist_code: str,
    court_complex_code: str,
    est_code: str = "",
    party_name: str,
    year: str,
    status_filter: str = "Both",
) -> list[CaseInfo]:
    """Search cases by party name.

    Args:
        state_code: State code.
        dist_code: District code.
        court_complex_code: Court complex code.
        est_code: Establishment code (if needed).
        party_name: Petitioner/respondent name (min 3 chars).
        year: Registration year (mandatory).
        status_filter: "Pending", "Disposed", or "Both".

    Returns:
        List of matching CaseInfo objects.
    """

    def build_form(captcha: str) -> dict:
        return endpoints.case_status_by_party_form(
            state_code=state_code,
            dist_code=dist_code,
            court_complex_code=court_complex_code,
            est_code=est_code,
            party_name=party_name,
            year=year,
            status_filter=status_filter,
            captcha=captcha,
        )

    result = await self._post_with_captcha_retry(
        "casestatus/submitPartyName",
        build_form,
        state_code=state_code,
        dist_code=dist_code,
        court_complex_code=court_complex_code,
        est_code=est_code,
    )
    html = result.get("party_data", "")
    return parse_case_status_html(html)

court_orders async

court_orders(
    *,
    state_code: str,
    dist_code: str,
    court_complex_code: str,
    est_code: str = "",
    case_type: str,
    case_number: str,
    year: str
) -> list[CaseOrder]

Get court orders for a case.

Parameters:

Name Type Description Default
state_code str

State code.

required
dist_code str

District code.

required
court_complex_code str

Court complex code.

required
est_code str

Establishment code (if needed).

''
case_type str

Case type code.

required
case_number str

Case number.

required
year str

Registration year.

required

Returns:

Type Description
list[CaseOrder]

List of CaseOrder objects.

Source code in src/bharat_courts/districtcourts/client.py
async def court_orders(
    self,
    *,
    state_code: str,
    dist_code: str,
    court_complex_code: str,
    est_code: str = "",
    case_type: str,
    case_number: str,
    year: str,
) -> list[CaseOrder]:
    """Get court orders for a case.

    Args:
        state_code: State code.
        dist_code: District code.
        court_complex_code: Court complex code.
        est_code: Establishment code (if needed).
        case_type: Case type code.
        case_number: Case number.
        year: Registration year.

    Returns:
        List of CaseOrder objects.
    """

    def build_form(captcha: str) -> dict:
        return endpoints.court_orders_by_number_form(
            state_code=state_code,
            dist_code=dist_code,
            court_complex_code=court_complex_code,
            est_code=est_code,
            case_type=case_type,
            case_number=case_number,
            year=year,
            captcha=captcha,
        )

    result = await self._post_with_captcha_retry(
        "courtorder/submitCaseNo",
        build_form,
        state_code=state_code,
        dist_code=dist_code,
        court_complex_code=court_complex_code,
        est_code=est_code,
    )
    html = result.get("order_data", result.get("case_data", ""))
    return parse_court_orders_html(html, base_url=endpoints.BASE_URL)

cause_list async

cause_list(
    *,
    state_code: str,
    dist_code: str,
    court_complex_code: str,
    est_code: str = "",
    court_no: str,
    court_name: str = "",
    causelist_date: str = "",
    civil: bool = True
) -> list[CauseListEntry]

Get cause list for a court.

Parameters:

Name Type Description Default
state_code str

State code.

required
dist_code str

District code.

required
court_complex_code str

Court complex code.

required
est_code str

Establishment code (if needed).

''
court_no str

Court code from :meth:list_cause_list_courts (the option's value).

required
court_name str

Court display name (the option's text). The portal validates against this — sending an empty court_name_txt triggers "Court Name is required". If left empty, this method calls :meth:list_cause_list_courts once to look up the matching name for court_no.

''
causelist_date str

Date in DD-MM-YYYY format (defaults to today).

''
civil bool

True for civil, False for criminal.

True

Returns:

Type Description
list[CauseListEntry]

List of CauseListEntry objects.

Source code in src/bharat_courts/districtcourts/client.py
async def cause_list(
    self,
    *,
    state_code: str,
    dist_code: str,
    court_complex_code: str,
    est_code: str = "",
    court_no: str,
    court_name: str = "",
    causelist_date: str = "",
    civil: bool = True,
) -> list[CauseListEntry]:
    """Get cause list for a court.

    Args:
        state_code: State code.
        dist_code: District code.
        court_complex_code: Court complex code.
        est_code: Establishment code (if needed).
        court_no: Court code from :meth:`list_cause_list_courts`
            (the option's ``value``).
        court_name: Court display name (the option's text). The
            portal validates against this — sending an empty
            ``court_name_txt`` triggers ``"Court Name is required"``.
            If left empty, this method calls
            :meth:`list_cause_list_courts` once to look up the
            matching name for ``court_no``.
        causelist_date: Date in DD-MM-YYYY format (defaults to today).
        civil: True for civil, False for criminal.

    Returns:
        List of CauseListEntry objects.
    """
    if not court_name:
        mapping = await self.list_cause_list_courts(
            state_code, dist_code, court_complex_code, est_code
        )
        court_name = mapping.get(court_no, "")
        if not court_name:
            raise ValueError(
                f"court_no={court_no!r} not found in fillCauseList for "
                f"complex={court_complex_code} est={est_code}. "
                f"Available: {list(mapping)[:5]}{'...' if len(mapping) > 5 else ''}"
            )

    def build_form(captcha: str) -> dict:
        return endpoints.cause_list_form(
            state_code=state_code,
            dist_code=dist_code,
            court_complex_code=court_complex_code,
            est_code=est_code,
            court_no=court_no,
            court_name=court_name,
            causelist_date=causelist_date,
            civil=civil,
            captcha=captcha,
        )

    result = await self._post_with_captcha_retry(
        "cause_list/submitCauseList",
        build_form,
        state_code=state_code,
        dist_code=dist_code,
        court_complex_code=court_complex_code,
        est_code=est_code,
    )
    html = result.get("causelist_data", result.get("cause_list_data", ""))
    return parse_cause_list_html(html)

JudgmentSearchClient

JudgmentSearchClient

JudgmentSearchClient(
    config: BharatCourtsConfig | None = None,
    captcha_solver: CaptchaSolver | None = None,
    http_client: RateLimitedClient | None = None,
)

Async client for the Judgment Search portal (judgments.ecourts.gov.in).

Usage::

async with JudgmentSearchClient() as client:
    sr = await client.search("section 498A")
    print(sr.total_count, len(sr.items))
    for j in sr.items:
        print(j.case_number, j.court_name, j.judgment_date)
        pdf = await client.download_pdf(j)  # populates j.pdf_bytes
Source code in src/bharat_courts/judgments/client.py
def __init__(
    self,
    config: BharatCourtsConfig | None = None,
    captcha_solver: CaptchaSolver | None = None,
    http_client: RateLimitedClient | None = None,
):
    self._config = config or default_config
    self._captcha_solver = captcha_solver or default_solver()
    self._http = http_client or RateLimitedClient(self._config)
    self._owns_http = http_client is None
    self._app_token: str = ""

search async

search(
    search_text: str,
    *,
    page: int = 1,
    page_size: int = 10,
    search_opt: str = "PHRASE",
    court_type: str = "2",
    max_captcha_attempts: int = 5
) -> SearchResult

Search for judgments by keyword.

Parameters:

Name Type Description Default
search_text str

Keywords / phrase to search for.

required
page int

1-indexed page number.

1
page_size int

Rows per page (portal supports 10/25/50/100/1000).

10
search_opt str

"PHRASE", "ANY", or "ALL".

'PHRASE'
court_type str

"2" for High Courts, "3" for SCR.

'2'
max_captcha_attempts int

Max CAPTCHA solve retries before giving up.

5

Returns:

Type Description
SearchResult

SearchResult of :class:JudgmentResult items.

Raises:

Type Description
CaptchaError

if the CAPTCHA solver couldn't produce a valid solution within max_captcha_attempts tries. Empty results are now distinguishable from "we gave up": empty means the portal returned zero rows.

Source code in src/bharat_courts/judgments/client.py
async def search(
    self,
    search_text: str,
    *,
    page: int = 1,
    page_size: int = 10,
    search_opt: str = "PHRASE",
    court_type: str = "2",
    max_captcha_attempts: int = 5,
) -> SearchResult:
    """Search for judgments by keyword.

    Args:
        search_text: Keywords / phrase to search for.
        page: 1-indexed page number.
        page_size: Rows per page (portal supports 10/25/50/100/1000).
        search_opt: ``"PHRASE"``, ``"ANY"``, or ``"ALL"``.
        court_type: ``"2"`` for High Courts, ``"3"`` for SCR.
        max_captcha_attempts: Max CAPTCHA solve retries before giving up.

    Returns:
        ``SearchResult`` of :class:`JudgmentResult` items.

    Raises:
        CaptchaError: if the CAPTCHA solver couldn't produce a valid
            solution within ``max_captcha_attempts`` tries. Empty
            results are now distinguishable from "we gave up": empty
            means the portal returned zero rows.
    """
    captcha_text = await self._authenticate(
        search_text, max_captcha_attempts=max_captcha_attempts
    )
    if captcha_text is None:
        raise CaptchaError(f"Failed to solve CAPTCHA after {max_captcha_attempts} attempts")

    data = await self._post_search(
        search_text=search_text,
        captcha_text=captcha_text,
        search_opt=search_opt,
        court_type=court_type,
        page=page,
        page_size=page_size,
    )
    return parse_search_response(data, page=page, page_size=page_size)

search_all async

search_all(
    search_text: str,
    *,
    page_size: int = 25,
    search_opt: str = "PHRASE",
    court_type: str = "2",
    max_captcha_attempts: int = 5
) -> AsyncIterator[SearchResult]

Iterate through every page of results, yielding one SearchResult per page. Re-authenticates if the session token expires mid-walk.

Source code in src/bharat_courts/judgments/client.py
async def search_all(
    self,
    search_text: str,
    *,
    page_size: int = 25,
    search_opt: str = "PHRASE",
    court_type: str = "2",
    max_captcha_attempts: int = 5,
) -> AsyncIterator[SearchResult]:
    """Iterate through every page of results, yielding one SearchResult
    per page. Re-authenticates if the session token expires mid-walk."""
    captcha_text = await self._authenticate(
        search_text, max_captcha_attempts=max_captcha_attempts
    )
    if captcha_text is None:
        raise CaptchaError(f"Failed to solve CAPTCHA after {max_captcha_attempts} attempts")

    page = 1
    while True:
        try:
            data = await self._post_search(
                search_text=search_text,
                captcha_text=captcha_text,
                search_opt=search_opt,
                court_type=court_type,
                page=page,
                page_size=page_size,
            )
        except RuntimeError:
            logger.info("Session likely expired at page %d, re-auth", page)
            captcha_text = await self._authenticate(
                search_text, max_captcha_attempts=max_captcha_attempts
            )
            if captcha_text is None:
                raise CaptchaError(
                    f"Failed to solve CAPTCHA after {max_captcha_attempts} attempts"
                ) from None
            continue

        result = parse_search_response(data, page=page, page_size=page_size)
        yield result
        if not result.has_next or not result.items:
            break
        page += 1

download_pdf async

download_pdf(
    judgment: JudgmentResult, *, court_type: str = "2"
) -> JudgmentResult

Download the PDF for a judgment result.

Mutates judgment in-place: sets pdf_bytes if the download succeeds. The judgment.pdf_url slot stores the row's relative path (from open_pdf(...)), not a directly-fetchable URL — we resolve it through the portal's openpdfcaptcha endpoint before downloading.

Raises:

Type Description
RuntimeError

if the download didn't return PDF bytes.

Source code in src/bharat_courts/judgments/client.py
async def download_pdf(
    self,
    judgment: JudgmentResult,
    *,
    court_type: str = "2",
) -> JudgmentResult:
    """Download the PDF for a judgment result.

    Mutates ``judgment`` in-place: sets ``pdf_bytes`` if the download
    succeeds. The ``judgment.pdf_url`` slot stores the row's relative
    ``path`` (from ``open_pdf(...)``), not a directly-fetchable URL —
    we resolve it through the portal's ``openpdfcaptcha`` endpoint
    before downloading.

    Raises:
        RuntimeError: if the download didn't return PDF bytes.
    """
    if not judgment.pdf_url:
        raise RuntimeError(f"No PDF path on judgment: {judgment.title!r}")

    # If pdf_url already looks resolved (full https URL), trust it.
    if judgment.pdf_url.startswith("http"):
        url = judgment.pdf_url
    else:
        # `val` is the row's open_pdf(VAL, ...) index. The portal
        # uses it as a session-scoped key when generating the temp
        # outputfile; without it (or with the same val for every
        # row) the portal serves the first row's PDF for every
        # subsequent call.
        val = (judgment.metadata or {}).get("pdf_val", "0")
        citation_year = (judgment.metadata or {}).get("pdf_citation_year", "")
        url = await self._resolve_pdf_url(
            judgment.pdf_url,
            court_type=court_type,
            val=val,
            citation_year=citation_year,
        )

    content = await self._http.get_bytes(
        url,
        headers={"Referer": endpoints.MAIN_PAGE_URL},
    )
    if content[:4] != _PDF_MAGIC:
        raise RuntimeError(
            f"PDF download did not return a valid PDF "
            f"(got {len(content)} bytes; head={content[:64]!r})"
        )
    judgment.pdf_bytes = content
    return judgment

download_pdfs async

download_pdfs(
    judgments: list[JudgmentResult],
    *,
    court_type: str = "2",
    stop_on_error: bool = False
) -> list[JudgmentResult]

Download PDFs for multiple judgments. Skips ones that already have pdf_bytes set. Errors are logged unless stop_on_error.

Source code in src/bharat_courts/judgments/client.py
async def download_pdfs(
    self,
    judgments: list[JudgmentResult],
    *,
    court_type: str = "2",
    stop_on_error: bool = False,
) -> list[JudgmentResult]:
    """Download PDFs for multiple judgments. Skips ones that already
    have ``pdf_bytes`` set. Errors are logged unless ``stop_on_error``."""
    for j in judgments:
        if j.pdf_bytes is not None or not j.pdf_url:
            continue
        try:
            await self.download_pdf(j, court_type=court_type)
        except Exception as e:
            logger.warning("PDF download failed for %r: %s", j.case_number or j.title, e)
            if stop_on_error:
                raise
    return judgments

CalcuttaHCClient

CalcuttaHCClient

CalcuttaHCClient(
    config: BharatCourtsConfig | None = None,
    captcha_solver: CaptchaSolver | None = None,
    http_client: RateLimitedClient | None = None,
)

Async client for Calcutta High Court (calcuttahighcourt.gov.in).

Provides order/judgment search and PDF download for cases from September 2020 onwards (CIS system).

Usage::

async with CalcuttaHCClient() as client:
    case_info, orders = await client.search_orders(
        case_type="12", case_number="12886", year="2024",
    )
    if case_info:
        print(case_info.case_number, case_info.petitioner, "vs", case_info.respondent)
    for order in orders:
        print(order.order_date, order.judge, order.neutral_citation)
        if order.pdf_url:
            pdf = await client.download_order_pdf(order.pdf_url)
Source code in src/bharat_courts/calcuttahc/client.py
def __init__(
    self,
    config: BharatCourtsConfig | None = None,
    captcha_solver: CaptchaSolver | None = None,
    http_client: RateLimitedClient | None = None,
):
    self._config = config or default_config
    self._captcha_solver = captcha_solver or default_solver()
    if http_client:
        self._http = http_client
        self._owns_http = False
    else:
        self._http = RateLimitedClient(self._config, ssl_context=create_legacy_ssl_context())
        self._owns_http = True
    self._csrf_token: str = ""

search_orders async

search_orders(
    *,
    case_type: str,
    case_number: str,
    year: str,
    establishment: str = "appellate",
    max_captcha_attempts: int = 5
) -> tuple[CaseInfo | None, list[CaseOrder]]

Search for orders/judgments by case number.

Parameters:

Name Type Description Default
case_type str

Numeric case type code (e.g. "12" for WPA).

required
case_number str

Case registration number (e.g. "12886").

required
year str

Case year (e.g. "2024").

required
establishment str

Bench name — "appellate", "original", "jalpaiguri", or "portblair".

'appellate'
max_captcha_attempts int

Max CAPTCHA solve retries. Default 5 (with OCR ~75% accuracy this gives ~0.1% all-fail rate; each retry opens a fresh session, ~3-4s overhead).

5

Returns:

Type Description
CaseInfo | None

Tuple of (CaseInfo | None, list[CaseOrder]). The

list[CaseOrder]

CaseInfo carries case-level metadata (CNR, parties,

tuple[CaseInfo | None, list[CaseOrder]]

full case number); the list carries per-order rows. If no

tuple[CaseInfo | None, list[CaseOrder]]

case matched and no metadata could be recovered, returns

tuple[CaseInfo | None, list[CaseOrder]]

(None, []).

Source code in src/bharat_courts/calcuttahc/client.py
async def search_orders(
    self,
    *,
    case_type: str,
    case_number: str,
    year: str,
    establishment: str = "appellate",
    max_captcha_attempts: int = 5,
) -> tuple[CaseInfo | None, list[CaseOrder]]:
    """Search for orders/judgments by case number.

    Args:
        case_type: Numeric case type code (e.g. "12" for WPA).
        case_number: Case registration number (e.g. "12886").
        year: Case year (e.g. "2024").
        establishment: Bench name — "appellate", "original",
            "jalpaiguri", or "portblair".
        max_captcha_attempts: Max CAPTCHA solve retries. Default 5
            (with OCR ~75% accuracy this gives ~0.1% all-fail rate;
            each retry opens a fresh session, ~3-4s overhead).

    Returns:
        Tuple of ``(CaseInfo | None, list[CaseOrder])``. The
        ``CaseInfo`` carries case-level metadata (CNR, parties,
        full case number); the list carries per-order rows. If no
        case matched and no metadata could be recovered, returns
        ``(None, [])``.
    """
    est_code = endpoints.ESTABLISHMENTS.get(establishment.lower(), establishment)

    # CAPTCHA retry loop — fresh session each attempt
    search_data = None
    for attempt in range(max_captcha_attempts):
        if attempt > 0:
            logger.info("CAPTCHA retry %d/%d — new session", attempt + 1, max_captcha_attempts)

        token = await self._init_session()
        captcha = await self._solve_captcha()

        form = endpoints.search_form(
            token=token,
            establishment=est_code,
            case_type=case_type,
            case_number=case_number,
            year=year,
            captcha=captcha,
        )
        try:
            resp = await self._http.post(
                endpoints.SEARCH_URL,
                data=form,
                headers={
                    "X-Requested-With": "XMLHttpRequest",
                    "Referer": endpoints.SEARCH_PAGE_URL,
                },
            )
        except httpx.HTTPStatusError as e:
            # Wrong CAPTCHA → the portal returns 422 with a Laravel
            # validation body. Rotate session and retry. Other 4xx
            # (real validation errors) propagate.
            if e.response.status_code == 422:
                logger.warning("CAPTCHA attempt %d failed (422)", attempt + 1)
                continue
            raise

        try:
            search_data = parse_search_response(resp.text)
            break
        except Exception as e:
            logger.warning("Failed to parse response on attempt %d: %s", attempt + 1, e)
            continue

    if search_data is None:
        logger.error("Failed to search after %d attempts", max_captcha_attempts)
        return None, []

    case_info = _build_case_info(search_data)

    if not search_data["orders"]:
        if case_info is None:
            return None, []
        return case_info, []

    # Resolve PDF URLs for each order via /show_pdf
    pdf_urls: dict[str, str] = {}
    for order in search_data["orders"]:
        order_data = order.get("order_data", "")
        if not order_data:
            continue
        try:
            pdf_form = endpoints.show_pdf_form(token=self._csrf_token, order_data=order_data)
            pdf_resp = await self._http.post(
                endpoints.SHOW_PDF_URL,
                data=pdf_form,
                headers={
                    "X-Requested-With": "XMLHttpRequest",
                    "Referer": endpoints.SEARCH_PAGE_URL,
                },
            )
            pdf_url = pdf_resp.text.strip()
            if pdf_url.startswith("http"):
                pdf_urls[order_data] = pdf_url
                logger.debug("Resolved PDF: %s", pdf_url)
        except Exception as e:
            logger.warning("Failed to resolve PDF for order %s: %s", order_data, e)

    return case_info, to_case_orders(search_data, pdf_urls)

download_order_pdf async

download_order_pdf(pdf_url: str) -> bytes

Download an order/judgment PDF.

Parameters:

Name Type Description Default
pdf_url str

URL from CaseOrder.pdf_url.

required

Returns:

Type Description
bytes

Raw PDF bytes.

Raises:

Type Description
RuntimeError

if the response does not start with the %PDF magic bytes (e.g. portal returned an error string instead of a PDF).

Source code in src/bharat_courts/calcuttahc/client.py
async def download_order_pdf(self, pdf_url: str) -> bytes:
    """Download an order/judgment PDF.

    Args:
        pdf_url: URL from CaseOrder.pdf_url.

    Returns:
        Raw PDF bytes.

    Raises:
        RuntimeError: if the response does not start with the
            ``%PDF`` magic bytes (e.g. portal returned an error
            string instead of a PDF).
    """
    resp = await self._http.get(
        pdf_url,
        headers={"Referer": endpoints.SEARCH_PAGE_URL},
    )
    content = resp.content
    if content[:4] != b"%PDF":
        raise RuntimeError(
            f"PDF download did not return a valid PDF "
            f"(got {len(content)} bytes; head={content[:64]!r})"
        )
    return content

SCIClient

SCIClient

SCIClient(
    config: BharatCourtsConfig | None = None,
    http_client: RateLimitedClient | None = None,
)

Async client for the Supreme Court of India (www.sci.gov.in).

Usage::

async with SCIClient() as client:
    recent = await client.list_recent_judgments()
    for j in recent[:3]:
        print(j.judgment_date, j.case_number, j.title)
        pdf = await client.download_pdf(j)  # populates j.pdf_bytes
Source code in src/bharat_courts/sci/client.py
def __init__(
    self,
    config: BharatCourtsConfig | None = None,
    http_client: RateLimitedClient | None = None,
):
    self._config = config or default_config
    self._http = http_client or RateLimitedClient(self._config)
    self._owns_http = http_client is None

list_recent_judgments async

list_recent_judgments(
    *, limit: int = 50
) -> list[JudgmentResult]

Return the homepage's "Latest Judgements / Orders" feed.

The portal surfaces the 50 most recent items inline on the homepage; this method scrapes that list. No CAPTCHA needed.

Parameters:

Name Type Description Default
limit int

Maximum number of items to return (the homepage caps this at 50). Pass less to truncate.

50
Source code in src/bharat_courts/sci/client.py
async def list_recent_judgments(self, *, limit: int = 50) -> list[JudgmentResult]:
    """Return the homepage's "Latest Judgements / Orders" feed.

    The portal surfaces the 50 most recent items inline on the
    homepage; this method scrapes that list. No CAPTCHA needed.

    Args:
        limit: Maximum number of items to return (the homepage caps
            this at 50). Pass less to truncate.
    """
    resp = await self._http.get(
        SCI_HOME_URL,
        headers={"Referer": SCI_HOME_URL},
    )
    items = parse_recent_judgments(resp.text, base_url=SCI_BASE)
    if limit and limit < len(items):
        items = items[:limit]
    return items

download_pdf async

download_pdf(judgment: JudgmentResult) -> JudgmentResult

Download the PDF bytes for a judgment.

Mutates judgment in-place: sets pdf_bytes on success. judgment.pdf_url is the /sci-get-pdf/?diary_no=... URL the portal viewer iframe uses.

Raises:

Type Description
RuntimeError

if the response isn't a PDF.

Source code in src/bharat_courts/sci/client.py
async def download_pdf(self, judgment: JudgmentResult) -> JudgmentResult:
    """Download the PDF bytes for a judgment.

    Mutates ``judgment`` in-place: sets ``pdf_bytes`` on success.
    ``judgment.pdf_url`` is the ``/sci-get-pdf/?diary_no=...`` URL
    the portal viewer iframe uses.

    Raises:
        RuntimeError: if the response isn't a PDF.
    """
    if not judgment.pdf_url:
        raise RuntimeError(f"No PDF URL on judgment: {judgment.title!r}")

    content = await self._http.get_bytes(
        judgment.pdf_url,
        headers={"Referer": judgment.source_url or SCI_HOME_URL},
    )
    if content[:4] != _PDF_MAGIC:
        raise RuntimeError(
            f"PDF download did not return a valid PDF "
            f"(got {len(content)} bytes; head={content[:64]!r})"
        )
    judgment.pdf_bytes = content
    return judgment

search_by_year async

search_by_year(
    year: int, month: int | None = None
) -> list[JudgmentResult]

Date-range search by year/month.

Not implemented. The legacy host (main.sci.gov.in) that served this form has been permanently 503 for years; the live site (www.sci.gov.in) only exposes an equivalent through a CAPTCHA-protected case-number/diary-number form, which this client does not yet wire up. Use :meth:list_recent_judgments for the most recent items.

Source code in src/bharat_courts/sci/client.py
async def search_by_year(
    self,
    year: int,
    month: int | None = None,
) -> list[JudgmentResult]:
    """Date-range search by year/month.

    **Not implemented.** The legacy host (``main.sci.gov.in``) that
    served this form has been permanently 503 for years; the live
    site (``www.sci.gov.in``) only exposes an equivalent through a
    CAPTCHA-protected case-number/diary-number form, which this
    client does not yet wire up. Use
    :meth:`list_recent_judgments` for the most recent items.
    """
    raise NotImplementedError(
        "search_by_year is not supported on the current www.sci.gov.in portal. "
        "Use list_recent_judgments() for recent items, or query by case number "
        "via the portal's /judgements-case-no/ form (CAPTCHA required, "
        "not yet implemented)."
    )

search_by_party async

search_by_party(party_name: str) -> list[JudgmentResult]

Party-name search.

Not implemented. Same situation as :meth:search_by_year.

Source code in src/bharat_courts/sci/client.py
async def search_by_party(self, party_name: str) -> list[JudgmentResult]:
    """Party-name search.

    **Not implemented.** Same situation as :meth:`search_by_year`.
    """
    raise NotImplementedError(
        "search_by_party is not supported on the current www.sci.gov.in portal. "
        "Use list_recent_judgments() for recent items."
    )