DocmaxV3 3.0.0a7__py3-none-any.whl

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.
docmax/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """DocMax — terminal-native document toolkit.
2
+
3
+ Importing this package must stay cheap. It pulls in no heavy dependency, no
4
+ tool implementation, and no UI framework; ``tests/hygiene/test_no_heavy_imports.py``
5
+ asserts as much by checking ``sys.modules`` after a bare import.
6
+
7
+ That matters because non-negotiable #3 says the base install stays light: the
8
+ TUI shell and cloud client are all a user gets until they first invoke a local
9
+ engine that genuinely needs more.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from importlib.metadata import PackageNotFoundError, version
15
+
16
+ from docmax.core.branding import DIST_NAME
17
+
18
+ try:
19
+ __version__ = version(DIST_NAME)
20
+ except PackageNotFoundError: # running from a source tree without an install
21
+ __version__ = "0.0.0.dev0"
22
+
23
+ __all__ = ["__version__"]
docmax/cli/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Command-line interface.
2
+
3
+ The only layer permitted to write to stdout, and the only layer permitted to
4
+ terminate the process. Library code raises typed errors; this layer decides how
5
+ to render them and what exit code to use.
6
+ """
7
+
8
+ from __future__ import annotations
docmax/cli/main.py ADDED
@@ -0,0 +1,99 @@
1
+ """CLI entry point.
2
+
3
+ This is the boundary where typed errors become exit codes. Library code raises;
4
+ only this layer calls ``typer.Exit``. ``tests/hygiene/test_no_sys_exit.py``
5
+ enforces the asymmetry by scanning ``core``, ``tools``, and ``cloud_client`` for
6
+ any process-terminating call.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ from typing import Annotated
13
+
14
+ import typer
15
+
16
+ from docmax import __version__
17
+ from docmax.cli.render import console, out
18
+ from docmax.core.branding import APP_NAME, CLI_NAME, HOMEPAGE
19
+
20
+ app = typer.Typer(
21
+ name=CLI_NAME,
22
+ help=f"{APP_NAME} — terminal-native document toolkit. Local-first, no server required.",
23
+ epilog=f"Docs: {HOMEPAGE}",
24
+ add_completion=True,
25
+ rich_markup_mode="rich",
26
+ no_args_is_help=True,
27
+ )
28
+
29
+ #: External binaries a local engine may need, with the tools that depend on them.
30
+ #: ``docmax setup`` (M3) installs these; ``doctor`` only reports.
31
+ EXTERNAL_BINARIES: dict[str, tuple[str, ...]] = {
32
+ "tesseract": ("ocr",),
33
+ "gs": ("compress", "pdfa"),
34
+ "pdftoppm": ("ocr", "to-images"),
35
+ "pandoc": ("convert",),
36
+ }
37
+
38
+
39
+ def _version_callback(value: bool) -> None:
40
+ if value:
41
+ out.print(f"{APP_NAME} {__version__}")
42
+ raise typer.Exit
43
+
44
+
45
+ @app.callback()
46
+ def main(
47
+ _version: Annotated[
48
+ bool,
49
+ typer.Option(
50
+ "--version",
51
+ "-V",
52
+ callback=_version_callback,
53
+ is_eager=True,
54
+ help="Show version and exit.",
55
+ ),
56
+ ] = False,
57
+ ) -> None:
58
+ """Root callback. Global options live here."""
59
+
60
+
61
+ @app.command()
62
+ def doctor() -> None:
63
+ """Report the status of external tools the local engines depend on.
64
+
65
+ Reports only — never mutates. ``docmax setup`` (M3) does the installing, and
66
+ unlike v2's version it will be idempotent, support --dry-run, and verify
67
+ afterwards rather than assuming success.
68
+ """
69
+ from rich.table import Table
70
+
71
+ table = Table(title="External tool status", title_justify="left")
72
+ table.add_column("Tool")
73
+ table.add_column("Status")
74
+ table.add_column("Path")
75
+ table.add_column("Needed by")
76
+
77
+ missing: list[str] = []
78
+ for binary, used_by in EXTERNAL_BINARIES.items():
79
+ path = shutil.which(binary)
80
+ if path:
81
+ table.add_row(binary, "[green]found[/green]", path, ", ".join(used_by))
82
+ else:
83
+ missing.append(binary)
84
+ table.add_row(binary, "[yellow]missing[/yellow]", "—", ", ".join(used_by))
85
+
86
+ out.print(table)
87
+
88
+ if missing:
89
+ console.print(
90
+ f"\n[yellow]{len(missing)} tool(s) missing.[/yellow] "
91
+ "Affected operations can still run via the Cloud Engine, "
92
+ f"or install locally with [bold]{CLI_NAME} setup[/bold] (coming in M3)."
93
+ )
94
+ else:
95
+ console.print("\n[green]All external tools available.[/green]")
96
+
97
+
98
+ if __name__ == "__main__": # pragma: no cover
99
+ app()
docmax/cli/render.py ADDED
@@ -0,0 +1,58 @@
1
+ """Terminal rendering. The only module that owns a ``rich.Console``.
2
+
3
+ v2 instantiated five separate ``Console`` objects across as many modules and
4
+ wrote to all of them from worker threads while a ``Progress`` live region was
5
+ active. The result was interleaved output and intermittent ``LiveError``
6
+ crashes. There is one console here, and from M9 onward worker threads will push
7
+ events onto a queue drained by a single render thread rather than printing
8
+ directly.
9
+
10
+ ``core`` never imports this module, or ``rich`` at all — progress crosses that
11
+ boundary as the ``ProgressSink`` protocol.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from typing import TYPE_CHECKING
18
+
19
+ from rich.console import Console
20
+ from rich.panel import Panel
21
+ from rich.text import Text
22
+
23
+ if TYPE_CHECKING:
24
+ from docmax.core.errors import DocMaxError
25
+
26
+ #: stderr, so that ``docmax get-info x.pdf --json | jq`` stays clean.
27
+ console = Console(stderr=True)
28
+
29
+ #: stdout, for actual command results.
30
+ out = Console()
31
+
32
+
33
+ def render_error(exc: DocMaxError, *, as_json: bool = False) -> None:
34
+ """Print a typed error as an actionable message — never a traceback.
35
+
36
+ Every anticipated failure carries a remedy naming the next step. If a user
37
+ sees a traceback, that is a bug in its own right: it means something escaped
38
+ the typed hierarchy.
39
+ """
40
+ if as_json:
41
+ out.print_json(json.dumps({"ok": False, "error": exc.to_dict()}))
42
+ return
43
+
44
+ body = Text()
45
+ body.append(exc.message)
46
+ if exc.remedy:
47
+ body.append("\n\n")
48
+ body.append("→ ", style="bold cyan")
49
+ body.append(exc.remedy)
50
+
51
+ console.print(
52
+ Panel(
53
+ body,
54
+ title=f"[bold red]{exc.code.value}[/bold red]",
55
+ border_style="red",
56
+ padding=(0, 1),
57
+ )
58
+ )
@@ -0,0 +1,35 @@
1
+ """Thin HTTP client for the Cloud Engine.
2
+
3
+ Fully open source and fully optional. The endpoint is configurable, so this
4
+ client talks to the hosted service by default and to a self-hosted deployment
5
+ if you point it elsewhere.
6
+
7
+ Cloud exists for one reason: to let someone use a tool without installing its
8
+ heavy local dependencies. It is never a default and never silent — see the
9
+ consent rules in ``core/router.py`` and the wire contract in ``docs/cloud-api.md``.
10
+
11
+ The layout mirrors that contract:
12
+
13
+ config.py which endpoint, which key, and the TLS rule
14
+ models.py the wire types, parsed defensively
15
+ errors.py error envelope -> typed exception, the inverse of server/errors.py
16
+ client.py requests, retries, idempotency, polling
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from docmax.cloud_client.client import CloudClient, idempotency_key, user_agent
22
+ from docmax.cloud_client.config import CloudConfig
23
+ from docmax.cloud_client.models import Capabilities, CloudJob, CloudOutput, JobStatus, UploadTicket
24
+
25
+ __all__ = [
26
+ "Capabilities",
27
+ "CloudClient",
28
+ "CloudConfig",
29
+ "CloudJob",
30
+ "CloudOutput",
31
+ "JobStatus",
32
+ "UploadTicket",
33
+ "idempotency_key",
34
+ "user_agent",
35
+ ]
@@ -0,0 +1,343 @@
1
+ """The HTTP client for the Cloud Engine.
2
+
3
+ Deliberately thin. It knows the wire contract in ``docs/cloud-api.md`` and
4
+ nothing else: it does not decide whether cloud should be used (the router does,
5
+ after checking consent), it does not know what a tool is beyond its name, and it
6
+ does not write files — :meth:`CloudClient.fetch_output` hands back bytes for the
7
+ caller to put through ``core/atomic.py``, because there is exactly one module in
8
+ this project permitted to touch a destination path.
9
+
10
+ Three behaviours are contract requirements rather than implementation choices:
11
+
12
+ * **Idempotency.** The key is derived from the input digest and the parameters,
13
+ so a retry after a dropped connection returns the original result instead of
14
+ re-running the job and billing it twice.
15
+ * **Server-controlled polling.** The client sleeps for ``poll_after_ms`` rather
16
+ than choosing an interval, so a busy endpoint can slow its callers down.
17
+ * **Retries only when retrying can help.** ``retryable`` decides. Re-sending a
18
+ bad API key or an exhausted quota just wastes the user's time.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ import secrets
26
+ import sys
27
+ import time
28
+ from typing import TYPE_CHECKING, Any
29
+
30
+ import httpx
31
+
32
+ from docmax import __version__
33
+ from docmax.cloud_client.config import CloudConfig
34
+ from docmax.cloud_client.errors import raise_for_error
35
+ from docmax.cloud_client.models import (
36
+ Capabilities,
37
+ CloudJob,
38
+ JobStatus,
39
+ UploadTicket,
40
+ as_mapping,
41
+ )
42
+ from docmax.core.branding import CLI_NAME
43
+ from docmax.core.errors import (
44
+ CloudAuthError,
45
+ CloudEngineUnavailableError,
46
+ CloudProtocolError,
47
+ CloudTimeoutError,
48
+ DocMaxError,
49
+ InternalError,
50
+ )
51
+
52
+ if TYPE_CHECKING:
53
+ from collections.abc import Mapping
54
+
55
+ from docmax.core.models import DocumentRef
56
+
57
+ IDEMPOTENCY_HEADER = "Idempotency-Key"
58
+
59
+ #: How long to keep waiting for one job before giving up on it entirely.
60
+ DEFAULT_JOB_TIMEOUT = 900.0
61
+
62
+ _CHUNK = 1 << 20
63
+
64
+
65
+ def user_agent() -> str:
66
+ """Identify the client, its Python, and its platform — nothing else."""
67
+ python = f"{sys.version_info.major}.{sys.version_info.minor}"
68
+ return f"{CLI_NAME}/{__version__} (python/{python}; {sys.platform})"
69
+
70
+
71
+ class CloudClient:
72
+ """One configured endpoint, one connection pool.
73
+
74
+ Constructing this opens no socket and makes no request, so the router can
75
+ build one to ask a cheap question and throw it away.
76
+ """
77
+
78
+ def __init__(
79
+ self,
80
+ config: CloudConfig | None = None,
81
+ *,
82
+ http: httpx.Client | None = None,
83
+ ) -> None:
84
+ self.config = config or CloudConfig.from_env()
85
+ #: Injectable so the test suite can drive the whole client through a
86
+ #: mock transport, which is how the contract is verified without a
87
+ #: server existing.
88
+ self._http_client = http
89
+ self._capabilities: Capabilities | None = None
90
+
91
+ # -- lifecycle ---------------------------------------------------------
92
+
93
+ def _http(self) -> httpx.Client:
94
+ if self._http_client is None:
95
+ if not self.config.is_configured:
96
+ raise CloudAuthError("No API key is configured for the cloud endpoint.")
97
+ self._http_client = httpx.Client(
98
+ base_url=self.config.endpoint,
99
+ headers={
100
+ "Authorization": f"Bearer {self.config.api_key}",
101
+ "User-Agent": user_agent(),
102
+ "Accept": "application/json",
103
+ },
104
+ timeout=httpx.Timeout(
105
+ self.config.read_timeout,
106
+ connect=self.config.connect_timeout,
107
+ ),
108
+ )
109
+ return self._http_client
110
+
111
+ def close(self) -> None:
112
+ if self._http_client is not None:
113
+ self._http_client.close()
114
+ self._http_client = None
115
+
116
+ def __enter__(self) -> CloudClient:
117
+ return self
118
+
119
+ def __exit__(self, *exc_info: object) -> None:
120
+ self.close()
121
+
122
+ # -- discovery ---------------------------------------------------------
123
+
124
+ def capabilities(self, *, refresh: bool = False) -> Capabilities:
125
+ """What this endpoint offers. Fetched once, then cached.
126
+
127
+ A self-hosted server implementing three of the five cloud tools is a
128
+ supported deployment, so "this endpoint cannot do that" is answered
129
+ before a document is uploaded rather than by a failed job.
130
+ """
131
+ if self._capabilities is None or refresh:
132
+ payload = self._request("GET", "/v1/capabilities")
133
+ self._capabilities = Capabilities.from_payload(payload)
134
+ return self._capabilities
135
+
136
+ # -- running a tool ----------------------------------------------------
137
+
138
+ def run(
139
+ self,
140
+ tool: str,
141
+ doc: DocumentRef,
142
+ params: Mapping[str, Any] | None = None,
143
+ *,
144
+ timeout: float = DEFAULT_JOB_TIMEOUT,
145
+ ) -> CloudJob:
146
+ """Submit ``doc`` and block until the job reaches a terminal state."""
147
+ return self.wait(self.submit(tool, doc, params), timeout=timeout)
148
+
149
+ def submit(
150
+ self,
151
+ tool: str,
152
+ doc: DocumentRef,
153
+ params: Mapping[str, Any] | None = None,
154
+ ) -> CloudJob:
155
+ """Send the work, taking whichever path the payload size calls for."""
156
+ resolved = dict(params or {})
157
+ if doc.size_bytes <= self._sync_limit():
158
+ return self._submit_sync(tool, doc, resolved)
159
+ return self._submit_large(tool, doc, resolved)
160
+
161
+ def poll(self, job_id: str) -> CloudJob:
162
+ """Ask after one job. Raises if the server reports it failed."""
163
+ body = self._request("GET", f"/v1/jobs/{job_id}")
164
+ if body.get("status") == JobStatus.FAILED.value:
165
+ raise_for_error(500, body)
166
+ return CloudJob.from_payload(body)
167
+
168
+ def wait(self, job: CloudJob, *, timeout: float = DEFAULT_JOB_TIMEOUT) -> CloudJob:
169
+ """Poll at the server's requested interval until the job settles."""
170
+ deadline = time.monotonic() + timeout
171
+ current = job
172
+ while not current.is_terminal:
173
+ if time.monotonic() >= deadline:
174
+ raise CloudTimeoutError(
175
+ f"The job did not finish within {timeout:.0f}s.",
176
+ remedy="Try again, or run locally with --engine local.",
177
+ context={"job_id": current.job_id},
178
+ )
179
+ time.sleep(current.poll_after_ms / 1000)
180
+ current = self.poll(current.job_id)
181
+ return current
182
+
183
+ def fetch_output(self, job: CloudJob) -> bytes:
184
+ """Download a finished job's document.
185
+
186
+ Returns bytes rather than writing them. The caller hands these to
187
+ ``core/atomic.py``, which is the only module allowed to touch a
188
+ destination path — so a cloud result is delivered under exactly the same
189
+ crash-safety guarantee as a local one.
190
+ """
191
+ if job.output is None:
192
+ raise CloudProtocolError(
193
+ "The job reported success but carried no output.",
194
+ context={"job_id": job.job_id},
195
+ )
196
+ return self._storage("GET", job.output.url).content
197
+
198
+ # -- the two submission paths ------------------------------------------
199
+
200
+ def _submit_sync(self, tool: str, doc: DocumentRef, params: dict[str, Any]) -> CloudJob:
201
+ """Small payloads: one multipart request, answered when the work is done."""
202
+ body = self._request(
203
+ "POST",
204
+ f"/v1/tools/{tool}",
205
+ files={"file": (doc.path.name, doc.path.read_bytes(), "application/octet-stream")},
206
+ data={"params": json.dumps(params, sort_keys=True)},
207
+ headers={IDEMPOTENCY_HEADER: idempotency_key(doc, params)},
208
+ )
209
+ return CloudJob.from_payload(body)
210
+
211
+ def _submit_large(self, tool: str, doc: DocumentRef, params: dict[str, Any]) -> CloudJob:
212
+ """Large payloads: presigned upload, then a job to poll."""
213
+ ticket = UploadTicket.from_payload(
214
+ self._request(
215
+ "POST",
216
+ "/v1/uploads",
217
+ json={"filename": doc.path.name, "size_bytes": doc.size_bytes},
218
+ )
219
+ )
220
+ self._upload(ticket, doc)
221
+ body = self._request(
222
+ "POST",
223
+ f"/v1/tools/{tool}",
224
+ json={"file_id": ticket.file_id, "params": params},
225
+ headers={IDEMPOTENCY_HEADER: idempotency_key(doc, params)},
226
+ )
227
+ return CloudJob.from_payload(body)
228
+
229
+ def _upload(self, ticket: UploadTicket, doc: DocumentRef) -> None:
230
+ """PUT the bytes straight to storage, never through the API."""
231
+ self._storage("PUT", ticket.upload_url, content=doc.path.read_bytes())
232
+
233
+ def _storage(self, method: str, url: str, *, content: bytes | None = None) -> httpx.Response:
234
+ """Transfer bytes to or from the storage host, through a bare client.
235
+
236
+ Deliberately not the authenticated one: a presigned URL points at
237
+ storage, not at the API, and our bearer token has no business being
238
+ sent there.
239
+ """
240
+ with httpx.Client(timeout=self._timeout()) as storage:
241
+ try:
242
+ response = storage.request(method, url, content=content)
243
+ except httpx.TimeoutException as exc:
244
+ raise CloudTimeoutError(
245
+ f"The transfer to storage timed out: {exc}",
246
+ context={"url": url},
247
+ ) from exc
248
+ except httpx.TransportError as exc:
249
+ raise CloudEngineUnavailableError(
250
+ f"The transfer to storage failed: {exc}",
251
+ context={"url": url},
252
+ ) from exc
253
+
254
+ if response.status_code >= 400:
255
+ raise_for_error(response.status_code, None)
256
+ return response
257
+
258
+ def _sync_limit(self) -> int:
259
+ """The server's own threshold when we know it, ours otherwise.
260
+
261
+ Never fetches capabilities just to answer this: a size check must not
262
+ cost a round trip.
263
+ """
264
+ if self._capabilities is not None:
265
+ return self._capabilities.max_sync_bytes
266
+ return self.config.max_sync_bytes
267
+
268
+ # -- transport ---------------------------------------------------------
269
+
270
+ def _timeout(self) -> httpx.Timeout:
271
+ return httpx.Timeout(self.config.read_timeout, connect=self.config.connect_timeout)
272
+
273
+ def _request(self, method: str, url: str, **kwargs: Any) -> Mapping[str, Any]:
274
+ """One request, retried while retrying can plausibly help."""
275
+ attempts = self.config.max_retries + 1
276
+ for attempt in range(1, attempts + 1):
277
+ try:
278
+ return as_mapping(_decode(self._raw(method, url, **kwargs)))
279
+ except DocMaxError as exc:
280
+ if not exc.retryable or attempt == attempts:
281
+ raise
282
+ time.sleep(_backoff(attempt))
283
+ raise InternalError("The retry loop finished without a result or an error.")
284
+
285
+ def _raw(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
286
+ """Send one request and turn transport and status failures into typed errors."""
287
+ try:
288
+ response = self._http().request(method, url, **kwargs)
289
+ except httpx.TimeoutException as exc:
290
+ raise CloudTimeoutError(
291
+ f"The cloud endpoint did not respond within {self.config.read_timeout:.0f}s.",
292
+ context={"url": url},
293
+ ) from exc
294
+ except httpx.TransportError as exc:
295
+ raise CloudEngineUnavailableError(
296
+ f"Could not reach the cloud endpoint: {exc}",
297
+ remedy="Check your connection, or run locally with --engine local.",
298
+ context={"url": url},
299
+ ) from exc
300
+
301
+ if response.status_code >= 400:
302
+ raise_for_error(response.status_code, _decode(response, strict=False))
303
+ return response
304
+
305
+
306
+ def idempotency_key(doc: DocumentRef, params: Mapping[str, Any]) -> str:
307
+ """Digest the input and the parameters, so a retry cannot run the job twice.
308
+
309
+ Same document plus same parameters means same key means the server returns
310
+ the original result. Anything that would change the output — a different
311
+ page range, a different language — changes the key.
312
+ """
313
+ digest = hashlib.sha256()
314
+ with doc.path.open("rb") as handle:
315
+ while chunk := handle.read(_CHUNK):
316
+ digest.update(chunk)
317
+ digest.update(json.dumps(params, sort_keys=True, separators=(",", ":")).encode())
318
+ return f"sha256:{digest.hexdigest()}"
319
+
320
+
321
+ def _decode(response: httpx.Response, *, strict: bool = True) -> object:
322
+ """Decode a JSON body, or say so in the contract's own terms."""
323
+ try:
324
+ return response.json()
325
+ except ValueError as exc:
326
+ if not strict:
327
+ return None
328
+ raise CloudProtocolError(
329
+ "The cloud endpoint returned a body that is not JSON.",
330
+ remedy="Check that the configured endpoint speaks this API version.",
331
+ context={"content_type": response.headers.get("content-type", "")},
332
+ ) from exc
333
+
334
+
335
+ def _backoff(attempt: int) -> float:
336
+ """Exponential, with jitter so concurrent clients do not resynchronise."""
337
+ base = min(2.0 ** (attempt - 1), 30.0)
338
+ # secrets, not random: not for secrecy, but because ruff's bandit rules
339
+ # (correctly) flag the stdlib PRNG and this is the cheaper answer.
340
+ return base + secrets.randbelow(1000) / 1000
341
+
342
+
343
+ __all__ = ["DEFAULT_JOB_TIMEOUT", "CloudClient", "idempotency_key", "user_agent"]
@@ -0,0 +1,84 @@
1
+ """Where the Cloud Engine lives and how we authenticate to it.
2
+
3
+ The endpoint is configuration, not a constant, because a self-hosted deployment
4
+ is a first-class case: point this elsewhere and everything above it is
5
+ identical. That is also why the TLS rule is enforced here rather than assumed —
6
+ a user-supplied endpoint is the one place a document could be sent over
7
+ plaintext by accident.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING
15
+ from urllib.parse import urlsplit
16
+
17
+ from docmax.core.branding import DEFAULT_CLOUD_ENDPOINT, ENV_PREFIX
18
+ from docmax.core.errors import InvalidParameterError
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Mapping
22
+
23
+ ENDPOINT_ENV = f"{ENV_PREFIX}CLOUD_ENDPOINT"
24
+ API_KEY_ENV = f"{ENV_PREFIX}API_KEY"
25
+
26
+ #: Plaintext is allowed only against these, so self-hosted development works
27
+ #: without a certificate while a real endpoint still cannot be misconfigured
28
+ #: into shipping documents in the clear.
29
+ LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
30
+
31
+ #: The contract's threshold between the synchronous path and presigned upload.
32
+ #: Overridden by whatever ``GET /v1/capabilities`` reports, since the server is
33
+ #: the authority on its own limits.
34
+ DEFAULT_MAX_SYNC_BYTES = 10 * 1024 * 1024
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class CloudConfig:
39
+ """Everything the client needs to talk to an endpoint."""
40
+
41
+ endpoint: str = DEFAULT_CLOUD_ENDPOINT
42
+ api_key: str | None = None
43
+ connect_timeout: float = 10.0
44
+ #: Generous, because OCR of a long document legitimately takes minutes.
45
+ #: A timeout is still mandatory: v2 had none anywhere and hung indefinitely.
46
+ read_timeout: float = 120.0
47
+ max_retries: int = 3
48
+ max_sync_bytes: int = DEFAULT_MAX_SYNC_BYTES
49
+
50
+ def __post_init__(self) -> None:
51
+ split = urlsplit(self.endpoint)
52
+ if split.scheme not in {"http", "https"} or not split.hostname:
53
+ raise InvalidParameterError(
54
+ f"The cloud endpoint is not a valid URL: {self.endpoint!r}",
55
+ remedy="Set a full URL, e.g. https://example.invalid",
56
+ context={"endpoint": self.endpoint},
57
+ )
58
+ if split.scheme == "http" and split.hostname not in LOCAL_HOSTS:
59
+ raise InvalidParameterError(
60
+ f"Refusing a plaintext endpoint: {self.endpoint!r}",
61
+ remedy="Use https://. Plaintext is permitted only for a local endpoint.",
62
+ context={"endpoint": self.endpoint},
63
+ )
64
+
65
+ @classmethod
66
+ def from_env(cls, env: Mapping[str, str] | None = None) -> CloudConfig:
67
+ """Read the endpoint and key from the environment.
68
+
69
+ The config file is the other source and takes lower precedence; that
70
+ merge belongs to ``core/config.py`` in M1, which will construct this.
71
+ """
72
+ source = os.environ if env is None else env
73
+ return cls(
74
+ endpoint=source.get(ENDPOINT_ENV, DEFAULT_CLOUD_ENDPOINT),
75
+ api_key=source.get(API_KEY_ENV) or None,
76
+ )
77
+
78
+ @property
79
+ def is_configured(self) -> bool:
80
+ """An API key is required from day one — anonymous access is not offered."""
81
+ return bool(self.api_key)
82
+
83
+
84
+ __all__ = ["API_KEY_ENV", "ENDPOINT_ENV", "CloudConfig"]