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 +202 -0
- proofage/__init__.py +44 -0
- proofage/_async_client.py +186 -0
- proofage/_client.py +181 -0
- proofage/_config.py +188 -0
- proofage/_signing.py +107 -0
- proofage/_transport.py +290 -0
- proofage/_version.py +3 -0
- proofage/errors.py +113 -0
- proofage/models.py +210 -0
- proofage/openapi.json +1795 -0
- proofage/py.typed +0 -0
- proofage/resources/__init__.py +1 -0
- proofage/resources/_payloads.py +86 -0
- proofage/resources/verifications.py +352 -0
- proofage/resources/workspace.py +41 -0
- proofage/webhooks.py +127 -0
- proofage-0.1.0.dist-info/METADATA +243 -0
- proofage-0.1.0.dist-info/RECORD +21 -0
- proofage-0.1.0.dist-info/WHEEL +4 -0
- proofage-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|