proofage 0.1.0__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.
proofage/AGENTS.md ADDED
@@ -0,0 +1,202 @@
1
+ # ProofAge Python SDK — API contract for agents
2
+
3
+ This package wraps the ProofAge v1 HTTP API. Methods live on `client.workspace` and
4
+ `client.verifications`; `AsyncProofAge` has the same methods, awaited. Responses are Pydantic
5
+ models from `proofage.models`, and they are lenient: a field the SDK does not know yet is kept in
6
+ `model.model_extra`, and a status the SDK does not know yet arrives as a plain `str` instead of a
7
+ `VerificationStatus` member (compare with `==`, not `is`). `model.model_dump(mode="json")` gives a
8
+ plain dict. A machine-readable spec ships next to this file as `openapi.json` (authoritative for
9
+ endpoints and request bodies; where it does not describe a response, the shapes below are
10
+ authoritative).
11
+
12
+ All requests send `X-API-Key` and `X-HMAC-Signature`. Request bodies use **snake_case** to match
13
+ the API. Responses are never wrapped in `data`.
14
+
15
+ ## Configuration
16
+
17
+ Argument → environment variable → default. The environment variables use the **Laravel/PHP
18
+ SDK's units**, so one `.env` serves both; constructor arguments are seconds as floats.
19
+
20
+ | Argument | Environment | Default | Notes |
21
+ |---|---|---|---|
22
+ | `api_key` | `PROOFAGE_API_KEY` | required | Workspace API key |
23
+ | `secret_key` | `PROOFAGE_SECRET_KEY` | required | Used only to sign; never sent, never in `repr` |
24
+ | `base_url` | `PROOFAGE_BASE_URL` | `https://api.proofage.xyz` | API origin without the version; a trailing `/v1` is stripped; a non-http(s) URL, a query or a fragment raises `ConfigurationError` |
25
+ | `version` | `PROOFAGE_VERSION` | `v1` | |
26
+ | `timeout` | `PROOFAGE_TIMEOUT` (seconds) | `30.0` | httpx per-operation timeout (connect, each read, each write), not a deadline for the whole request. Node reads this variable in milliseconds |
27
+ | `retry_attempts` | `PROOFAGE_RETRY_ATTEMPTS` | `3` | Attempts for interactive requests |
28
+ | `retry_delay` | `PROOFAGE_RETRY_DELAY` (**milliseconds**) | `1.0` s | Linear backoff unit; `PROOFAGE_RETRY_DELAY=1000` is one second |
29
+ | `download_retry_attempts` | `PROOFAGE_DOWNLOAD_RETRY_ATTEMPTS` | `1` | Media downloads; only transport failures are retried |
30
+ | `http_client` | — | owned | Your `httpx.Client` / `httpx.AsyncClient`; the SDK never closes it |
31
+ | `sdk_tokens` | — | `()` | `name/version` tokens for a package wrapping this SDK (see "SDK identification") |
32
+ | `user_agent` | — | SDK default | Replaces the User-Agent; `X-ProofAge-Sdk` is still sent |
33
+
34
+ ## Errors
35
+
36
+ Every non-2xx response raises. All request errors extend `ProofAgeError`, which carries
37
+ `status_code`, `message`, `code` (the API's machine-readable code, when sent), `error_data` and the
38
+ raw `response_body`:
39
+
40
+ ```
41
+ ProofAgeError
42
+ ├── AuthenticationError 401
43
+ ├── PaymentRequiredError 402 code PAYMENT_METHOD_REQUIRED; error_data has free_verifications_remaining, trial_ends_at, trial_active
44
+ ├── PermissionDeniedError 403
45
+ ├── NotFoundError 404
46
+ ├── ValidationError 422 .errors: dict[str, list[str]]; media rejections carry .code (e.g. FACE_NOT_FOUND)
47
+ ├── RateLimitError 429 .retry_after: float | None, raised after the retries run out
48
+ ├── ServerError 5xx
49
+ └── TransportError no response (DNS, connection, TLS, timeout); __cause__ is the httpx exception
50
+ ConfigurationError raised when a client is built
51
+ WebhookVerificationError see "Outbound webhook"
52
+ ```
53
+
54
+ The API uses four error body shapes; the client reads all of them:
55
+
56
+ - `{ error: { code, message } }` — most errors (401 auth, 429 `RATE_LIMIT`, submit 422, media download 404). `error_data` is the inner object.
57
+ - `{ code, message, ...extra }` — flat: `402 PAYMENT_METHOD_REQUIRED` (extra: `free_verifications_remaining`, `trial_ends_at`, `trial_active`) and media-quality rejections on upload (`422`, e.g. `FACE_NOT_FOUND`; `500 VALIDATION_SERVICE_UNAVAILABLE`). `error_data` is the whole body.
58
+ - `{ message, errors }` — request validation (`422`). `code` is `None`; `errors` has the fields.
59
+ - `{ message }` — `403` (verification not in your workspace) and `404` (`Resource not found`).
60
+
61
+ A 2xx with an empty body returns `None` from the methods typed `-> None`; where a model is
62
+ promised, an empty or non-JSON 2xx raises `ProofAgeError` (a non-JSON one almost always means
63
+ `base_url` points at a website rather than the API).
64
+
65
+ Retries: GETs retry on 408, 429, 5xx, timeouts and transport errors. POSTs retry **only** on 429
66
+ and on errors raised before anything was sent (`httpx.ConnectError`, `httpx.ConnectTimeout`) —
67
+ never on 5xx or after sending began (`WriteError`, `WriteTimeout`, `ReadError`, `ReadTimeout`,
68
+ `RemoteProtocolError`), where the server may already have created the verification or stored the
69
+ upload. A 429 waits for `Retry-After` (seconds or an HTTP date) when present, up to 60 seconds —
70
+ a longer one raises `RateLimitError` at once with `retry_after` set — else `retry_delay * attempt`.
71
+ A 2xx whose body does not match the model raises `ProofAgeError` ("Unexpected response shape from
72
+ GET /v1/..."), naming the fields but never their values.
73
+
74
+ ## Auth / HMAC
75
+
76
+ - `X-API-Key`: workspace API key (plaintext; the server SHA256-hashes it).
77
+ - `X-HMAC-Signature`: hex HMAC-SHA256 with the workspace secret key over a canonical string:
78
+ - JSON / no-file requests: `METHOD + /{version}/{path} + ?query + rawJsonBody` (direct
79
+ concatenation, no delimiter; the query part only when there is one, keys sorted and values
80
+ RFC 3986-encoded). The body is serialised once, compact, and sent as exactly the signed bytes;
81
+ an empty payload is the empty string, never `{}`.
82
+ - Multipart (file) requests: `METHOD/{version}/{path}\n{fields}\n{comma-joined sorted sha256(file) hashes}`,
83
+ where `{fields}` is PHP `http_build_query(ksort($fields), '', '&', PHP_QUERY_RFC3986)` — keys
84
+ sorted, values `rawurlencode`d (so `! ' ( ) *` are percent-encoded too). The client signs
85
+ exactly what it sends: `None` fields are dropped, numbers are `str(n)`, booleans `"1"`/`"0"`,
86
+ dicts and lists compact JSON strings. Golden vectors: `tests/fixtures/hmac-vectors.json` in the
87
+ source repository.
88
+
89
+ ## Endpoints
90
+
91
+ ### GET /workspace — `client.workspace.get()` → `WorkspaceInfo`
92
+ Request: none.
93
+ Response: `{ id: str, name: str, flow_type: str, mode: str, age_mode: str|None, age_threshold: int|None, verification_type: str, redirect_url: str|None, webhook_url: str|None, allow_expired_documents: bool, allow_duplicate_accounts: bool }`
94
+
95
+ ### GET /consent — `client.workspace.consent()` → `ConsentInfo`
96
+ Request: none.
97
+ Response: `{ id: int, version: str, text_sha256: str, url: str }`
98
+
99
+ ### POST /verifications — `client.verifications.create(**kwargs)` → `CreatedVerification`
100
+ Request (all optional keywords): `fingerprint: str(64), callback_url: url(<=2048), external_id: str(<=255), external_metadata: dict, metadata: dict, page_url: str(<=8192)` (`page_url`: the page the verification was started on; only scheme, host and path are kept).
101
+ Response (`201`): `{ id, external_id, external_metadata, redirect_url, status, reason, duplicate_check: DuplicateCheck, erasure: Erasure|None, consent_accepted_at, created_at, updated_at, url }` — `url` is the hosted session the person opens.
102
+ Errors: `402` `PaymentRequiredError`; `422` `ValidationError`.
103
+
104
+ - `DuplicateCheck`: `{ checked: bool, duplicate_count: int, duplicates: [ { verification_id: str, external_id: str|None, similarity_score: float, verified_at: datetime|None } ] }` — always present.
105
+ - `Erasure`: `{ erased_at: datetime, scope: "personal_data", reason: str|None, requested_via: "customer"|"proofage"|"retention"|None }` — `None` until the verification's personal data is erased. `reason` is an erasure reason code (`data_subject_request`, `customer_request`, `retention_policy`, `test_data`, `other`) or `None` if unrecorded.
106
+
107
+ ### GET /verifications/{verification} — `client.verifications.get(verification_id)` → `Verification`
108
+ Request: none.
109
+ Response: same as create **without** `url`.
110
+
111
+ ### POST /verifications/{verification}/consent — `client.verifications.accept_consent(verification_id, *, consent_version_id, text_sha256, ...)` → `AcceptConsentResult`
112
+ Request: `consent_version_id: int, text_sha256: str(64 hex)`, optional `device: { platform, screen, language, timezone, hardware_concurrency, device_memory }, in_app_browser: str|None, camera_permission: "granted"|"denied"|"prompt"|"unsupported"|None, camera_policy_allowed: bool|None, in_iframe: bool|None, referrer: str|None`. `consent_version_id` / `text_sha256` are `id` / `text_sha256` from `workspace.consent()`. Custom capture flows only.
113
+ Response: `{ consent_version_id: int, consent_accepted_at: datetime }`
114
+
115
+ ### POST /verifications/{verification}/media — `client.verifications.upload_media(verification_id, *, file, type, ...)` → `None`
116
+ Request (multipart): `file: bytes | pathlib.Path | binary file object (image, <=10 MB; documents >=200px per edge)`, `type: "selfie"|"liveness_selfie"|"document"`, `side: "front"|"back"` and `document: "id"|"driver_license"|"passport"|"residence_permit"` (both required when `type="document"`), optional `filename` (default: the path's name, else `upload.bin`), `fingerprint: str(64), head_turn_step: int(0..10), capture_resolution: str|dict, device_info: str|dict, liveness_telemetry: str|list`. Dicts and lists are sent as JSON strings. A text-mode file raises `TypeError`; an invalid `type`/`side`/`document` combination raises `ValueError`, both before any request. Requires consent accepted first.
117
+ Response: `200` with an **empty body**; returns `None`.
118
+ Errors: `422` `ValidationError` with `.code` when the image is rejected (e.g. `FACE_NOT_FOUND`) or `.errors` for invalid fields; `500` `ServerError` `VALIDATION_SERVICE_UNAVAILABLE`.
119
+
120
+ ### POST /verifications/{verification}/submit — `client.verifications.submit(verification_id)` → `None`
121
+ Request: none.
122
+ Response: `200` with an **empty body**. Error: `422` `{ error: { code, message } }` (e.g. `MISSING_REQUIRED_MEDIA`).
123
+
124
+ ### GET /verifications/{verification}/document — `client.verifications.document(verification_id)` → `VerificationDocument`
125
+ Request: none.
126
+ Response: `{ document: { fields: { first_name: str|None, last_name: str|None, date_of_birth: date|None, document_number: str|None } }, media: [ { id: str, type: "selfie"|"document_front"|"document_back", url: str|None } ], meta: { attempt_id: str|None } }`. `url` is None once the media has been purged or is past retention.
127
+
128
+ ### GET /verifications/{verification}/media/{media} — `download_media()` / `download_media_to()`
129
+ `with client.verifications.download_media(verification_id, media_id) as chunks:` yields the image bytes as an iterator (`async with` and an async iterator on `AsyncProofAge`); `client.verifications.download_media_to(verification_id, media_id, path)` streams to disk and returns the `Path`, writing to a temporary sibling and renaming only after a 2xx, so a failure never leaves a partial file. `media_id` is `media[].id` from `document()`; check its `url` is not None first. Requests send `Accept: application/json, */*;q=0.8` so errors come back as JSON. Error: `404` `NotFoundError` with `code == "MEDIA_NOT_FOUND"`. An HTTP status is never retried, 429 included; a transport failure is retried `download_retry_attempts` times, only before the first byte.
130
+
131
+ ### GET /verifications/{verification}/estimation — `client.verifications.estimation(verification_id)` → `AgeEstimation`
132
+ Request: none.
133
+ Response: `{ verification_id: str, attempt_id: str|None, age_threshold: { minimum: int|None, passed: bool|None, confidence: float|None }, gender: { value: 0|1|None, confidence: float|None }|None }` (gender value: 0=female, 1=male).
134
+
135
+ ### POST /verifications/{verification}/blocked-face — `client.verifications.block_face(verification_id, *, reason_code=None, reason=None)` → `None`
136
+ Request: `reason_code: BlockFaceReasonCode | str`, `reason: str(<=1000)`.
137
+ Response: `204 No Content`.
138
+
139
+ Every `verification_id` and `media_id` must be a ProofAge id (letters, digits, `-`, `_`); anything else raises `ValueError` before a request is built.
140
+
141
+ ## Enums
142
+
143
+ - `status` (`VerificationStatus`): `created`, `started`, `submitted`, `resubmission_requested`, `approved`, `declined`, `abandoned`, `expired`, `review`, or `documents_required` (the last is surfaced from the latest attempt's state, not a verification status). Open: an unknown value arrives as `str`.
144
+ - `reason_code` (`BlockFaceReasonCode`): `presentation_attack` (spoof: screen, print or mask), `fraudulent_document` (forged, edited, or not a real document), `scam_or_abuse` (identity may be genuine — blocked for behaviour on your platform), `underage`, `other` (explain in `reason`). Optional over the API, mandatory in the ProofAge consoles: send it whenever a person made the decision, or the block cannot be told apart from an automated one in reporting.
145
+ - `reason` (on `declined` / `resubmission_requested`): dotted codes from the server's reason catalog — illustrative examples: `aml.blocklist.face_match`, `document.face.mismatch`, `verification.age_threshold.failed`. Treat `reason` as an open string.
146
+
147
+ ## Outbound webhook (ProofAge → your `callback_url` / workspace webhook URL)
148
+
149
+ Headers: `X-Auth-Client` (api key), `X-Timestamp` (unix seconds), `X-HMAC-Signature`
150
+ (= hex HMAC-SHA256 of `{timestamp}.{rawJsonBody}` with the active secret key),
151
+ `X-ProofAge-Webhook-Delivery-Id` (the same on every automatic retry of one delivery, a new one on a
152
+ manual resend — de-duplicate on it).
153
+
154
+ ```python
155
+ from proofage import WebhookVerificationError, verify_webhook
156
+
157
+ try:
158
+ # api_key=, secret_key= and tolerance= fall back to the environment
159
+ event = verify_webhook(raw_body, headers)
160
+ except WebhookVerificationError as error:
161
+ ... # answer error.http_status; error.code says which check failed
162
+ ```
163
+
164
+ - `verify_webhook(raw_body, headers, *, api_key=None, secret_key=None, tolerance=None)` → `WebhookEvent`; `verify_webhook_signature(...)` → `None` (checks only). Keys and tolerance fall back to `PROOFAGE_API_KEY`, `PROOFAGE_SECRET_KEY`, `PROOFAGE_WEBHOOK_TOLERANCE` (default 300 seconds, in both directions).
165
+ - Pass the body **exactly as received** (`bytes` or `str`). A body whose only change is whitespace is still accepted (the canonical compact JSON is tried once), but one that lost PHP's `\/` escapes is not.
166
+ - Error codes, in check order: `MISSING_SIGNATURE`, `MISSING_TIMESTAMP`, `MISSING_AUTH_CLIENT` (401); `CONFIGURATION_ERROR` (500, keys missing — checked after the three headers); `INVALID_AUTH_CLIENT`, `MISSING_TIMESTAMP` for a non-integer timestamp, `TIMESTAMP_TOO_OLD`, `INVALID_SIGNATURE` (401); `INVALID_PAYLOAD` (400, correctly signed but not a webhook event; the message names the fields, never the body's values).
167
+ - `WebhookEvent`:
168
+
169
+ ```
170
+ {
171
+ "verification_id": str,
172
+ "status": VerificationStatus | str,
173
+ "external_id": str|None,
174
+ "external_metadata": dict|None,
175
+ "reason": str|None, # a code only on resubmission_requested / declined
176
+ "timestamp": datetime,
177
+ "duplicate_detected": bool (default False), # the three duplicate_* keys appear together
178
+ "duplicate_count": int|None,
179
+ "duplicate_of": { "verification_id": str, "external_id": str|None }|None,
180
+ "fingerprint_signals": dict|None,
181
+ "manual_moderation": { # after a console approve/decline
182
+ "action": "approve"|"decline", "reason": str, "source": "tenant_admin"|"landlord_admin",
183
+ "performed_by": { "id": int, "name": str|None, "email": str|None, "role": str|None },
184
+ "source_status": str|None, "source_reason": str|None
185
+ }|None,
186
+ "delivery_id": str|None # from X-ProofAge-Webhook-Delivery-Id
187
+ }
188
+ ```
189
+
190
+ ## SDK identification
191
+
192
+ Every request carries `X-ProofAge-Sdk: [wrapper tokens ]python/{version}` (wrappers pass
193
+ `sdk_tokens=["telegram-bot/1.2.0"]`, outermost first; a wrapper token named `python` is dropped)
194
+ and `User-Agent: ProofAge-Python/{version} (Python {x.y.z})` unless you pass `user_agent` or your
195
+ `http_client` carries its own. Neither header is part of the signature.
196
+
197
+ ## Keeping this in sync
198
+
199
+ This contract is drift-tested against `openapi.json` by `tests/test_api_contract.py`, so it stays
200
+ aligned with the API. Maintainers refreshing it after an API change: see the SDK contract-sync
201
+ runbook in the ProofAge app repo (`developer-docs/README.md`, "Keeping the SDK clients in sync"),
202
+ the single source of truth for all SDKs.
proofage/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """Python client for the ProofAge age and identity verification API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._async_client import AsyncProofAge
6
+ from ._client import ProofAge
7
+ from ._version import __version__
8
+ from .errors import (
9
+ AuthenticationError,
10
+ ConfigurationError,
11
+ NotFoundError,
12
+ PaymentRequiredError,
13
+ PermissionDeniedError,
14
+ ProofAgeError,
15
+ RateLimitError,
16
+ ServerError,
17
+ TransportError,
18
+ ValidationError,
19
+ WebhookVerificationError,
20
+ )
21
+ from .models import BlockFaceReasonCode, VerificationStatus, WebhookEvent
22
+ from .webhooks import verify_webhook, verify_webhook_signature
23
+
24
+ __all__ = [
25
+ "AsyncProofAge",
26
+ "AuthenticationError",
27
+ "BlockFaceReasonCode",
28
+ "ConfigurationError",
29
+ "NotFoundError",
30
+ "PaymentRequiredError",
31
+ "PermissionDeniedError",
32
+ "ProofAge",
33
+ "ProofAgeError",
34
+ "RateLimitError",
35
+ "ServerError",
36
+ "TransportError",
37
+ "ValidationError",
38
+ "VerificationStatus",
39
+ "WebhookEvent",
40
+ "WebhookVerificationError",
41
+ "__version__",
42
+ "verify_webhook",
43
+ "verify_webhook_signature",
44
+ ]
@@ -0,0 +1,186 @@
1
+ """The asynchronous client: the same surface as `ProofAge`, awaited."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
8
+ from contextlib import asynccontextmanager
9
+ from types import TracebackType
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from ._config import resolve_config
15
+ from ._transport import (
16
+ DOWNLOAD_ACCEPT,
17
+ M,
18
+ PreparedRequest,
19
+ api_path,
20
+ decode_success,
21
+ error_for_response,
22
+ is_retryable_exception,
23
+ is_retryable_status,
24
+ parse_model,
25
+ parse_retry_after,
26
+ prepare_json,
27
+ prepare_multipart,
28
+ retry_delay,
29
+ within_retry_after_cap,
30
+ )
31
+ from .errors import TransportError
32
+ from .resources.verifications import AsyncVerifications
33
+ from .resources.workspace import AsyncWorkspace
34
+
35
+
36
+ class AsyncProofAge:
37
+ """Asynchronous ProofAge client. Use it with `async with` or call `aclose()`."""
38
+
39
+ def __init__(
40
+ self,
41
+ *,
42
+ api_key: str | None = None,
43
+ secret_key: str | None = None,
44
+ base_url: str | None = None,
45
+ version: str | None = None,
46
+ timeout: float | None = None,
47
+ retry_attempts: int | None = None,
48
+ retry_delay: float | None = None,
49
+ download_retry_attempts: int | None = None,
50
+ sdk_tokens: Sequence[str] = (),
51
+ user_agent: str | None = None,
52
+ http_client: httpx.AsyncClient | None = None,
53
+ ) -> None:
54
+ self._config = resolve_config(
55
+ api_key=api_key,
56
+ secret_key=secret_key,
57
+ base_url=base_url,
58
+ version=version,
59
+ timeout=timeout,
60
+ retry_attempts=retry_attempts,
61
+ retry_delay=retry_delay,
62
+ download_retry_attempts=download_retry_attempts,
63
+ sdk_tokens=sdk_tokens,
64
+ user_agent=user_agent,
65
+ client_headers=http_client.headers if http_client is not None else None,
66
+ )
67
+ self._owns_http = http_client is None
68
+ self._http = http_client or httpx.AsyncClient(timeout=self._config.timeout)
69
+ self._sleep: Callable[[float], Awaitable[None]] = asyncio.sleep
70
+ self.workspace = AsyncWorkspace(self)
71
+ self.verifications = AsyncVerifications(self)
72
+
73
+ def __repr__(self) -> str:
74
+ return f"AsyncProofAge({self._config!r})"
75
+
76
+ async def aclose(self) -> None:
77
+ """Close the HTTP client, unless it was passed in by the caller."""
78
+ if self._owns_http:
79
+ await self._http.aclose()
80
+
81
+ async def __aenter__(self) -> AsyncProofAge:
82
+ return self
83
+
84
+ async def __aexit__(
85
+ self,
86
+ exc_type: type[BaseException] | None,
87
+ exc: BaseException | None,
88
+ traceback: TracebackType | None,
89
+ ) -> None:
90
+ await self.aclose()
91
+
92
+ async def _get(self, endpoint: str) -> Any:
93
+ return await self._send(prepare_json(self._config, "GET", endpoint), expect_body=True)
94
+
95
+ async def _post(self, endpoint: str, payload: Mapping[str, Any]) -> Any:
96
+ prepared = prepare_json(self._config, "POST", endpoint, payload)
97
+ return await self._send(prepared, expect_body=True)
98
+
99
+ async def _get_model(self, endpoint: str, model: type[M]) -> M:
100
+ data = await self._get(endpoint)
101
+ return parse_model(model, data, f"GET {api_path(self._config, endpoint)}")
102
+
103
+ async def _post_model(self, endpoint: str, payload: Mapping[str, Any], model: type[M]) -> M:
104
+ data = await self._post(endpoint, payload)
105
+ return parse_model(model, data, f"POST {api_path(self._config, endpoint)}")
106
+
107
+ async def _post_empty(self, endpoint: str, payload: Mapping[str, Any] | None = None) -> None:
108
+ prepared = prepare_json(self._config, "POST", endpoint, payload)
109
+ await self._send(prepared, expect_body=False)
110
+
111
+ async def _post_multipart(
112
+ self, endpoint: str, fields: Mapping[str, Any], *, filename: str, content: bytes
113
+ ) -> None:
114
+ prepared = prepare_multipart(
115
+ self._config, "POST", endpoint, fields, filename=filename, content=content
116
+ )
117
+ await self._send(prepared, expect_body=False)
118
+
119
+ async def _send(self, prepared: PreparedRequest, *, expect_body: bool) -> Any:
120
+ attempts = self._config.retry_attempts
121
+ for attempt in range(attempts):
122
+ last = attempt == attempts - 1
123
+ try:
124
+ response = await self._http.request(
125
+ prepared.method,
126
+ prepared.url,
127
+ headers=prepared.headers,
128
+ content=prepared.content,
129
+ data=prepared.data,
130
+ files=prepared.files,
131
+ timeout=self._config.timeout,
132
+ )
133
+ except httpx.TransportError as exc:
134
+ if not last and is_retryable_exception(prepared.method, exc):
135
+ await self._sleep(retry_delay(self._config, attempt, None, None))
136
+ continue
137
+ raise TransportError(f"{prepared.method} {prepared.url} failed: {exc}") from exc
138
+
139
+ if response.is_success:
140
+ return decode_success(
141
+ response.status_code,
142
+ response.text,
143
+ prepared.url,
144
+ response.headers.get("content-type"),
145
+ expect_body=expect_body,
146
+ )
147
+ retry_after = parse_retry_after(response.headers.get("retry-after"), time.time())
148
+ if (
149
+ not last
150
+ and is_retryable_status(prepared.method, response.status_code)
151
+ and within_retry_after_cap(response.status_code, retry_after)
152
+ ):
153
+ await self._sleep(
154
+ retry_delay(self._config, attempt, response.status_code, retry_after)
155
+ )
156
+ continue
157
+ raise error_for_response(response.status_code, response.text, retry_after)
158
+ raise AssertionError("unreachable: the loop returns or raises")
159
+
160
+ @asynccontextmanager
161
+ async def _stream_media(self, endpoint: str) -> AsyncIterator[httpx.Response]:
162
+ prepared = prepare_json(self._config, "GET", endpoint, accept=DOWNLOAD_ACCEPT)
163
+ attempts = self._config.download_retry_attempts
164
+ for attempt in range(attempts):
165
+ request = self._http.build_request(
166
+ "GET", prepared.url, headers=prepared.headers, timeout=self._config.timeout
167
+ )
168
+ try:
169
+ response = await self._http.send(request, stream=True)
170
+ except httpx.TransportError as exc:
171
+ if attempt < attempts - 1:
172
+ await self._sleep(retry_delay(self._config, attempt, None, None))
173
+ continue
174
+ raise TransportError(f"GET {prepared.url} failed: {exc}") from exc
175
+ try:
176
+ if not response.is_success:
177
+ await response.aread()
178
+ raise error_for_response(
179
+ response.status_code,
180
+ response.text,
181
+ parse_retry_after(response.headers.get("retry-after"), time.time()),
182
+ )
183
+ yield response
184
+ finally:
185
+ await response.aclose()
186
+ return
proofage/_client.py ADDED
@@ -0,0 +1,181 @@
1
+ """The synchronous client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable, Iterator, Mapping, Sequence
7
+ from contextlib import contextmanager
8
+ from types import TracebackType
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from ._config import resolve_config
14
+ from ._transport import (
15
+ DOWNLOAD_ACCEPT,
16
+ M,
17
+ PreparedRequest,
18
+ api_path,
19
+ decode_success,
20
+ error_for_response,
21
+ is_retryable_exception,
22
+ is_retryable_status,
23
+ parse_model,
24
+ parse_retry_after,
25
+ prepare_json,
26
+ prepare_multipart,
27
+ retry_delay,
28
+ within_retry_after_cap,
29
+ )
30
+ from .errors import TransportError
31
+ from .resources.verifications import Verifications
32
+ from .resources.workspace import Workspace
33
+
34
+
35
+ class ProofAge:
36
+ """Synchronous ProofAge client. Use it as a context manager or call `close()`."""
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ api_key: str | None = None,
42
+ secret_key: str | None = None,
43
+ base_url: str | None = None,
44
+ version: str | None = None,
45
+ timeout: float | None = None,
46
+ retry_attempts: int | None = None,
47
+ retry_delay: float | None = None,
48
+ download_retry_attempts: int | None = None,
49
+ sdk_tokens: Sequence[str] = (),
50
+ user_agent: str | None = None,
51
+ http_client: httpx.Client | None = None,
52
+ ) -> None:
53
+ self._config = resolve_config(
54
+ api_key=api_key,
55
+ secret_key=secret_key,
56
+ base_url=base_url,
57
+ version=version,
58
+ timeout=timeout,
59
+ retry_attempts=retry_attempts,
60
+ retry_delay=retry_delay,
61
+ download_retry_attempts=download_retry_attempts,
62
+ sdk_tokens=sdk_tokens,
63
+ user_agent=user_agent,
64
+ client_headers=http_client.headers if http_client is not None else None,
65
+ )
66
+ self._owns_http = http_client is None
67
+ self._http = http_client or httpx.Client(timeout=self._config.timeout)
68
+ self._sleep: Callable[[float], None] = time.sleep
69
+ self.workspace = Workspace(self)
70
+ self.verifications = Verifications(self)
71
+
72
+ def __repr__(self) -> str:
73
+ return f"ProofAge({self._config!r})"
74
+
75
+ def close(self) -> None:
76
+ """Close the HTTP client, unless it was passed in by the caller."""
77
+ if self._owns_http:
78
+ self._http.close()
79
+
80
+ def __enter__(self) -> ProofAge:
81
+ return self
82
+
83
+ def __exit__(
84
+ self,
85
+ exc_type: type[BaseException] | None,
86
+ exc: BaseException | None,
87
+ traceback: TracebackType | None,
88
+ ) -> None:
89
+ self.close()
90
+
91
+ def _get(self, endpoint: str) -> Any:
92
+ return self._send(prepare_json(self._config, "GET", endpoint), expect_body=True)
93
+
94
+ def _post(self, endpoint: str, payload: Mapping[str, Any]) -> Any:
95
+ return self._send(prepare_json(self._config, "POST", endpoint, payload), expect_body=True)
96
+
97
+ def _get_model(self, endpoint: str, model: type[M]) -> M:
98
+ data = self._get(endpoint)
99
+ return parse_model(model, data, f"GET {api_path(self._config, endpoint)}")
100
+
101
+ def _post_model(self, endpoint: str, payload: Mapping[str, Any], model: type[M]) -> M:
102
+ data = self._post(endpoint, payload)
103
+ return parse_model(model, data, f"POST {api_path(self._config, endpoint)}")
104
+
105
+ def _post_empty(self, endpoint: str, payload: Mapping[str, Any] | None = None) -> None:
106
+ self._send(prepare_json(self._config, "POST", endpoint, payload), expect_body=False)
107
+
108
+ def _post_multipart(
109
+ self, endpoint: str, fields: Mapping[str, Any], *, filename: str, content: bytes
110
+ ) -> None:
111
+ prepared = prepare_multipart(
112
+ self._config, "POST", endpoint, fields, filename=filename, content=content
113
+ )
114
+ self._send(prepared, expect_body=False)
115
+
116
+ def _send(self, prepared: PreparedRequest, *, expect_body: bool) -> Any:
117
+ attempts = self._config.retry_attempts
118
+ for attempt in range(attempts):
119
+ last = attempt == attempts - 1
120
+ try:
121
+ response = self._http.request(
122
+ prepared.method,
123
+ prepared.url,
124
+ headers=prepared.headers,
125
+ content=prepared.content,
126
+ data=prepared.data,
127
+ files=prepared.files,
128
+ timeout=self._config.timeout,
129
+ )
130
+ except httpx.TransportError as exc:
131
+ if not last and is_retryable_exception(prepared.method, exc):
132
+ self._sleep(retry_delay(self._config, attempt, None, None))
133
+ continue
134
+ raise TransportError(f"{prepared.method} {prepared.url} failed: {exc}") from exc
135
+
136
+ if response.is_success:
137
+ return decode_success(
138
+ response.status_code,
139
+ response.text,
140
+ prepared.url,
141
+ response.headers.get("content-type"),
142
+ expect_body=expect_body,
143
+ )
144
+ retry_after = parse_retry_after(response.headers.get("retry-after"), time.time())
145
+ if (
146
+ not last
147
+ and is_retryable_status(prepared.method, response.status_code)
148
+ and within_retry_after_cap(response.status_code, retry_after)
149
+ ):
150
+ self._sleep(retry_delay(self._config, attempt, response.status_code, retry_after))
151
+ continue
152
+ raise error_for_response(response.status_code, response.text, retry_after)
153
+ raise AssertionError("unreachable: the loop returns or raises")
154
+
155
+ @contextmanager
156
+ def _stream_media(self, endpoint: str) -> Iterator[httpx.Response]:
157
+ prepared = prepare_json(self._config, "GET", endpoint, accept=DOWNLOAD_ACCEPT)
158
+ attempts = self._config.download_retry_attempts
159
+ for attempt in range(attempts):
160
+ request = self._http.build_request(
161
+ "GET", prepared.url, headers=prepared.headers, timeout=self._config.timeout
162
+ )
163
+ try:
164
+ response = self._http.send(request, stream=True)
165
+ except httpx.TransportError as exc:
166
+ if attempt < attempts - 1:
167
+ self._sleep(retry_delay(self._config, attempt, None, None))
168
+ continue
169
+ raise TransportError(f"GET {prepared.url} failed: {exc}") from exc
170
+ try:
171
+ if not response.is_success:
172
+ response.read()
173
+ raise error_for_response(
174
+ response.status_code,
175
+ response.text,
176
+ parse_retry_after(response.headers.get("retry-after"), time.time()),
177
+ )
178
+ yield response
179
+ finally:
180
+ response.close()
181
+ return