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 +23 -0
- docmax/cli/__init__.py +8 -0
- docmax/cli/main.py +99 -0
- docmax/cli/render.py +58 -0
- docmax/cloud_client/__init__.py +35 -0
- docmax/cloud_client/client.py +343 -0
- docmax/cloud_client/config.py +84 -0
- docmax/cloud_client/errors.py +95 -0
- docmax/cloud_client/models.py +189 -0
- docmax/core/__init__.py +12 -0
- docmax/core/atomic.py +282 -0
- docmax/core/branding.py +53 -0
- docmax/core/cancellation.py +235 -0
- docmax/core/errors.py +472 -0
- docmax/core/models.py +174 -0
- docmax/core/protocols.py +107 -0
- docmax/core/registry.py +248 -0
- docmax/py.typed +0 -0
- docmax/tools/__init__.py +15 -0
- docmax/tools/merge/__init__.py +9 -0
- docmax/tools/merge/local.py +80 -0
- docmax/tools/merge/tool.py +35 -0
- docmax/tools/merge/validators.py +63 -0
- docmax/tools/ocr/__init__.py +10 -0
- docmax/tools/ocr/cloud.py +78 -0
- docmax/tools/ocr/local.py +96 -0
- docmax/tools/ocr/tool.py +44 -0
- docmax/tools/ocr/validators.py +37 -0
- docmaxv3-3.0.0a7.dist-info/METADATA +196 -0
- docmaxv3-3.0.0a7.dist-info/RECORD +34 -0
- docmaxv3-3.0.0a7.dist-info/WHEEL +5 -0
- docmaxv3-3.0.0a7.dist-info/entry_points.txt +2 -0
- docmaxv3-3.0.0a7.dist-info/licenses/LICENSE +21 -0
- docmaxv3-3.0.0a7.dist-info/top_level.txt +1 -0
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"]
|