Metadata-Version: 2.4
Name: caselaw-access
Version: 0.1.0
Summary: Python client for the Case Law Explorer shared access service: authentication, endpoint scoping, and rate limiting as a drop-in guard.
License: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: httpx>=0.28.0
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"

# caselaw-access

Client SDK for the Case Law Explorer access service. One call decides
authentication, endpoint scope, and rate limit, so a product does not
implement any of them itself.

## Install

```bash
pip install caselaw-access
```

Until the package is on PyPI, build a wheel from this directory and vendor it:

```bash
pip wheel . --no-deps -w dist
cp dist/caselaw_access-*.whl ../../../<your-project>/vendor/
```

and reference it from `requirements.txt`:

```
./vendor/caselaw_access-0.1.0-py3-none-any.whl
```

Bump the version in `pyproject.toml` whenever the contents change — pip caches
wheels by version and will silently reuse a stale copy otherwise.

## Configuration

Everything comes from the environment:

| Variable | Meaning | Default |
| --- | --- | --- |
| `ACCESS_SERVICE_URL` | Base URL of the access service. Empty disables all checks. | — |
| `ACCESS_PROJECT` | Project slug registered in the access console. | `citations-api` |
| `ACCESS_SERVICE_TIMEOUT_SECONDS` | Per-check timeout. | `5` |
| `ACCESS_FAIL_OPEN` | Allow traffic when the service is unreachable. | `false` |
| `REQUIRE_AUTH` / `ACCESS_ENFORCE` | Master switch for enforcement. | `false` |

Checks are only sent when the service URL is set **and** enforcement is on.
That combination is what lets a service run locally, and its tests run in CI,
without the control plane.

## Direct use

```python
from caselaw_access import AccessClient, RateLimited, PermissionDenied

client = AccessClient.from_env()

try:
    decision = client.check(token, endpoint="echr", method="POST")
except RateLimited as exc:
    ...  # exc.status == 429, exc.headers() has Retry-After
except PermissionDenied as exc:
    ...  # exc.status == 403
```

An allowed `Decision` carries the resolved `principal`, the matched `plan`,
the granted `scopes`, and the remaining quota. `decision.headers()` returns
the `X-RateLimit-Remaining-*` headers to attach to the response.

## `http.server` handlers

```python
from caselaw_access.http_server import AccessGuard

guard = AccessGuard(send_error=my_json_writer)

class handler(BaseHTTPRequestHandler):
    @guard.protect("echr", methods=["POST"])
    def do_POST(self):
        ...
```

The decorated method runs only when the call is allowed; a denial is written
before the body executes. The decision is attached to the handler as
`access_decision` and `access_principal`, and the headers to emit as
`access_headers`.

The verb reported to the service is the request's real method
(`handler.command`). `methods` only shapes the CORS header on an error
response.

## Failure semantics

| Situation | Result |
| --- | --- |
| No or malformed `Authorization` header | `401`, service never called |
| Service answers 401 | `401` with the service's code |
| Service answers 403, or `allowed: false` with a scope error | `403` |
| Service answers 429, or `allowed: false` with `rate_limit_exceeded` | `429` with `Retry-After` |
| Service unreachable, times out, or answers 4xx/5xx it cannot decide on | `503`, or allowed when `ACCESS_FAIL_OPEN=true` |

Connection failures are retried because the request provably never arrived.
Read timeouts are **not** retried: the service may already have counted the
call against the caller's quota, and a retry would charge them twice.

Behaviour is pinned by the shared conformance suite in
[`../contract`](../contract), which this package runs in `tests/test_conformance.py`.
