mcp-dharmamitra 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ .git
2
+ .gitignore
3
+ .idea
4
+ .venv
5
+ .pytest_cache
6
+ __pycache__
7
+ **/__pycache__
8
+ **/*.pyc
9
+ tests
10
+ tmp
11
+ *.md
12
+ !README.md
13
+ Dockerfile
14
+ .dockerignore
15
+ uv.lock
@@ -0,0 +1,20 @@
1
+ .idea/
2
+ .vscode/
3
+ .venv/
4
+
5
+ .claude/settings.local.json
6
+
7
+ .DS_Store
8
+ ._*
9
+ .~*#
10
+ *.pyc
11
+ *.swp
12
+ *.swo
13
+ *.swn
14
+ .svn
15
+ *.orig
16
+ *.egg-info
17
+
18
+ /bandit-report.html
19
+
20
+ /tmp/
@@ -0,0 +1,60 @@
1
+ # mcp-dharmamitra
2
+
3
+ Python MCP server that runs OCR on an image via `https://dharmamitra.org/bff/api/ocr`
4
+ and writes the extracted text to a local file. Aimed at Tibetan / Sanskrit /
5
+ Devanagari sources.
6
+
7
+ ## Layout
8
+
9
+ ```
10
+ src/mcp_dharmamitra/
11
+ server.py # MCPServer instance + @mcp.tool() ocr_image
12
+ ocr_client.py # submit_ocr() + poll_status() against Dharmamitra
13
+ __main__.py # python -m mcp_dharmamitra
14
+ tests/
15
+ test_ocr_client.py # respx-mocked
16
+ ```
17
+
18
+ The server exposes exactly one tool, `ocr_image(image_path, output_path, ...)`.
19
+ It writes only `result.extracted_text` to `output_path` and returns a small
20
+ JSON summary — full text is intentionally *not* returned to the MCP client
21
+ (keeps responses compact and cheap).
22
+
23
+ ## Commands
24
+
25
+ ```bash
26
+ uv sync --extra dev # install
27
+ uv run pytest -q # tests (5 cases, respx-mocked, offline)
28
+ uv run mcp-dharmamitra # run server over stdio
29
+ uv run mcp dev src/mcp_dharmamitra/server.py # MCP inspector
30
+ ```
31
+
32
+ ## Gotchas
33
+
34
+ - **`mcp` package is 2.x.** We use `from mcp.server.mcpserver import MCPServer`
35
+ (renamed from `FastMCP` in 2.0). Do not import `mcp.server.fastmcp` — it errors
36
+ at import time in 2.x. API surface (`.tool()`, `.run()`, `.list_tools()`) is
37
+ the same.
38
+ - **Cloudflare / `cf_clearance`.** The endpoint sits behind Cloudflare. First
39
+ try without a cookie — usually works. On 403 the client raises
40
+ `DharmamitraForbiddenError`; the fix is to set `DHARMAMITRA_COOKIE` env var to
41
+ a fresh `cf_clearance` value grabbed from a browser. Never hardcode it —
42
+ Cloudflare rotates it.
43
+ - **Polling.** `poll_status` loops with `asyncio.sleep(interval)` until status
44
+ is `completed` (success), any other terminal status like `failed`/`error`
45
+ (raises `DharmamitraError`), or the wall-clock deadline hits
46
+ (`TimeoutError`). Defaults: 2s interval, 300s timeout.
47
+ - **HTTP client lifecycle.** Both `submit_ocr` and `poll_status` create their
48
+ own `httpx.AsyncClient` if none is passed, and close it. Tests pass in a
49
+ mocked transport via `respx`, which patches the default client.
50
+
51
+ ## When editing
52
+
53
+ - Keep the tool output small (summary dict). Do not return `extracted_text`
54
+ itself — it can be tens of KB of CJK / Tibetan text and blows up token cost
55
+ on the client side.
56
+ - Add new API params (`transliterate_*`, `model`, `instruction`) as tool
57
+ arguments with sensible defaults matching the Dharmamitra web UI (`false`,
58
+ `"auto"`, empty string).
59
+ - If you add polling backoff or retries, keep the terminal-status set in
60
+ `ocr_client.TERMINAL_STATUSES` in sync.
@@ -0,0 +1,25 @@
1
+ FROM python:3.12-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1
4
+ ENV PYTHONUNBUFFERED=1
5
+ ENV PIP_DISABLE_PIP_VERSION_CHECK=1
6
+ ENV PIP_NO_CACHE_DIR=1
7
+ ENV USER app
8
+ ENV GROUP app
9
+ ENV WORKDIR /data
10
+
11
+ WORKDIR ${WORKDIR}
12
+
13
+ COPY pyproject.toml README.md ./
14
+ COPY src ./src
15
+
16
+ RUN pip install --no-cache-dir .
17
+
18
+ RUN useradd --create-home --uid 1000 ${USER} \
19
+ && mkdir -p ${WORKDIR} \
20
+ && chown -R ${USER}:${GROUP} ${WORKDIR}
21
+
22
+ USER ${USER}
23
+ WORKDIR ${WORKDIR}
24
+
25
+ ENTRYPOINT ["mcp-dharmamitra"]
@@ -0,0 +1,163 @@
1
+ Metadata-Version: 2.5
2
+ Name: mcp-dharmamitra
3
+ Version: 0.1.0
4
+ Summary: MCP server for Dharmamitra OCR (Tibetan/Sanskrit/Devanagari)
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: httpx>=0.27.0
7
+ Requires-Dist: mcp[cli]>=2.0.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
10
+ Requires-Dist: pytest>=8.0; extra == 'dev'
11
+ Requires-Dist: respx>=0.21; extra == 'dev'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # mcp-dharmamitra
15
+
16
+ <!-- mcp-name: io.github.alex-deus/mcp-dharmamitra -->
17
+
18
+ MCP server that runs OCR on an image via the public
19
+ [Dharmamitra](https://dharmamitra.org) OCR endpoint and writes the extracted
20
+ text to a local file. Optimised for Tibetan / Sanskrit / Devanagari input.
21
+
22
+ ## Tool
23
+
24
+ ### `ocr_image`
25
+
26
+ | Argument | Type | Default | Notes |
27
+ | --- | --- | --- | --- |
28
+ | `image_path` | `str` | — | Absolute path to a local image (png/jpg/…). |
29
+ | `output_path` | `str` | — | File to write UTF-8 text into. Parent dirs are created. |
30
+ | `transliterate_devanagari_to_iast` | `bool` | `false` | Passed to the API. |
31
+ | `transliterate_tibetan_to_wylie` | `bool` | `false` | Passed to the API. |
32
+ | `instruction` | `str` | `""` | Optional model instruction. |
33
+ | `model` | `str` | `"auto"` | OCR model id. |
34
+ | `poll_interval_seconds` | `float` | `2.0` | Delay between status polls. |
35
+ | `timeout_seconds` | `float` | `300.0` | Overall polling deadline. |
36
+
37
+ Returns a small JSON summary — the extracted text is written to `output_path`,
38
+ not returned to the client.
39
+
40
+ ```json
41
+ {
42
+ "job_id": "b8b26984b3ca49199c28303cad6a144d",
43
+ "output_path": "/abs/path/out.txt",
44
+ "pages": 1,
45
+ "processing_time_seconds": 3.957,
46
+ "chars": 1234
47
+ }
48
+ ```
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ uv sync # or: pip install -e '.[dev]'
54
+ ```
55
+
56
+ Run locally with the MCP inspector:
57
+
58
+ ```bash
59
+ uv run mcp dev src/mcp_dharmamitra/server.py
60
+ ```
61
+
62
+ Run tests:
63
+
64
+ ```bash
65
+ uv run pytest
66
+ ```
67
+
68
+ ## Wire into Claude Desktop / Claude Code
69
+
70
+ `~/Library/Application Support/Claude/claude_desktop_config.json`:
71
+
72
+ ```json
73
+ {
74
+ "mcpServers": {
75
+ "dharmamitra": {
76
+ "command": "uv",
77
+ "args": ["--directory", "/path/mcp-dharmamitra", "run", "mcp-dharmamitra"],
78
+ "env": {"DHARMAMITRA_COOKIE": ""}
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
84
+ ## Docker
85
+
86
+ Build the image:
87
+
88
+ ```bash
89
+ docker build -t mcp-dharmamitra:latest .
90
+ ```
91
+
92
+ MCP talks over stdio, so the container must be run with `-i` (no `-t`). Mount
93
+ a host directory that holds the input images — the tool arguments
94
+ (`image_path`, `output_path`) are paths **inside the container**.
95
+
96
+ Smoke-test the entrypoint:
97
+
98
+ ```bash
99
+ docker run --rm --entrypoint python mcp-dharmamitra:latest \
100
+ -c "from mcp_dharmamitra.server import mcp; print(mcp.name)"
101
+ ```
102
+
103
+ ### Wire the container into Claude Desktop / Claude Code
104
+
105
+ Replace `/Users/aleksei/projects/ai/mcp-dharmamitra/tmp` with any host
106
+ directory you want the tool to be able to read/write. Files inside it will be
107
+ visible under `/data` in the container.
108
+
109
+ ```json
110
+ {
111
+ "mcpServers": {
112
+ "dharmamitra": {
113
+ "command": "docker",
114
+ "args": [
115
+ "run", "-i", "--rm",
116
+ "-v", "/Users/aleksei/projects/ai/mcp-dharmamitra/tmp:/data",
117
+ "-e", "DHARMAMITRA_COOKIE",
118
+ "mcp-dharmamitra:latest"
119
+ ],
120
+ "env": {
121
+ "DHARMAMITRA_COOKIE": ""
122
+ }
123
+ }
124
+ }
125
+ }
126
+ ```
127
+
128
+ Or the same via CLI:
129
+
130
+ ```bash
131
+ claude mcp add dharmamitra \
132
+ --scope project \
133
+ -e DHARMAMITRA_COOKIE="" \
134
+ -- docker run -i --rm \
135
+ -v /Users/aleksei/projects/ai/mcp-dharmamitra/tmp:/data \
136
+ -e DHARMAMITRA_COOKIE \
137
+ mcp-dharmamitra:latest
138
+ ```
139
+
140
+ When calling the tool, pass container paths. Example — image at
141
+ `<host>/tmp/text.png` → `/data/text.png`:
142
+
143
+ ```json
144
+ {
145
+ "image_path": "/data/text.png",
146
+ "output_path": "/data/text.txt"
147
+ }
148
+ ```
149
+
150
+ The resulting `text.txt` appears in the mounted host directory.
151
+
152
+ ## Cloudflare / `cf_clearance`
153
+
154
+ The endpoint sits behind Cloudflare. Most requests go through without any
155
+ cookie. If you start getting `403`, grab a fresh `cf_clearance` value from a
156
+ logged-in browser session on `dharmamitra.org` and export it:
157
+
158
+ ```bash
159
+ export DHARMAMITRA_COOKIE='cf_clearance value here'
160
+ ```
161
+
162
+ The server will attach it as a cookie on every request. Do not hardcode it —
163
+ Cloudflare rotates the value.
@@ -0,0 +1,150 @@
1
+ # mcp-dharmamitra
2
+
3
+ <!-- mcp-name: io.github.alex-deus/mcp-dharmamitra -->
4
+
5
+ MCP server that runs OCR on an image via the public
6
+ [Dharmamitra](https://dharmamitra.org) OCR endpoint and writes the extracted
7
+ text to a local file. Optimised for Tibetan / Sanskrit / Devanagari input.
8
+
9
+ ## Tool
10
+
11
+ ### `ocr_image`
12
+
13
+ | Argument | Type | Default | Notes |
14
+ | --- | --- | --- | --- |
15
+ | `image_path` | `str` | — | Absolute path to a local image (png/jpg/…). |
16
+ | `output_path` | `str` | — | File to write UTF-8 text into. Parent dirs are created. |
17
+ | `transliterate_devanagari_to_iast` | `bool` | `false` | Passed to the API. |
18
+ | `transliterate_tibetan_to_wylie` | `bool` | `false` | Passed to the API. |
19
+ | `instruction` | `str` | `""` | Optional model instruction. |
20
+ | `model` | `str` | `"auto"` | OCR model id. |
21
+ | `poll_interval_seconds` | `float` | `2.0` | Delay between status polls. |
22
+ | `timeout_seconds` | `float` | `300.0` | Overall polling deadline. |
23
+
24
+ Returns a small JSON summary — the extracted text is written to `output_path`,
25
+ not returned to the client.
26
+
27
+ ```json
28
+ {
29
+ "job_id": "b8b26984b3ca49199c28303cad6a144d",
30
+ "output_path": "/abs/path/out.txt",
31
+ "pages": 1,
32
+ "processing_time_seconds": 3.957,
33
+ "chars": 1234
34
+ }
35
+ ```
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ uv sync # or: pip install -e '.[dev]'
41
+ ```
42
+
43
+ Run locally with the MCP inspector:
44
+
45
+ ```bash
46
+ uv run mcp dev src/mcp_dharmamitra/server.py
47
+ ```
48
+
49
+ Run tests:
50
+
51
+ ```bash
52
+ uv run pytest
53
+ ```
54
+
55
+ ## Wire into Claude Desktop / Claude Code
56
+
57
+ `~/Library/Application Support/Claude/claude_desktop_config.json`:
58
+
59
+ ```json
60
+ {
61
+ "mcpServers": {
62
+ "dharmamitra": {
63
+ "command": "uv",
64
+ "args": ["--directory", "/path/mcp-dharmamitra", "run", "mcp-dharmamitra"],
65
+ "env": {"DHARMAMITRA_COOKIE": ""}
66
+ }
67
+ }
68
+ }
69
+ ```
70
+
71
+ ## Docker
72
+
73
+ Build the image:
74
+
75
+ ```bash
76
+ docker build -t mcp-dharmamitra:latest .
77
+ ```
78
+
79
+ MCP talks over stdio, so the container must be run with `-i` (no `-t`). Mount
80
+ a host directory that holds the input images — the tool arguments
81
+ (`image_path`, `output_path`) are paths **inside the container**.
82
+
83
+ Smoke-test the entrypoint:
84
+
85
+ ```bash
86
+ docker run --rm --entrypoint python mcp-dharmamitra:latest \
87
+ -c "from mcp_dharmamitra.server import mcp; print(mcp.name)"
88
+ ```
89
+
90
+ ### Wire the container into Claude Desktop / Claude Code
91
+
92
+ Replace `/Users/aleksei/projects/ai/mcp-dharmamitra/tmp` with any host
93
+ directory you want the tool to be able to read/write. Files inside it will be
94
+ visible under `/data` in the container.
95
+
96
+ ```json
97
+ {
98
+ "mcpServers": {
99
+ "dharmamitra": {
100
+ "command": "docker",
101
+ "args": [
102
+ "run", "-i", "--rm",
103
+ "-v", "/Users/aleksei/projects/ai/mcp-dharmamitra/tmp:/data",
104
+ "-e", "DHARMAMITRA_COOKIE",
105
+ "mcp-dharmamitra:latest"
106
+ ],
107
+ "env": {
108
+ "DHARMAMITRA_COOKIE": ""
109
+ }
110
+ }
111
+ }
112
+ }
113
+ ```
114
+
115
+ Or the same via CLI:
116
+
117
+ ```bash
118
+ claude mcp add dharmamitra \
119
+ --scope project \
120
+ -e DHARMAMITRA_COOKIE="" \
121
+ -- docker run -i --rm \
122
+ -v /Users/aleksei/projects/ai/mcp-dharmamitra/tmp:/data \
123
+ -e DHARMAMITRA_COOKIE \
124
+ mcp-dharmamitra:latest
125
+ ```
126
+
127
+ When calling the tool, pass container paths. Example — image at
128
+ `<host>/tmp/text.png` → `/data/text.png`:
129
+
130
+ ```json
131
+ {
132
+ "image_path": "/data/text.png",
133
+ "output_path": "/data/text.txt"
134
+ }
135
+ ```
136
+
137
+ The resulting `text.txt` appears in the mounted host directory.
138
+
139
+ ## Cloudflare / `cf_clearance`
140
+
141
+ The endpoint sits behind Cloudflare. Most requests go through without any
142
+ cookie. If you start getting `403`, grab a fresh `cf_clearance` value from a
143
+ logged-in browser session on `dharmamitra.org` and export it:
144
+
145
+ ```bash
146
+ export DHARMAMITRA_COOKIE='cf_clearance value here'
147
+ ```
148
+
149
+ The server will attach it as a cookie on every request. Do not hardcode it —
150
+ Cloudflare rotates the value.
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "mcp-dharmamitra"
3
+ version = "0.1.0"
4
+ description = "MCP server for Dharmamitra OCR (Tibetan/Sanskrit/Devanagari)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "mcp[cli]>=2.0.0",
9
+ "httpx>=0.27.0",
10
+ ]
11
+
12
+ [project.scripts]
13
+ mcp-dharmamitra = "mcp_dharmamitra.server:main"
14
+
15
+ [project.optional-dependencies]
16
+ dev = [
17
+ "pytest>=8.0",
18
+ "pytest-asyncio>=0.24",
19
+ "respx>=0.21",
20
+ ]
21
+
22
+ [build-system]
23
+ requires = ["hatchling"]
24
+ build-backend = "hatchling.build"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/mcp_dharmamitra"]
28
+
29
+ [tool.pytest.ini_options]
30
+ asyncio_mode = "auto"
31
+ testpaths = ["tests"]
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.alex-deus/mcp-dharmamitra",
4
+ "description": "OCR for Tibetan / Sanskrit / Devanagari images via the Dharmamitra API; writes extracted text to a local file.",
5
+ "repository": {
6
+ "url": "https://github.com/alex-deus/mcp-dharmamitra",
7
+ "source": "github"
8
+ },
9
+ "version": "0.1.0",
10
+ "packages": [
11
+ {
12
+ "registryType": "pypi",
13
+ "identifier": "mcp-dharmamitra",
14
+ "version": "0.1.0",
15
+ "runtimeHint": "uvx",
16
+ "transport": {
17
+ "type": "stdio"
18
+ },
19
+ "environmentVariables": [
20
+ {
21
+ "name": "DHARMAMITRA_COOKIE",
22
+ "description": "Optional cf_clearance cookie value. Only needed if requests start returning 403 from Cloudflare.",
23
+ "isRequired": false,
24
+ "isSecret": true,
25
+ "format": "string"
26
+ }
27
+ ]
28
+ }
29
+ ]
30
+ }
@@ -0,0 +1,3 @@
1
+ from .server import mcp, main
2
+
3
+ __all__ = ["mcp", "main"]
@@ -0,0 +1,4 @@
1
+ from .server import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import mimetypes
5
+ import os
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ BASE_URL = "https://dharmamitra.org/bff/api/ocr"
13
+ DEFAULT_UA = (
14
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
15
+ "(KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36"
16
+ )
17
+ TERMINAL_STATUSES = {"completed", "failed", "error"}
18
+
19
+
20
+ class DharmamitraError(RuntimeError):
21
+ """Raised when the Dharmamitra OCR API returns a failure."""
22
+
23
+
24
+ class DharmamitraForbiddenError(PermissionError):
25
+ """Raised on HTTP 403 — likely Cloudflare requires cf_clearance cookie."""
26
+
27
+
28
+ def _default_headers() -> dict[str, str]:
29
+ return {
30
+ "accept": "*/*",
31
+ "origin": "https://dharmamitra.org",
32
+ "referer": "https://dharmamitra.org/translate",
33
+ "user-agent": DEFAULT_UA,
34
+ }
35
+
36
+
37
+ def _cookies_from_env() -> dict[str, str] | None:
38
+ cookie = os.environ.get("DHARMAMITRA_COOKIE", "").strip()
39
+ if not cookie:
40
+ return None
41
+ return {"cf_clearance": cookie}
42
+
43
+
44
+ def _build_client(client: httpx.AsyncClient | None) -> tuple[httpx.AsyncClient, bool]:
45
+ if client is not None:
46
+ return client, False
47
+ return (
48
+ httpx.AsyncClient(
49
+ timeout=httpx.Timeout(60.0, connect=10.0),
50
+ headers=_default_headers(),
51
+ cookies=_cookies_from_env(),
52
+ ),
53
+ True,
54
+ )
55
+
56
+
57
+ async def submit_ocr(
58
+ image_path: Path,
59
+ *,
60
+ transliterate_devanagari_to_iast: bool = False,
61
+ transliterate_tibetan_to_wylie: bool = False,
62
+ instruction: str = "",
63
+ model: str = "auto",
64
+ client: httpx.AsyncClient | None = None,
65
+ ) -> str:
66
+ """POST the image to Dharmamitra OCR. Returns job_id."""
67
+ image_path = Path(image_path)
68
+ if not image_path.is_file():
69
+ raise FileNotFoundError(f"image not found: {image_path}")
70
+
71
+ mime = mimetypes.guess_type(image_path.name)[0] or "application/octet-stream"
72
+ data = {
73
+ "transliterate_devanagari_to_iast": "true" if transliterate_devanagari_to_iast else "false",
74
+ "transliterate_tibetan_to_wylie": "true" if transliterate_tibetan_to_wylie else "false",
75
+ "instruction": instruction,
76
+ "model": model,
77
+ }
78
+
79
+ ac, owns = _build_client(client)
80
+ try:
81
+ with image_path.open("rb") as fh:
82
+ files = {"file": (image_path.name, fh, mime)}
83
+ resp = await ac.post(BASE_URL, data=data, files=files)
84
+ _raise_for_status(resp)
85
+ payload = resp.json()
86
+ finally:
87
+ if owns:
88
+ await ac.aclose()
89
+
90
+ job_id = payload.get("job_id")
91
+ if not job_id:
92
+ raise DharmamitraError(f"submit response missing job_id: {payload!r}")
93
+ return job_id
94
+
95
+
96
+ async def poll_status(
97
+ job_id: str,
98
+ *,
99
+ interval: float = 2.0,
100
+ timeout: float = 300.0,
101
+ client: httpx.AsyncClient | None = None,
102
+ ) -> dict[str, Any]:
103
+ """Poll status endpoint until the job reaches a terminal state.
104
+
105
+ Returns the final status payload on success. Raises DharmamitraError on
106
+ failure, TimeoutError if the deadline is exceeded.
107
+ """
108
+ url = f"{BASE_URL}/status"
109
+ ac, owns = _build_client(client)
110
+ deadline = time.monotonic() + timeout
111
+ last_payload: dict[str, Any] | None = None
112
+ try:
113
+ while True:
114
+ resp = await ac.get(url, params={"jobId": job_id})
115
+ _raise_for_status(resp)
116
+ payload = resp.json()
117
+ last_payload = payload
118
+ status = payload.get("status")
119
+ if status == "completed":
120
+ return payload
121
+ if status in TERMINAL_STATUSES:
122
+ raise DharmamitraError(
123
+ f"job {job_id} ended with status={status!r} "
124
+ f"error={payload.get('error')!r} "
125
+ f"error_code={payload.get('error_code')!r}"
126
+ )
127
+ if time.monotonic() >= deadline:
128
+ raise TimeoutError(
129
+ f"job {job_id} still status={status!r} after {timeout}s"
130
+ )
131
+ await asyncio.sleep(interval)
132
+ finally:
133
+ if owns:
134
+ await ac.aclose()
135
+ # unreachable
136
+ return last_payload # type: ignore[return-value]
137
+
138
+
139
+ def _raise_for_status(resp: httpx.Response) -> None:
140
+ if resp.status_code == 403:
141
+ raise DharmamitraForbiddenError(
142
+ "Dharmamitra returned 403 (likely Cloudflare). "
143
+ "Set DHARMAMITRA_COOKIE env var to a fresh cf_clearance value."
144
+ )
145
+ if resp.status_code >= 400:
146
+ body = resp.text[:500]
147
+ raise DharmamitraError(
148
+ f"Dharmamitra HTTP {resp.status_code} for {resp.request.url}: {body}"
149
+ )