ironeye 1.0.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 @@
1
+ * text=auto eol=lf
@@ -0,0 +1,29 @@
1
+ # The build this repository has to pass. It runs on the supported floor and the
2
+ # current release of the toolchain, because a client library that only builds on
3
+ # the version its author happened to have installed is a support ticket waiting.
4
+ name: CI
5
+
6
+ on:
7
+ push:
8
+ branches: [main]
9
+ pull_request:
10
+ workflow_dispatch:
11
+
12
+ jobs:
13
+ build:
14
+ runs-on: ubuntu-latest
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ python: ["3.11", "3.12", "3.13"]
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: ${{ matrix.python }}
24
+ - run: pip install --upgrade pip build httpx
25
+ - name: Import the package from a clean install
26
+ run: |
27
+ pip install .
28
+ python -c "import ironeye; print(ironeye.__version__)"
29
+ - run: python -m build
@@ -0,0 +1,5 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ build/
5
+ .venv/
ironeye-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Direct Softworks SRL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ironeye-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,42 @@
1
+ Metadata-Version: 2.5
2
+ Name: ironeye
3
+ Version: 1.0.0
4
+ Summary: Official Python client for the IronEye document intelligence and collection API.
5
+ Project-URL: Documentation, https://ironeye.org/docs/sdk/python
6
+ Project-URL: Homepage, https://ironeye.org
7
+ Project-URL: Repository, https://github.com/IronEyeAPI/ironeye-python
8
+ Project-URL: Issues, https://github.com/IronEyeAPI/ironeye-python/issues
9
+ Author: Direct Softworks
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: document,gdpr,ironeye,malware,osint,pii,secrets
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: httpx>=0.27
15
+ Description-Content-Type: text/markdown
16
+
17
+ # IronEye for Python
18
+
19
+ The official Python client for the [IronEye](https://ironeye.org) API: document
20
+ analysis over bytes you send, and normalised collection from public sources,
21
+ behind one key.
22
+
23
+ ```sh
24
+ pip install ironeye
25
+ ```
26
+
27
+ ## Features
28
+
29
+ - Synchronous and `async` clients over one shared transport, so the two cannot
30
+ drift apart.
31
+ - Every analysis route, the async job path with `await_job`, the collection
32
+ catalogue and the data-subject-rights endpoints.
33
+ - `TypedDict` models, and one exception class per refusal family.
34
+ - Retries on the server's own `retryable` flag, honouring `Retry-After`.
35
+ - Logs to the `ironeye` logger. No credential, no payload.
36
+
37
+ Full documentation, including every endpoint and every option, is at
38
+ **https://ironeye.org/docs/sdk/python**.
39
+
40
+ ---
41
+
42
+ Direct Softworks · [MIT](LICENSE) · issues and pull requests welcome
@@ -0,0 +1,26 @@
1
+ # IronEye for Python
2
+
3
+ The official Python client for the [IronEye](https://ironeye.org) API: document
4
+ analysis over bytes you send, and normalised collection from public sources,
5
+ behind one key.
6
+
7
+ ```sh
8
+ pip install ironeye
9
+ ```
10
+
11
+ ## Features
12
+
13
+ - Synchronous and `async` clients over one shared transport, so the two cannot
14
+ drift apart.
15
+ - Every analysis route, the async job path with `await_job`, the collection
16
+ catalogue and the data-subject-rights endpoints.
17
+ - `TypedDict` models, and one exception class per refusal family.
18
+ - Retries on the server's own `retryable` flag, honouring `Retry-After`.
19
+ - Logs to the `ironeye` logger. No credential, no payload.
20
+
21
+ Full documentation, including every endpoint and every option, is at
22
+ **https://ironeye.org/docs/sdk/python**.
23
+
24
+ ---
25
+
26
+ Direct Softworks · [MIT](LICENSE) · issues and pull requests welcome
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ironeye"
7
+ version = "1.0.0"
8
+ description = "Official Python client for the IronEye document intelligence and collection API."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Direct Softworks" }]
14
+ keywords = ["ironeye", "document", "pii", "malware", "secrets", "gdpr", "osint"]
15
+ dependencies = ["httpx>=0.27"]
16
+
17
+ [project.urls]
18
+ Documentation = "https://ironeye.org/docs/sdk/python"
19
+ Homepage = "https://ironeye.org"
20
+ Repository = "https://github.com/IronEyeAPI/ironeye-python"
21
+ Issues = "https://github.com/IronEyeAPI/ironeye-python/issues"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/ironeye"]
25
+
26
+ [tool.ruff]
27
+ line-length = 100
28
+ target-version = "py311"
29
+
30
+ [tool.mypy]
31
+ python_version = "3.11"
32
+ strict = true
@@ -0,0 +1,67 @@
1
+ """Official Python client for the IronEye API.
2
+
3
+ from ironeye import IronEye
4
+
5
+ with IronEye() as eye: # IRONEYE_API_KEY from the env
6
+ result = eye.secrets({"input": {"text": open("config.env").read()}})
7
+ print(result["security"]["secrets"]["secret_count"])
8
+
9
+ Logging goes to the ``ironeye`` logger and is silent until you configure it.
10
+ No credential and no payload is ever written to it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+
17
+ from .client import VERSION, AsyncIronEye, IronEye, from_bytes
18
+ from .errors import (
19
+ APIError,
20
+ AuthenticationError,
21
+ ComplianceError,
22
+ ConnectionError,
23
+ InvalidRequestError,
24
+ IronEyeError,
25
+ NotFoundError,
26
+ PermissionError,
27
+ RateLimitError,
28
+ ServerError,
29
+ UpstreamError,
30
+ )
31
+
32
+ __all__ = [
33
+ "IronEye",
34
+ "AsyncIronEye",
35
+ "VERSION",
36
+ "from_bytes",
37
+ "IronEyeError",
38
+ "APIError",
39
+ "AuthenticationError",
40
+ "PermissionError",
41
+ "RateLimitError",
42
+ "InvalidRequestError",
43
+ "NotFoundError",
44
+ "ComplianceError",
45
+ "UpstreamError",
46
+ "ServerError",
47
+ "ConnectionError",
48
+ ]
49
+
50
+ __version__ = VERSION
51
+
52
+ # A library that configures logging for its host is a library that has to be
53
+ # undone; the handler is here only so an unconfigured application is quiet.
54
+ logging.getLogger("ironeye").addHandler(logging.NullHandler())
55
+
56
+
57
+ def this() -> None:
58
+ print(
59
+ "Evidence beats assertion.\n"
60
+ "A byte offset beats a summary.\n"
61
+ "Observed is not inferred, and inferred says so.\n"
62
+ "Refuse loudly rather than guess quietly.\n"
63
+ "A finding you cannot check is a rumour.\n"
64
+ "Nothing touches disk.\n"
65
+ "\n"
66
+ " ...forged at Direct Softworks."
67
+ )
@@ -0,0 +1,481 @@
1
+ """The synchronous and asynchronous clients.
2
+
3
+ Both are thin wrappers over one ``httpx`` client. Everything that decides
4
+ behaviour -- the URL, the headers, whether a failure is worth another attempt,
5
+ how long to wait -- lives in ``_Transport`` and is shared, so the two clients
6
+ cannot drift apart in the only ways that would matter.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64 as _base64
12
+ import json
13
+ import logging
14
+ import os
15
+ import random
16
+ import time
17
+ from typing import Any, Literal, Mapping
18
+ from urllib.parse import quote
19
+
20
+ import httpx
21
+
22
+ from .errors import APIError, ConnectionError, error_from
23
+ from .models import AnalyzeRequest, Declaration, Envelope, Job, Subject
24
+
25
+ __all__ = ["IronEye", "AsyncIronEye", "VERSION"]
26
+
27
+ VERSION = "1.0.0"
28
+
29
+ _DEFAULT_BASE_URL = "https://ironeye.org"
30
+ _RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})
31
+
32
+ _log = logging.getLogger("ironeye")
33
+
34
+ _ANALYSIS_ROUTES: dict[str, str] = {
35
+ "analyze": "/v1/analyze",
36
+ "extract": "/v1/extract",
37
+ "classify": "/v1/classify",
38
+ "pii": "/v1/pii/analyze",
39
+ "moderation": "/v1/moderation/analyze",
40
+ "malware": "/v1/malware/scan",
41
+ "secrets": "/v1/secrets/scan",
42
+ "validate": "/v1/validate",
43
+ "deduplicate": "/v1/deduplicate",
44
+ "invoices": "/v1/invoices/parse",
45
+ }
46
+
47
+
48
+ def _mask(key: str) -> str:
49
+ """Enough of the key to recognise it in a log, never enough to use it."""
50
+ return f"{key[:9]}..." if len(key) > 12 else "..."
51
+
52
+
53
+ def _declaration_headers(declaration: Declaration | None) -> dict[str, str]:
54
+ if not declaration:
55
+ return {}
56
+ names = {
57
+ "legal_basis": "X-Legal-Basis",
58
+ "purpose": "X-Purpose",
59
+ "controller": "X-Controller",
60
+ "basis_evidence": "X-Basis-Evidence",
61
+ "special_condition": "X-Special-Condition",
62
+ "projection": "X-Projection",
63
+ }
64
+ return {names[key]: str(value) for key, value in declaration.items() if key in names and value}
65
+
66
+
67
+ class _Transport:
68
+ """Everything both clients must agree on."""
69
+
70
+ def __init__(
71
+ self,
72
+ api_key: str | None,
73
+ base_url: str | None,
74
+ timeout: float,
75
+ max_retries: int,
76
+ default_headers: Mapping[str, str] | None,
77
+ ) -> None:
78
+ key = api_key or os.environ.get("IRONEYE_API_KEY")
79
+ if not key:
80
+ raise ValueError("An API key is required: pass api_key= or set IRONEYE_API_KEY.")
81
+ self.api_key = key
82
+ self.base_url = (base_url or os.environ.get("IRONEYE_BASE_URL") or _DEFAULT_BASE_URL).rstrip("/")
83
+ self.timeout = timeout
84
+ self.max_retries = max_retries
85
+ self.headers = {
86
+ "accept": "application/json",
87
+ "authorization": f"Bearer {key}",
88
+ "user-agent": f"ironeye-python/{VERSION}",
89
+ **{k.lower(): v for k, v in (default_headers or {}).items()},
90
+ }
91
+
92
+ def interpret(self, response: httpx.Response, path: str, elapsed: float) -> Any:
93
+ request_id = response.headers.get("x-request-id", "-")
94
+ _log.debug(
95
+ "ironeye %s %s -> %s in %.0fms (request_id=%s)",
96
+ response.request.method,
97
+ path,
98
+ response.status_code,
99
+ elapsed * 1000,
100
+ request_id,
101
+ )
102
+ if response.status_code in (204, 205):
103
+ return None
104
+ if not response.content:
105
+ payload: Any = None
106
+ else:
107
+ try:
108
+ payload = response.json()
109
+ except ValueError:
110
+ payload = {"error": {"code": "INTERNAL", "message": response.text[:200]}}
111
+ if response.is_success:
112
+ return payload
113
+ raise error_from(response.status_code, payload)
114
+
115
+ def should_retry(self, error: APIError, attempt: int) -> bool:
116
+ return (
117
+ attempt < self.max_retries
118
+ and error.retryable
119
+ and error.status in _RETRYABLE_STATUS
120
+ )
121
+
122
+ def wait_for(self, attempt: int, response: httpx.Response | None, code: str, path: str) -> float:
123
+ """Retry-After is the server's own number, so it wins over the curve."""
124
+ advised = (response.headers.get("retry-after") if response is not None else None) or ""
125
+ seconds = float(advised) if advised.isdigit() else 0.25 * (2**attempt) + random.random() / 4
126
+ _log.warning("ironeye %s retrying after %s in %.0fms", path, code, seconds * 1000)
127
+ return seconds
128
+
129
+
130
+ class _Base:
131
+ """The call surface, in terms of one abstract ``_request``."""
132
+
133
+ _transport: _Transport
134
+
135
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any: # pragma: no cover
136
+ raise NotImplementedError
137
+
138
+ # -- analysis ----------------------------------------------------------
139
+ def analyze(self, request: AnalyzeRequest, *, idempotency_key: str | None = None) -> Any:
140
+ return self._analysis("analyze", request, idempotency_key)
141
+
142
+ def extract(self, request: AnalyzeRequest, *, idempotency_key: str | None = None) -> Any:
143
+ return self._analysis("extract", request, idempotency_key)
144
+
145
+ def classify(self, request: AnalyzeRequest, *, idempotency_key: str | None = None) -> Any:
146
+ return self._analysis("classify", request, idempotency_key)
147
+
148
+ def pii(self, request: AnalyzeRequest, *, idempotency_key: str | None = None) -> Any:
149
+ return self._analysis("pii", request, idempotency_key)
150
+
151
+ def moderation(self, request: AnalyzeRequest, *, idempotency_key: str | None = None) -> Any:
152
+ return self._analysis("moderation", request, idempotency_key)
153
+
154
+ def malware(self, request: AnalyzeRequest, *, idempotency_key: str | None = None) -> Any:
155
+ return self._analysis("malware", request, idempotency_key)
156
+
157
+ def secrets(self, request: AnalyzeRequest, *, idempotency_key: str | None = None) -> Any:
158
+ return self._analysis("secrets", request, idempotency_key)
159
+
160
+ def validate(self, request: AnalyzeRequest, *, idempotency_key: str | None = None) -> Any:
161
+ return self._analysis("validate", request, idempotency_key)
162
+
163
+ def deduplicate(self, request: AnalyzeRequest, *, idempotency_key: str | None = None) -> Any:
164
+ return self._analysis("deduplicate", request, idempotency_key)
165
+
166
+ def invoices(self, request: AnalyzeRequest, *, idempotency_key: str | None = None) -> Any:
167
+ return self._analysis("invoices", request, idempotency_key)
168
+
169
+ def _analysis(self, name: str, request: AnalyzeRequest, key: str | None) -> Any:
170
+ headers = {"Idempotency-Key": key} if key else None
171
+ return self._request("POST", _ANALYSIS_ROUTES[name], json=request, headers=headers)
172
+
173
+ # -- jobs --------------------------------------------------------------
174
+ def create_job(self, request: AnalyzeRequest) -> Any:
175
+ return self._request("POST", "/v1/jobs", json=request)
176
+
177
+ def get_job(self, job_id: str) -> Any:
178
+ return self._request("GET", f"/v1/jobs/{quote(job_id, safe='')}")
179
+
180
+ def delete_job(self, job_id: str) -> Any:
181
+ return self._request("DELETE", f"/v1/jobs/{quote(job_id, safe='')}")
182
+
183
+ # -- collection --------------------------------------------------------
184
+ def catalogue(self) -> Any:
185
+ return self._request("GET", "/v1/harvest/catalogue")
186
+
187
+ def operations(self, platform: str | None = None) -> Any:
188
+ params = {"platform": platform} if platform else None
189
+ return self._request("GET", "/v1/harvest/operations", params=params)
190
+
191
+ def operation(self, op_id: str) -> Any:
192
+ return self._request("GET", f"/v1/harvest/operations/{quote(op_id, safe='')}")
193
+
194
+ def collect(
195
+ self,
196
+ path: str,
197
+ params: Mapping[str, Any] | None = None,
198
+ declaration: Declaration | None = None,
199
+ ) -> Any:
200
+ """Runs one collection operation, addressed by its own route.
201
+
202
+ ``path`` is what the catalogue gives: ``/v1/harvest/reddit/subreddit``.
203
+ """
204
+ return self._request(
205
+ "GET", path, params=dict(params or {}), headers=_declaration_headers(declaration)
206
+ )
207
+
208
+ def collect_post(
209
+ self,
210
+ path: str,
211
+ params: Mapping[str, Any] | None = None,
212
+ declaration: Declaration | None = None,
213
+ ) -> Any:
214
+ """``collect`` for the operations the registry declares as POST.
215
+
216
+ The parameters are identical; only where they travel changes.
217
+ """
218
+ return self._request(
219
+ "POST", path, json=dict(params or {}), headers=_declaration_headers(declaration)
220
+ )
221
+
222
+ # -- data subject rights ----------------------------------------------
223
+ def gdpr_notice(self) -> Any:
224
+ return self._request("GET", "/v1/gdpr/notice")
225
+
226
+ def erasure(self, subject: Subject) -> Any:
227
+ return self._request("POST", "/v1/gdpr/erasure", json=subject)
228
+
229
+ def objection(self, subject: Subject) -> Any:
230
+ return self._request("POST", "/v1/gdpr/objections", json=subject)
231
+
232
+ def access_request(self, subject: Subject) -> Any:
233
+ return self._request("POST", "/v1/gdpr/access", json=subject)
234
+
235
+ def suppression(self) -> Any:
236
+ return self._request("GET", "/v1/gdpr/suppression")
237
+
238
+ def unsuppress(self, subject_key: str) -> Any:
239
+ return self._request("DELETE", f"/v1/gdpr/suppression/{quote(subject_key, safe='')}")
240
+
241
+ # -- service -----------------------------------------------------------
242
+ def health(self) -> Any:
243
+ return self._request("GET", "/healthz")
244
+
245
+ def ready(self) -> Any:
246
+ return self._request("GET", "/readyz")
247
+
248
+ def features(self) -> Any:
249
+ return self._request("GET", "/v1/features")
250
+
251
+ def status(self) -> Any:
252
+ return self._request("GET", "/v1/status")
253
+
254
+ def audit_head(self) -> Any:
255
+ return self._request("GET", "/v1/audit/head")
256
+
257
+ def __repr__(self) -> str:
258
+ return f"{type(self).__name__}(base_url={self._transport.base_url!r}, key={_mask(self._transport.api_key)!r})"
259
+
260
+ def __invert__(self) -> Any:
261
+ return self.health()
262
+
263
+
264
+ def _upload_parts(
265
+ file: bytes,
266
+ filename: str,
267
+ content_type: str,
268
+ features: list[str] | None,
269
+ preset: str | None,
270
+ options: dict[str, Any] | None,
271
+ output_mode: str | None,
272
+ retention_seconds: int | None,
273
+ ) -> tuple[dict[str, Any], dict[str, str]]:
274
+ files = {"file": (filename, file, content_type)}
275
+ data: dict[str, str] = {}
276
+ if features:
277
+ data["features"] = ",".join(features)
278
+ if preset:
279
+ data["preset"] = preset
280
+ if options:
281
+ data["options"] = json.dumps(options)
282
+ if output_mode:
283
+ data["output_mode"] = output_mode
284
+ if retention_seconds is not None:
285
+ data["retention_seconds"] = str(retention_seconds)
286
+ return files, data
287
+
288
+
289
+ class IronEye(_Base):
290
+ """The synchronous client.
291
+
292
+ Usable as a context manager, and safe to share between threads: ``httpx``
293
+ handles the pooling and nothing here is per-request state.
294
+ """
295
+
296
+ def __init__(
297
+ self,
298
+ api_key: str | None = None,
299
+ *,
300
+ base_url: str | None = None,
301
+ timeout: float = 60.0,
302
+ max_retries: int = 2,
303
+ default_headers: Mapping[str, str] | None = None,
304
+ http_client: httpx.Client | None = None,
305
+ ) -> None:
306
+ self._transport = _Transport(api_key, base_url, timeout, max_retries, default_headers)
307
+ self._http = http_client or httpx.Client(timeout=timeout)
308
+ self._owns_http = http_client is None
309
+
310
+ def close(self) -> None:
311
+ if self._owns_http:
312
+ self._http.close()
313
+
314
+ def __enter__(self) -> IronEye:
315
+ return self
316
+
317
+ def __exit__(self, *_: object) -> None:
318
+ self.close()
319
+
320
+ def analyze_upload(
321
+ self,
322
+ file: bytes,
323
+ *,
324
+ filename: str = "document",
325
+ content_type: str = "application/octet-stream",
326
+ features: list[str] | None = None,
327
+ preset: str | None = None,
328
+ options: dict[str, Any] | None = None,
329
+ output_mode: Literal["canonical", "redacted"] | None = None,
330
+ retention_seconds: int | None = None,
331
+ idempotency_key: str | None = None,
332
+ ) -> Envelope:
333
+ """Multipart, for bytes you hold already rather than base64 in a body."""
334
+ files, data = _upload_parts(
335
+ file, filename, content_type, features, preset, options, output_mode, retention_seconds
336
+ )
337
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
338
+ return self._request("POST", "/v1/analyze/upload", files=files, data=data, headers=headers)
339
+
340
+ def await_job(self, job_id: str, *, interval: float = 2.0, timeout: float = 300.0) -> Job:
341
+ """Polls until the job settles.
342
+
343
+ Nothing in the service dispatches to a callback URL, so polling is the
344
+ whole of the asynchronous contract.
345
+ """
346
+ deadline = time.monotonic() + timeout
347
+ while True:
348
+ job = self.get_job(job_id)
349
+ if job.get("status") in ("completed", "failed"):
350
+ return job
351
+ if time.monotonic() + interval > deadline:
352
+ raise TimeoutError(f"Job {job_id} was still {job.get('status')} after {timeout}s.")
353
+ time.sleep(interval)
354
+
355
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
356
+ transport = self._transport
357
+ headers = {**transport.headers, **{k: v for k, v in (kwargs.pop("headers", None) or {}).items()}}
358
+ url = transport.base_url + path
359
+ last: Exception | None = None
360
+ for attempt in range(transport.max_retries + 1):
361
+ started = time.monotonic()
362
+ try:
363
+ response = self._http.request(
364
+ method, url, headers=headers, timeout=transport.timeout, **kwargs
365
+ )
366
+ except httpx.HTTPError as failure:
367
+ last = ConnectionError(f"{method} {path} failed: {failure}")
368
+ if attempt >= transport.max_retries:
369
+ raise last from failure
370
+ time.sleep(transport.wait_for(attempt, None, "CONNECTION", path))
371
+ continue
372
+ try:
373
+ return transport.interpret(response, path, time.monotonic() - started)
374
+ except APIError as error:
375
+ if not transport.should_retry(error, attempt):
376
+ raise
377
+ last = error
378
+ time.sleep(transport.wait_for(attempt, response, error.code, path))
379
+ raise last or ConnectionError(f"{method} {path} exhausted its retries.")
380
+
381
+
382
+ class AsyncIronEye(_Base):
383
+ """The asynchronous client. The same surface, awaited."""
384
+
385
+ def __init__(
386
+ self,
387
+ api_key: str | None = None,
388
+ *,
389
+ base_url: str | None = None,
390
+ timeout: float = 60.0,
391
+ max_retries: int = 2,
392
+ default_headers: Mapping[str, str] | None = None,
393
+ http_client: httpx.AsyncClient | None = None,
394
+ ) -> None:
395
+ self._transport = _Transport(api_key, base_url, timeout, max_retries, default_headers)
396
+ self._http = http_client or httpx.AsyncClient(timeout=timeout)
397
+ self._owns_http = http_client is None
398
+
399
+ async def aclose(self) -> None:
400
+ if self._owns_http:
401
+ await self._http.aclose()
402
+
403
+ async def __aenter__(self) -> AsyncIronEye:
404
+ return self
405
+
406
+ async def __aexit__(self, *_: object) -> None:
407
+ await self.aclose()
408
+
409
+ async def analyze_upload(
410
+ self,
411
+ file: bytes,
412
+ *,
413
+ filename: str = "document",
414
+ content_type: str = "application/octet-stream",
415
+ features: list[str] | None = None,
416
+ preset: str | None = None,
417
+ options: dict[str, Any] | None = None,
418
+ output_mode: Literal["canonical", "redacted"] | None = None,
419
+ retention_seconds: int | None = None,
420
+ idempotency_key: str | None = None,
421
+ ) -> Envelope:
422
+ files, data = _upload_parts(
423
+ file, filename, content_type, features, preset, options, output_mode, retention_seconds
424
+ )
425
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
426
+ return await self._request(
427
+ "POST", "/v1/analyze/upload", files=files, data=data, headers=headers
428
+ )
429
+
430
+ async def await_job(self, job_id: str, *, interval: float = 2.0, timeout: float = 300.0) -> Job:
431
+ import asyncio
432
+
433
+ deadline = time.monotonic() + timeout
434
+ while True:
435
+ job = await self.get_job(job_id)
436
+ if job.get("status") in ("completed", "failed"):
437
+ return job
438
+ if time.monotonic() + interval > deadline:
439
+ raise TimeoutError(f"Job {job_id} was still {job.get('status')} after {timeout}s.")
440
+ await asyncio.sleep(interval)
441
+
442
+ async def _request(self, method: str, path: str, **kwargs: Any) -> Any: # type: ignore[override]
443
+ import asyncio
444
+
445
+ transport = self._transport
446
+ headers = {**transport.headers, **{k: v for k, v in (kwargs.pop("headers", None) or {}).items()}}
447
+ url = transport.base_url + path
448
+ last: Exception | None = None
449
+ for attempt in range(transport.max_retries + 1):
450
+ started = time.monotonic()
451
+ try:
452
+ response = await self._http.request(
453
+ method, url, headers=headers, timeout=transport.timeout, **kwargs
454
+ )
455
+ except httpx.HTTPError as failure:
456
+ last = ConnectionError(f"{method} {path} failed: {failure}")
457
+ if attempt >= transport.max_retries:
458
+ raise last from failure
459
+ await asyncio.sleep(transport.wait_for(attempt, None, "CONNECTION", path))
460
+ continue
461
+ try:
462
+ return transport.interpret(response, path, time.monotonic() - started)
463
+ except APIError as error:
464
+ if not transport.should_retry(error, attempt):
465
+ raise
466
+ last = error
467
+ await asyncio.sleep(transport.wait_for(attempt, response, error.code, path))
468
+ raise last or ConnectionError(f"{method} {path} exhausted its retries.")
469
+
470
+ def __invert__(self) -> Any:
471
+ return self.health()
472
+
473
+
474
+ def from_bytes(data: bytes, filename: str | None = None, content_type: str | None = None) -> dict[str, Any]:
475
+ """Builds the ``input`` object for a JSON body out of raw bytes."""
476
+ source: dict[str, Any] = {"base64": _base64.b64encode(data).decode("ascii")}
477
+ if filename:
478
+ source["filename"] = filename
479
+ if content_type:
480
+ source["content_type"] = content_type
481
+ return source
@@ -0,0 +1,118 @@
1
+ """The error contract, one class per family.
2
+
3
+ Every attribute here is one the server actually sends. ``retryable`` is the
4
+ server's own verdict rather than an inference from the status code: a 429 from
5
+ a spent monthly allowance is not the same wait as a 429 from a rate limiter,
6
+ and only the body tells them apart.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ __all__ = [
14
+ "IronEyeError",
15
+ "APIError",
16
+ "AuthenticationError",
17
+ "PermissionError",
18
+ "RateLimitError",
19
+ "InvalidRequestError",
20
+ "NotFoundError",
21
+ "ComplianceError",
22
+ "UpstreamError",
23
+ "ServerError",
24
+ "ConnectionError",
25
+ ]
26
+
27
+
28
+ class IronEyeError(Exception):
29
+ """Base of every error this package raises."""
30
+
31
+
32
+ class APIError(IronEyeError):
33
+ """An error the server described in its response body."""
34
+
35
+ def __init__(self, status: int, body: dict[str, Any]) -> None:
36
+ self.status = status
37
+ self.code: str = body.get("code", "INTERNAL")
38
+ self.message: str = body.get("message", "The request failed.")
39
+ self.retryable: bool = bool(body.get("retryable", False))
40
+ self.request_id: str = body.get("request_id", "-")
41
+ self.suggested_action: str = body.get("suggested_action", "")
42
+ self.doc: str = body.get("doc", "")
43
+ self.path: str | None = body.get("path")
44
+ self.meta: dict[str, Any] = body.get("meta", {})
45
+ super().__init__(f"{self.code}: {self.message} (request_id={self.request_id})")
46
+
47
+
48
+ class AuthenticationError(APIError):
49
+ pass
50
+
51
+
52
+ class PermissionError(APIError): # noqa: A001 - the API's own vocabulary wins here
53
+ pass
54
+
55
+
56
+ class RateLimitError(APIError):
57
+ pass
58
+
59
+
60
+ class InvalidRequestError(APIError):
61
+ pass
62
+
63
+
64
+ class NotFoundError(APIError):
65
+ pass
66
+
67
+
68
+ class ComplianceError(APIError):
69
+ pass
70
+
71
+
72
+ class UpstreamError(APIError):
73
+ pass
74
+
75
+
76
+ class ServerError(APIError):
77
+ pass
78
+
79
+
80
+ class ConnectionError(IronEyeError): # noqa: A001 - shadows the builtin deliberately
81
+ """A transport failure, where there is no server verdict to read."""
82
+
83
+ retryable = True
84
+
85
+
86
+ _FAMILIES: dict[str, type[APIError]] = {
87
+ "UNAUTHENTICATED": AuthenticationError,
88
+ "FORBIDDEN_SCOPE": PermissionError,
89
+ "PLAN_LIMITED": PermissionError,
90
+ "RATE_LIMITED": RateLimitError,
91
+ "QUOTA_EXHAUSTED": RateLimitError,
92
+ "TENANT_BUSY": RateLimitError,
93
+ "NOT_FOUND": NotFoundError,
94
+ "COMPLIANCE_REFUSED": ComplianceError,
95
+ "COLLECTION_BLOCKED": ComplianceError,
96
+ "SOURCE_NOT_CONFIGURED": UpstreamError,
97
+ "UPSTREAM_REFUSED": UpstreamError,
98
+ "UPSTREAM_THROTTLED": UpstreamError,
99
+ "INTERNAL": ServerError,
100
+ "DEPENDENCY_UNAVAILABLE": ServerError,
101
+ "SERVER_DRAINING": ServerError,
102
+ }
103
+
104
+
105
+ def error_from(status: int, payload: Any) -> APIError:
106
+ """Builds the narrowest error class the response body justifies."""
107
+ body = payload.get("error") if isinstance(payload, dict) else None
108
+ if not isinstance(body, dict) or "code" not in body:
109
+ return ServerError(
110
+ status,
111
+ {
112
+ "code": "INTERNAL",
113
+ "message": f"The server returned {status} with no error body.",
114
+ "retryable": status >= 500,
115
+ "suggested_action": "Retry, and quote the status if it persists.",
116
+ },
117
+ )
118
+ return _FAMILIES.get(body["code"], InvalidRequestError)(status, body)
@@ -0,0 +1,164 @@
1
+ """Request and response shapes, transcribed from the server's own types.
2
+
3
+ They are ``TypedDict`` rather than dataclasses on purpose: a response is JSON
4
+ the server owns, and a class that has to be kept in step with it would fail
5
+ closed on a field the engine added this morning. These annotate; they never
6
+ gate.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Literal, NotRequired, TypedDict
12
+
13
+ __all__ = [
14
+ "Source",
15
+ "Output",
16
+ "AnalyzeRequest",
17
+ "Evidence",
18
+ "Finding",
19
+ "ModuleResult",
20
+ "Envelope",
21
+ "Job",
22
+ "Declaration",
23
+ "Collection",
24
+ "Subject",
25
+ "Preset",
26
+ "LawfulBasis",
27
+ ]
28
+
29
+ Preset = Literal[
30
+ "minimal", "standard", "business", "security", "compliance", "forensic", "media", "all-safe"
31
+ ]
32
+
33
+ LawfulBasis = Literal[
34
+ "consent",
35
+ "contract",
36
+ "legal_obligation",
37
+ "vital_interests",
38
+ "public_task",
39
+ "legitimate_interests",
40
+ ]
41
+
42
+ SpecialCondition = Literal[
43
+ "explicit_consent", "manifestly_public", "legal_claims", "research_statistics"
44
+ ]
45
+
46
+ Projection = Literal["minimal", "standard", "full"]
47
+
48
+
49
+ class Source(TypedDict, total=False):
50
+ """Exactly one of ``text``, ``base64`` or ``url``. Two is refused, so is none."""
51
+
52
+ text: str
53
+ base64: str
54
+ url: str
55
+ filename: str
56
+ content_type: str
57
+
58
+
59
+ class Output(TypedDict, total=False):
60
+ mode: Literal["canonical", "redacted"]
61
+ include_findings: bool
62
+
63
+
64
+ class AnalyzeRequest(TypedDict, total=False):
65
+ input: Source
66
+ features: list[str]
67
+ preset: str
68
+ options: dict[str, Any]
69
+ output: Output
70
+ retention_seconds: int
71
+ callback_url: str
72
+
73
+
74
+ class Evidence(TypedDict, total=False):
75
+ text_span: tuple[int, int]
76
+ page: int
77
+ bbox: tuple[float, float, float, float]
78
+ time_range: tuple[float, float]
79
+ container_path: str
80
+
81
+
82
+ class Finding(TypedDict):
83
+ id: str
84
+ type: str
85
+ category: str
86
+ epistemic: Literal["observed", "derived", "inferred", "validated"]
87
+ confidence: float
88
+ status: str
89
+ sensitive: bool
90
+ evidence: Evidence
91
+ method: dict[str, Any]
92
+ created_at: str
93
+ value: NotRequired[str | None]
94
+ normalized: NotRequired[Any]
95
+ severity: NotRequired[Literal["info", "low", "medium", "high", "critical"] | None]
96
+ redacted: NotRequired[bool]
97
+ attributes: NotRequired[dict[str, Any]]
98
+
99
+
100
+ class ModuleResult(TypedDict, total=False):
101
+ status: str
102
+ cached: bool
103
+ findings: list[Finding]
104
+
105
+
106
+ class Envelope(TypedDict, total=False):
107
+ """The sections are named for how the engine came to know each one."""
108
+
109
+ request_id: str
110
+ status: str
111
+ engine: dict[str, str]
112
+ input: dict[str, Any]
113
+ classification: dict[str, Any]
114
+ observed: dict[str, ModuleResult]
115
+ derived: dict[str, ModuleResult]
116
+ inferred: dict[str, ModuleResult]
117
+ validated: dict[str, ModuleResult]
118
+ safety: dict[str, ModuleResult]
119
+ privacy: dict[str, ModuleResult]
120
+ security: dict[str, ModuleResult]
121
+ compliance: dict[str, ModuleResult]
122
+ provenance: dict[str, Any]
123
+ retention: dict[str, Any]
124
+ actions: list[Any]
125
+ created_at: str
126
+
127
+
128
+ class Job(TypedDict, total=False):
129
+ id: str
130
+ status: Literal["queued", "running", "completed", "failed"]
131
+ retention_seconds: int
132
+ created_at: str
133
+ result: Envelope
134
+
135
+
136
+ class Declaration(TypedDict, total=False):
137
+ """What a collection call declares about itself.
138
+
139
+ Required on any operation whose ``personal_data`` flag is true: the server
140
+ refuses rather than assumes.
141
+ """
142
+
143
+ legal_basis: LawfulBasis
144
+ purpose: str
145
+ controller: str
146
+ basis_evidence: str
147
+ special_condition: SpecialCondition
148
+ projection: Projection
149
+
150
+
151
+ class Collection(TypedDict, total=False):
152
+ request_id: str
153
+ operation: str
154
+ entity: str
155
+ data: Any
156
+ collection: dict[str, Any]
157
+ compliance: dict[str, Any]
158
+ paging: dict[str, Any]
159
+
160
+
161
+ class Subject(TypedDict, total=False):
162
+ platform: str
163
+ identifier: str
164
+ reference: str