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.
- ironeye-1.0.0/.gitattributes +1 -0
- ironeye-1.0.0/.github/workflows/ci.yml +29 -0
- ironeye-1.0.0/.gitignore +5 -0
- ironeye-1.0.0/LICENSE +21 -0
- ironeye-1.0.0/PKG-INFO +42 -0
- ironeye-1.0.0/README.md +26 -0
- ironeye-1.0.0/pyproject.toml +32 -0
- ironeye-1.0.0/src/ironeye/__init__.py +67 -0
- ironeye-1.0.0/src/ironeye/client.py +481 -0
- ironeye-1.0.0/src/ironeye/errors.py +118 -0
- ironeye-1.0.0/src/ironeye/models.py +164 -0
|
@@ -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
|
ironeye-1.0.0/.gitignore
ADDED
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
|
ironeye-1.0.0/README.md
ADDED
|
@@ -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
|