Skip to content

Live clients

These clients read distinct court portals. They expose different workflows and return different result models; use the guide for the source you intend to query.

CAPTCHA-gated clients accept a solver. SCIClient uses the recent feed without a solver argument. Async context managers close owned connections.

High Court Services

Number and party searches return CaseInfo; CNR lookup returns the richer CaseDetail. See High Court workflows.

HCServicesClient

HCServicesClient(
    config: BharatJudgementsConfig | 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_judgements/hcservices/client.py
def __init__(
    self,
    config: BharatJudgementsConfig | 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_judgements/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_judgements/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

case_status_by_advocate async

case_status_by_advocate(
    court: Court,
    *,
    advocate_name: str | None = None,
    bar_code: str | None = None,
    bench_code: str = "1",
    status_filter: str = "Both"
) -> list[CaseInfo]

Search an advocate's cases by name or bar registration number.

Unlike :meth:case_status_by_party this needs no year, so a single call returns the advocate's whole book — useful for onboarding a practice without entering case numbers by hand.

Prefer the bar code. The portal resolves it to the advocate itself and echoes the resolution back in the response's adv_name ("G/504/2011" answered with "MR. HEMAL SHAH(6960)"), so the result is exact and needs no filtering.

Name search is a substring match and can pull in other advocates. Filtering it on the bracketed court id silently loses matters: for G/504/2011 the id appears on 6,666 rows but 97 more — 64 further CNRs, all filed 2015 and earlier — carry the bare name with no id at all. Both searches returned the same 2,779 matters; only the id-filtered subset was short.

Parameters:

Name Type Description Default
court Court

Court object.

required
advocate_name str | None

Advocate name, full or partial (min 3 chars).

None
bar_code str | None

Bar registration number as <STATE>/<NUMBER>/<YEAR>, e.g. "G/504/2011".

None
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. Results are one row per

list[CaseInfo]

party, so a case with several petitioners repeats.

Raises:

Type Description
ValueError

If neither or both of advocate_name / bar_code given.

See also

:meth:advocate_search, the same request kept whole. This is a thin view over it — call that one directly if you also need to know whether the portal recognised the advocate, rather than spending a second session and CAPTCHA solve to find out.

Source code in src/bharat_judgements/hcservices/client.py
async def case_status_by_advocate(
    self,
    court: Court,
    *,
    advocate_name: str | None = None,
    bar_code: str | None = None,
    bench_code: str = "1",
    status_filter: str = "Both",
) -> list[CaseInfo]:
    """Search an advocate's cases by name or bar registration number.

    Unlike :meth:`case_status_by_party` this needs no year, so a single
    call returns the advocate's whole book — useful for onboarding a
    practice without entering case numbers by hand.

    Prefer the bar code. The portal resolves it to the advocate itself
    and echoes the resolution back in the response's ``adv_name``
    ("G/504/2011" answered with "MR. HEMAL SHAH(6960)"), so the result is
    exact and needs no filtering.

    Name search is a substring match and can pull in other advocates.
    **Filtering it on the bracketed court id silently loses matters**:
    for G/504/2011 the id appears on 6,666 rows but 97 more — 64 further
    CNRs, all filed 2015 and earlier — carry the bare name with no id at
    all. Both searches returned the same 2,779 matters; only the
    id-filtered subset was short.

    Args:
        court: Court object.
        advocate_name: Advocate name, full or partial (min 3 chars).
        bar_code: Bar registration number as ``<STATE>/<NUMBER>/<YEAR>``,
            e.g. "G/504/2011".
        bench_code: Bench code from :meth:`list_benches` (default "1").
        status_filter: "Pending", "Disposed", or "Both".

    Returns:
        List of matching CaseInfo objects. Results are one row per
        party, so a case with several petitioners repeats.

    Raises:
        ValueError: If neither or both of advocate_name / bar_code given.

    See also:
        :meth:`advocate_search`, the same request kept whole. This is a
        thin view over it — call that one directly if you also need to
        know *whether* the portal recognised the advocate, rather than
        spending a second session and CAPTCHA solve to find out.
    """
    result = await self.advocate_search(
        court,
        advocate_name=advocate_name,
        bar_code=bar_code,
        bench_code=bench_code,
        status_filter=status_filter,
    )
    return result.cases
advocate_search(
    court: Court,
    *,
    advocate_name: str | None = None,
    bar_code: str | None = None,
    bench_code: str = "1",
    status_filter: str = "Both"
) -> AdvocateSearch

Search an advocate's cases, keeping who the portal matched.

The whole of the :meth:case_status_by_advocate request — that method is now a view over this one, returning only .cases — so the advocate the portal resolved the query to survives alongside the cases: G/504/2011 comes back as MR. HEMAL SHAH(6960).

That echo is the positive confirmation a bar code is real, and it is worth having because "you have no pending matters" reads very differently from "that bar number does not exist" to someone who has just signed up.

Measured live: G/504/2011 answered found=True, name="MR. HEMAL SHAH", code="6960", 2,704 rows, while G/999999/1999 raised ServerError: ERROR_VAL.

The negative signal is ambiguous, though. ERROR_VAL is also what a transient refusal looks like — a seeding run saw two dates fail that had answered minutes earlier — so a single error does not prove a bar code wrong. Retry before telling a lawyer their number is invalid; only .found is unambiguous.

Returns:

Name Type Description
An AdvocateSearch

class:AdvocateSearch. Check .found before .cases.

Raises:

Type Description
ValueError

If neither or both of advocate_name / bar_code given.

Source code in src/bharat_judgements/hcservices/client.py
async def advocate_search(
    self,
    court: Court,
    *,
    advocate_name: str | None = None,
    bar_code: str | None = None,
    bench_code: str = "1",
    status_filter: str = "Both",
) -> AdvocateSearch:
    """Search an advocate's cases, keeping who the portal matched.

    The whole of the :meth:`case_status_by_advocate` request — that
    method is now a view over this one, returning only ``.cases`` — so
    the advocate the portal resolved the query to survives alongside the
    cases: ``G/504/2011`` comes back as ``MR. HEMAL SHAH(6960)``.

    That echo is the positive confirmation a bar code is real, and it is
    worth having because "you have no pending matters" reads very
    differently from "that bar number does not exist" to someone who has
    just signed up.

    Measured live: ``G/504/2011`` answered ``found=True``,
    ``name="MR. HEMAL SHAH"``, ``code="6960"``, 2,704 rows, while
    ``G/999999/1999`` raised ``ServerError: ERROR_VAL``.

    **The negative signal is ambiguous, though.** ``ERROR_VAL`` is also
    what a transient refusal looks like — a seeding run saw two dates
    fail that had answered minutes earlier — so a single error does not
    prove a bar code wrong. Retry before telling a lawyer their number is
    invalid; only ``.found`` is unambiguous.

    Returns:
        An :class:`AdvocateSearch`. Check ``.found`` before ``.cases``.

    Raises:
        ValueError: If neither or both of advocate_name / bar_code given.
    """
    # Before the session and the CAPTCHA solve, not inside the retry
    # loop's form_builder — a manual solver blocks on a human at that
    # point, and the request was never going to be buildable.
    endpoints.validate_advocate_query(advocate_name, bar_code)

    def build_form(captcha: str) -> dict:
        return endpoints.case_status_by_advocate_form(
            state_code=court.state_code,
            court_code=bench_code,
            captcha=captcha,
            advocate_name=advocate_name,
            bar_code=bar_code,
            status_filter=status_filter,
        )

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

advocate_cause_list async

advocate_cause_list(
    court: Court,
    *,
    bar_code: str,
    causelist_date: str,
    bench_code: str = "1"
) -> list[CauseListEntry]

Fetch an advocate's cause list for a given date.

This is the court's own answer to "what do I have on this date", so it needs no matching against a party or advocate name.

Rows are per-party; pass the result through :func:~bharat_judgements.hcservices.parser.dedupe_by_cnr for one entry per case. No item/serial number is returned — that appears only in the cause list PDF.

Parameters:

Name Type Description Default
court Court

Court object.

required
bar_code str

Bar registration number, e.g. "G/504/2011".

required
causelist_date str

Listing date as DD-MM-YYYY. The portal rejects dates more than one month ahead.

required
bench_code str

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

'1'

Returns:

Type Description
list[CauseListEntry]

List of CauseListEntry, one per party per listed case.

Source code in src/bharat_judgements/hcservices/client.py
async def advocate_cause_list(
    self,
    court: Court,
    *,
    bar_code: str,
    causelist_date: str,
    bench_code: str = "1",
) -> list[CauseListEntry]:
    """Fetch an advocate's cause list for a given date.

    This is the court's own answer to "what do I have on this date",
    so it needs no matching against a party or advocate name.

    Rows are per-party; pass the result through
    :func:`~bharat_judgements.hcservices.parser.dedupe_by_cnr` for one entry
    per case. No item/serial number is returned — that appears only in
    the cause list PDF.

    Args:
        court: Court object.
        bar_code: Bar registration number, e.g. "G/504/2011".
        causelist_date: Listing date as ``DD-MM-YYYY``. The portal
            rejects dates more than one month ahead.
        bench_code: Bench code from :meth:`list_benches` (default "1").

    Returns:
        List of CauseListEntry, one per party per listed case.
    """

    def build_form(captcha: str) -> dict:
        return endpoints.advocate_cause_list_form(
            state_code=court.state_code,
            court_code=bench_code,
            captcha=captcha,
            bar_code=bar_code,
            causelist_date=causelist_date,
        )

    resp = await self._post_with_captcha_retry(endpoints.SHOW_RECORDS_URL, build_form)
    return parse_advocate_cause_list(resp.text)

case_status_by_cnr async

case_status_by_cnr(cnr: str) -> CaseDetail

Look up a case by its CNR number.

This returns considerably more than :meth:case_status: the search endpoints answer with identity only, leaving status and next_hearing_date empty, whereas a CNR lookup returns the whole case page — stage, coram, every party with advocates, the acts, the full hearing history and the orders — in a single request.

Parameters:

Name Type Description Default
cnr str

16-character CNR, e.g. "GJHC240464312025". Hyphens and spaces are stripped.

required

Returns:

Type Description
CaseDetail

A CaseDetail. Sections the case does not have (no orders yet, no

CaseDetail

acts recorded) come back empty rather than raising.

Raises:

Type Description
ValueError

If the CNR is not 16 alphanumeric characters.

CaptchaError

If every CAPTCHA attempt failed.

Source code in src/bharat_judgements/hcservices/client.py
async def case_status_by_cnr(self, cnr: str) -> CaseDetail:
    """Look up a case by its CNR number.

    This returns considerably more than :meth:`case_status`: the search
    endpoints answer with identity only, leaving ``status`` and
    ``next_hearing_date`` empty, whereas a CNR lookup returns the whole
    case page — stage, coram, every party with advocates, the acts, the
    full hearing history and the orders — in a single request.

    Args:
        cnr: 16-character CNR, e.g. "GJHC240464312025". Hyphens and
            spaces are stripped.

    Returns:
        A CaseDetail. Sections the case does not have (no orders yet, no
        acts recorded) come back empty rather than raising.

    Raises:
        ValueError: If the CNR is not 16 alphanumeric characters.
        CaptchaError: If every CAPTCHA attempt failed.
    """
    cleaned = re.sub(r"[\s-]", "", cnr).upper()
    if len(cleaned) != 16 or not cleaned.isalnum():
        raise ValueError(f"CNR must be 16 alphanumeric characters, got {cnr!r}")

    def build_form(captcha: str) -> dict:
        return endpoints.case_status_by_cnr_form(cnr=cleaned, captcha=captcha)

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

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_judgements/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_judgements/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_judgements/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_judgements/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_judgements/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

District Courts

Discover location codes for number and party queries, or use a direct CNR lookup. See district workflows.

DistrictCourtClient

DistrictCourtClient(
    config: BharatJudgementsConfig | 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_judgements/districtcourts/client.py
def __init__(
    self,
    config: BharatJudgementsConfig | 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.

Scraped live from the portal's sess_state_code dropdown — the codes are portal-internal and have drifted before (13 of 36 entries went stale between releases, silently mapping e.g. Delhi to Jharkhand's districts, #25). Falls back to the bundled :data:~bharat_judgements.districtcourts.endpoints.DISTRICT_STATES snapshot only if the page can't be fetched or parsed.

Returns:

Type Description
dict[str, str]

Dict mapping state code to state name.

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

    Scraped live from the portal's ``sess_state_code`` dropdown — the
    codes are portal-internal and have drifted before (13 of 36 entries
    went stale between releases, silently mapping e.g. Delhi to
    Jharkhand's districts, #25). Falls back to the bundled
    :data:`~bharat_judgements.districtcourts.endpoints.DISTRICT_STATES`
    snapshot only if the page can't be fetched or parsed.

    Returns:
        Dict mapping state code to state name.
    """
    try:
        html = await self._http.get_text(
            endpoints.ajax_url("casestatus/index"),
            headers={"Referer": endpoints.BASE_URL + "/"},
        )
        states = parse_state_options(html)
    except Exception as e:
        logger.warning("Could not scrape live state list, using bundled snapshot: %s", e)
        states = {}
    if states:
        return states
    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_judgements/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_judgements/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_judgements/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.
    """
    court_complex_code = self._bare_complex_code(court_complex_code)
    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_judgements/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.
    """
    court_complex_code = self._bare_complex_code(court_complex_code)
    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_judgements/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.
    """
    court_complex_code = self._bare_complex_code(court_complex_code)
    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_by_cnr async

case_status_by_cnr(cnr: str) -> CaseDetail

Look up a case by its CNR number.

Needs no state/district/complex codes: a CNR identifies the establishment on its own, so this skips the set_data court setup that the other lookups require. That also means it cannot reuse :meth:_post_with_captcha_retry, which exists to re-establish that selection on every retry — the retry loop here is the same shape minus the court setup.

Parameters:

Name Type Description Default
cnr str

16-character CNR, e.g. "GJRJ060015282018". Hyphens and spaces are stripped.

required

Returns:

Type Description
CaseDetail

A CaseDetail with the full case page: stage, judge, parties with

CaseDetail

advocates, acts, hearing history and interim orders.

Raises:

Type Description
ValueError

If the CNR is not 16 alphanumeric characters.

CaptchaError

If every CAPTCHA attempt failed.

Source code in src/bharat_judgements/districtcourts/client.py
async def case_status_by_cnr(self, cnr: str) -> CaseDetail:
    """Look up a case by its CNR number.

    Needs no state/district/complex codes: a CNR identifies the
    establishment on its own, so this skips the ``set_data`` court setup
    that the other lookups require. That also means it cannot reuse
    :meth:`_post_with_captcha_retry`, which exists to re-establish that
    selection on every retry — the retry loop here is the same shape
    minus the court setup.

    Args:
        cnr: 16-character CNR, e.g. "GJRJ060015282018". Hyphens and
            spaces are stripped.

    Returns:
        A CaseDetail with the full case page: stage, judge, parties with
        advocates, acts, hearing history and interim orders.

    Raises:
        ValueError: If the CNR is not 16 alphanumeric characters.
        CaptchaError: If every CAPTCHA attempt failed.
    """
    cleaned = re.sub(r"[\s-]", "", cnr).upper()
    if len(cleaned) != 16 or not cleaned.isalnum():
        raise ValueError(f"CNR must be 16 alphanumeric characters, got {cnr!r}")

    last_error: Exception | None = None
    for attempt in range(self._config.captcha_attempts):
        if attempt > 0:
            logger.info("CAPTCHA retry %d/5 — new session", attempt + 1)
        await self._init_session()
        captcha = await self._solve_captcha()
        if not captcha:
            logger.warning("CAPTCHA attempt %d skipped (solver returned empty)", attempt + 1)
            continue
        form = endpoints.case_status_by_cnr_form(cnr=cleaned, captcha=captcha)
        try:
            result = await self._post_ajax("cnr_status/searchByCNR", form)
        except (CaptchaError, MalformedResponseError) as exc:
            last_error = exc
            logger.warning("CAPTCHA attempt %d failed: %s", attempt + 1, exc)
            continue
        html = result.get("casetype_list") or result.get("historytable") or ""
        if html:
            return parse_case_detail(html, cnr=cleaned, base_url=endpoints.BASE_URL)
        last_error = CaptchaError("No case data returned")

    raise last_error or CaptchaError("CNR lookup failed")

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_judgements/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.
    """
    court_complex_code = self._bare_complex_code(court_complex_code)

    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_judgements/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.
    """
    court_complex_code = self._bare_complex_code(court_complex_code)

    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_judgements/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.
    """
    court_complex_code = self._bare_complex_code(court_complex_code)

    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_judgements/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.
    """
    court_complex_code = self._bare_complex_code(court_complex_code)
    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)

Keep the original search result and session for PDF retrieval. See text search.

JudgmentSearchClient

JudgmentSearchClient(
    config: BharatJudgementsConfig | 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_judgements/judgments/client.py
def __init__(
    self,
    config: BharatJudgementsConfig | 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_judgements/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.
    """
    if not search_text.strip() or page < 1 or not 1 <= page_size <= 100:
        raise ValueError("Search needs text, positive page and page_size between 1 and 100")
    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,
    max_session_refreshes: int = 2,
    max_pages: int = 1000
) -> 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_judgements/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,
    max_session_refreshes: int = 2,
    max_pages: int = 1000,
) -> AsyncIterator[SearchResult]:
    """Iterate through every page of results, yielding one SearchResult
    per page. Re-authenticates if the session token expires mid-walk."""
    if max_pages < 1 or max_session_refreshes < 0 or not 1 <= page_size <= 100:
        raise ValueError("Invalid pagination budget")
    refreshes = 0
    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 page <= max_pages:
        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:
            if refreshes >= max_session_refreshes:
                raise
            refreshes += 1
            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_judgements/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_judgements/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

Calcutta High Court

Order search returns a pair of case metadata and orders. See Calcutta retrieval.

CalcuttaHCClient

CalcuttaHCClient(
    config: BharatJudgementsConfig | 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_judgements/calcuttahc/client.py
def __init__(
    self,
    config: BharatJudgementsConfig | 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:
        context = create_legacy_ssl_context()
        verify_ssl = self._config.calcutta_verify_ssl
        if verify_ssl is None:
            verify_ssl = self._config.verify_ssl
        if not verify_ssl:
            context.check_hostname = False
            context.verify_mode = ssl.CERT_NONE
        self._http = RateLimitedClient(self._config, ssl_context=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 | None = None
) -> 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 | None

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).

None

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_judgements/calcuttahc/client.py
async def search_orders(
    self,
    *,
    case_type: str,
    case_number: str,
    year: str,
    establishment: str = "appellate",
    max_captcha_attempts: int | None = None,
) -> 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, [])``.
    """
    max_captcha_attempts = (
        self._config.captcha_attempts if max_captcha_attempts is None else max_captcha_attempts
    )
    if not 1 <= max_captcha_attempts <= 10:
        raise ValueError("max_captcha_attempts must be between 1 and 10")
    est_code = endpoints.ESTABLISHMENTS.get(establishment.lower(), establishment)
    if est_code not in endpoints.ESTABLISHMENTS.values():
        raise ValueError("Unsupported Calcutta establishment")
    if not case_type.isdigit() or not case_number.isdigit() or not year.isdigit():
        raise ValueError("case_type, case_number and year must be numeric strings")

    # 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 ValueError as e:
            logger.warning("Failed to parse response on attempt %d: %s", attempt + 1, e)
            continue

    if search_data is None:
        raise RuntimeError(
            f"Calcutta portal search failed after {max_captcha_attempts} CAPTCHA attempts"
        )

    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_judgements/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

Supreme Court feed

The current client supports recent feed entries and their PDFs. Year and party search methods raise NotImplementedError. See Supreme Court source choices.

SCIClient

SCIClient(
    config: BharatJudgementsConfig | 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_judgements/sci/client.py
def __init__(
    self,
    config: BharatJudgementsConfig | 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_judgements/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_judgements/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_judgements/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_judgements/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."
    )