axivoy 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.
- axivoy-1.0.0/.gitignore +5 -0
- axivoy-1.0.0/PKG-INFO +28 -0
- axivoy-1.0.0/README.md +17 -0
- axivoy-1.0.0/axivoy/__init__.py +33 -0
- axivoy-1.0.0/axivoy/client.py +166 -0
- axivoy-1.0.0/axivoy/errors.py +27 -0
- axivoy-1.0.0/axivoy/py.typed +0 -0
- axivoy-1.0.0/axivoy/webhooks.py +50 -0
- axivoy-1.0.0/pyproject.toml +29 -0
axivoy-1.0.0/.gitignore
ADDED
axivoy-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: axivoy
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Typed client for the Axivoy API: operations, webhooks and errors.
|
|
5
|
+
Project-URL: Documentation, https://app.axivoy.ai/developers
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Typing :: Typed
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# axivoy
|
|
13
|
+
|
|
14
|
+
Typed client for the [Axivoy API](https://app.axivoy.ai/developers): every published operation, refusals as exceptions and webhook signature checks. Generated from the public OpenAPI reference; standard library only, Python 3.11+.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import os
|
|
18
|
+
from axivoy import AxivoyClient, client_credentials
|
|
19
|
+
|
|
20
|
+
client = AxivoyClient(token=client_credentials(os.environ["AXIVOY_CLIENT_ID"], os.environ["AXIVOY_CLIENT_SECRET"]))
|
|
21
|
+
page = client.call("loads.list", {"limit": 25})
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Guide: [Client libraries](https://app.axivoy.ai/developers/guides/client-libraries).
|
|
25
|
+
|
|
26
|
+
## Maintainers
|
|
27
|
+
|
|
28
|
+
Source lives in the Axivoy TMS repository under `clients/python` and is released by the `client-libraries` workflow when the reference changes. `py scripts/generate.py` rebuilds `axivoy/generated.py` from `docs/api/openapi-public.json`; `py -m unittest discover tests`; `py scripts/smoke.py` runs against a live install.
|
axivoy-1.0.0/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# axivoy
|
|
2
|
+
|
|
3
|
+
Typed client for the [Axivoy API](https://app.axivoy.ai/developers): every published operation, refusals as exceptions and webhook signature checks. Generated from the public OpenAPI reference; standard library only, Python 3.11+.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
import os
|
|
7
|
+
from axivoy import AxivoyClient, client_credentials
|
|
8
|
+
|
|
9
|
+
client = AxivoyClient(token=client_credentials(os.environ["AXIVOY_CLIENT_ID"], os.environ["AXIVOY_CLIENT_SECRET"]))
|
|
10
|
+
page = client.call("loads.list", {"limit": 25})
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Guide: [Client libraries](https://app.axivoy.ai/developers/guides/client-libraries).
|
|
14
|
+
|
|
15
|
+
## Maintainers
|
|
16
|
+
|
|
17
|
+
Source lives in the Axivoy TMS repository under `clients/python` and is released by the `client-libraries` workflow when the reference changes. `py scripts/generate.py` rebuilds `axivoy/generated.py` from `docs/api/openapi-public.json`; `py -m unittest discover tests`; `py scripts/smoke.py` runs against a live install.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Axivoy API client: operations, webhooks and errors. Docs: https://app.axivoy.ai/developers"""
|
|
2
|
+
|
|
3
|
+
from .client import (
|
|
4
|
+
DEFAULT_BASE_URL,
|
|
5
|
+
DEFAULT_TOKEN_URL,
|
|
6
|
+
AxivoyClient,
|
|
7
|
+
Response,
|
|
8
|
+
Transport,
|
|
9
|
+
client_credentials,
|
|
10
|
+
urllib_transport,
|
|
11
|
+
)
|
|
12
|
+
from .errors import AxivoyError, AxivoyProposal, AxivoyWebhookError
|
|
13
|
+
from .generated import API_VERSION, EventType, OperationKey
|
|
14
|
+
from .webhooks import verify_webhook
|
|
15
|
+
|
|
16
|
+
__version__ = API_VERSION
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"API_VERSION",
|
|
20
|
+
"DEFAULT_BASE_URL",
|
|
21
|
+
"DEFAULT_TOKEN_URL",
|
|
22
|
+
"AxivoyClient",
|
|
23
|
+
"AxivoyError",
|
|
24
|
+
"AxivoyProposal",
|
|
25
|
+
"AxivoyWebhookError",
|
|
26
|
+
"EventType",
|
|
27
|
+
"OperationKey",
|
|
28
|
+
"Response",
|
|
29
|
+
"Transport",
|
|
30
|
+
"client_credentials",
|
|
31
|
+
"urllib_transport",
|
|
32
|
+
"verify_webhook",
|
|
33
|
+
]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import random
|
|
5
|
+
import ssl
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Callable, Dict, Mapping, Optional, Union
|
|
13
|
+
|
|
14
|
+
from .errors import AxivoyError, AxivoyProposal
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://api.axivoy.ai/v1"
|
|
17
|
+
DEFAULT_TOKEN_URL = "https://app.axivoy.ai/oauth/token"
|
|
18
|
+
DRY_RUN_HEADER = "Axivoy-Dry-Run"
|
|
19
|
+
|
|
20
|
+
TokenSource = Union[str, Callable[[], str]]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Response:
|
|
25
|
+
status: int
|
|
26
|
+
headers: Mapping[str, str]
|
|
27
|
+
body: bytes
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
Transport = Callable[[str, bytes, Dict[str, str], float], Response]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def urllib_transport(ssl_context: Optional[ssl.SSLContext] = None) -> Transport:
|
|
34
|
+
"""The default transport (standard library only)."""
|
|
35
|
+
|
|
36
|
+
def send(url: str, data: bytes, headers: Dict[str, str], timeout: float) -> Response:
|
|
37
|
+
request = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
|
38
|
+
try:
|
|
39
|
+
with urllib.request.urlopen(request, timeout=timeout, context=ssl_context) as reply:
|
|
40
|
+
return Response(reply.status, dict(reply.headers.items()), reply.read())
|
|
41
|
+
except urllib.error.HTTPError as error:
|
|
42
|
+
return Response(error.code, dict(error.headers.items()), error.read())
|
|
43
|
+
|
|
44
|
+
return send
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def client_credentials(
|
|
48
|
+
client_id: str,
|
|
49
|
+
client_secret: str,
|
|
50
|
+
token_url: str = DEFAULT_TOKEN_URL,
|
|
51
|
+
transport: Optional[Transport] = None,
|
|
52
|
+
timeout: float = 30.0,
|
|
53
|
+
) -> Callable[[], str]:
|
|
54
|
+
"""A token source for an API key: reuses the token until a minute before it expires."""
|
|
55
|
+
send = transport or urllib_transport()
|
|
56
|
+
cached: Dict[str, Any] = {}
|
|
57
|
+
|
|
58
|
+
def token() -> str:
|
|
59
|
+
if cached and time.monotonic() < cached["expires_at"]:
|
|
60
|
+
return str(cached["token"])
|
|
61
|
+
data = urllib.parse.urlencode(
|
|
62
|
+
{"grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret}
|
|
63
|
+
).encode()
|
|
64
|
+
reply = send(token_url, data, {"Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded"}, timeout)
|
|
65
|
+
body = _json(reply.body)
|
|
66
|
+
if reply.status != 200 or not isinstance(body.get("access_token"), str):
|
|
67
|
+
raise AxivoyError(
|
|
68
|
+
reply.status,
|
|
69
|
+
{
|
|
70
|
+
"code": body.get("error", "token_failed"),
|
|
71
|
+
"message": body.get("error_description", f"Token request failed with HTTP {reply.status}."),
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
lifetime = body.get("expires_in") if isinstance(body.get("expires_in"), int) else 3600
|
|
75
|
+
cached.update(token=body["access_token"], expires_at=time.monotonic() + max(0, lifetime - 60))
|
|
76
|
+
return str(body["access_token"])
|
|
77
|
+
|
|
78
|
+
return token
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class AxivoyClient:
|
|
83
|
+
"""Runs Axivoy operations: ``client.call("loads.list", {"limit": 25})``.
|
|
84
|
+
|
|
85
|
+
``token`` is an access token or a function returning a fresh one
|
|
86
|
+
(see ``client_credentials``). Network errors, 429, 409 ``in_flight`` and
|
|
87
|
+
5xx are retried up to ``max_retries`` times with the same idempotency key.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
token: TokenSource
|
|
91
|
+
base_url: str = DEFAULT_BASE_URL
|
|
92
|
+
max_retries: int = 3
|
|
93
|
+
timeout: float = 30.0
|
|
94
|
+
transport: Transport = field(default_factory=urllib_transport)
|
|
95
|
+
|
|
96
|
+
def call(self, key: str, input: Optional[Mapping[str, Any]] = None, *, idempotency_key: Optional[str] = None) -> Any:
|
|
97
|
+
"""Runs one operation and returns its ``result``.
|
|
98
|
+
|
|
99
|
+
Raises ``AxivoyError`` when refused and ``AxivoyProposal`` when the
|
|
100
|
+
write was sent for approval.
|
|
101
|
+
"""
|
|
102
|
+
return self._post(key, input, idempotency_key, dry_run=False)["result"]
|
|
103
|
+
|
|
104
|
+
def dry_run(self, key: str, input: Optional[Mapping[str, Any]] = None, *, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
105
|
+
"""Validates and authorizes without running anything: ``{"outcome", "input", "simulation"?}``."""
|
|
106
|
+
return self._post(key, input, idempotency_key, dry_run=True)["dry_run"]
|
|
107
|
+
|
|
108
|
+
def _post(self, key: str, input: Optional[Mapping[str, Any]], idempotency_key: Optional[str], dry_run: bool) -> Dict[str, Any]:
|
|
109
|
+
url = f"{self.base_url.rstrip('/')}/ops/{urllib.parse.quote(key, safe='.')}"
|
|
110
|
+
data = json.dumps(dict(input or {})).encode()
|
|
111
|
+
idempotency = idempotency_key or str(uuid.uuid4())
|
|
112
|
+
attempt = 0
|
|
113
|
+
while True:
|
|
114
|
+
headers = {
|
|
115
|
+
"Authorization": f"Bearer {self._token()}",
|
|
116
|
+
"Accept": "application/json",
|
|
117
|
+
"Content-Type": "application/json",
|
|
118
|
+
"Idempotency-Key": idempotency,
|
|
119
|
+
}
|
|
120
|
+
if dry_run:
|
|
121
|
+
headers[DRY_RUN_HEADER] = "true"
|
|
122
|
+
try:
|
|
123
|
+
reply = self.transport(url, data, headers, self.timeout)
|
|
124
|
+
except (urllib.error.URLError, OSError):
|
|
125
|
+
if attempt >= self.max_retries:
|
|
126
|
+
raise
|
|
127
|
+
time.sleep(_backoff(attempt))
|
|
128
|
+
attempt += 1
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
body = _json(reply.body)
|
|
132
|
+
if reply.status == 200:
|
|
133
|
+
return body
|
|
134
|
+
if reply.status == 202 and isinstance(body.get("proposal"), dict):
|
|
135
|
+
raise AxivoyProposal(body["proposal"])
|
|
136
|
+
error = body.get("error") if isinstance(body.get("error"), dict) else {"code": "unexpected", "message": f"HTTP {reply.status}"}
|
|
137
|
+
retryable = reply.status in (429, 500, 502, 503, 504) or (reply.status == 409 and error.get("reason") == "in_flight")
|
|
138
|
+
if not retryable or attempt >= self.max_retries:
|
|
139
|
+
raise AxivoyError(reply.status, error)
|
|
140
|
+
time.sleep(_retry_after(reply.headers) or _backoff(attempt))
|
|
141
|
+
attempt += 1
|
|
142
|
+
|
|
143
|
+
def _token(self) -> str:
|
|
144
|
+
return self.token() if callable(self.token) else self.token
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _json(raw: bytes) -> Dict[str, Any]:
|
|
148
|
+
try:
|
|
149
|
+
decoded = json.loads(raw.decode() or "{}")
|
|
150
|
+
except ValueError:
|
|
151
|
+
return {}
|
|
152
|
+
return decoded if isinstance(decoded, dict) else {}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _backoff(attempt: int) -> float:
|
|
156
|
+
return min(8.0, 0.5 * 2**attempt) + random.random() / 4
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _retry_after(headers: Mapping[str, str]) -> Optional[float]:
|
|
160
|
+
for name, value in headers.items():
|
|
161
|
+
if name.lower() == "retry-after":
|
|
162
|
+
try:
|
|
163
|
+
return min(60.0, float(value))
|
|
164
|
+
except ValueError:
|
|
165
|
+
return None
|
|
166
|
+
return None
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Mapping, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AxivoyError(Exception):
|
|
7
|
+
"""A refused call: ``status`` is the HTTP status, ``code`` the stable refusal code."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, status: int, body: Mapping[str, Any]) -> None:
|
|
10
|
+
super().__init__(str(body.get("message", f"HTTP {status}")))
|
|
11
|
+
self.status = status
|
|
12
|
+
self.code: str = str(body.get("code", "unexpected"))
|
|
13
|
+
self.reason: Optional[str] = body.get("reason")
|
|
14
|
+
self.fields: Dict[str, List[str]] = dict(body.get("fields") or {})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AxivoyProposal(Exception):
|
|
18
|
+
"""A write sent for approval (HTTP 202). Check it later with ``approvals.list``."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, body: Mapping[str, Any]) -> None:
|
|
21
|
+
super().__init__(str(body.get("message", "")))
|
|
22
|
+
self.id: str = str(body.get("id", ""))
|
|
23
|
+
self.approval_url: Optional[str] = body.get("approval_url")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AxivoyWebhookError(Exception):
|
|
27
|
+
"""The ``Axivoy-Signature`` header is missing, stale or does not match."""
|
|
File without changes
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Dict, List, Optional, Union
|
|
8
|
+
|
|
9
|
+
from .errors import AxivoyWebhookError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def verify_webhook(
|
|
13
|
+
raw_body: Union[bytes, str],
|
|
14
|
+
signature_header: Optional[str],
|
|
15
|
+
secret: str,
|
|
16
|
+
*,
|
|
17
|
+
tolerance_seconds: int = 300,
|
|
18
|
+
now: Optional[int] = None,
|
|
19
|
+
) -> Dict[str, Any]:
|
|
20
|
+
"""Checks ``Axivoy-Signature`` (``t=<unix>,v1=<hex>``: HMAC-SHA256 of
|
|
21
|
+
``{t}.{raw body}``) and returns the parsed event.
|
|
22
|
+
|
|
23
|
+
Pass the raw body exactly as received. Raises ``AxivoyWebhookError``
|
|
24
|
+
when the header is missing, older than ``tolerance_seconds`` or does
|
|
25
|
+
not match.
|
|
26
|
+
"""
|
|
27
|
+
body = raw_body.encode() if isinstance(raw_body, str) else raw_body
|
|
28
|
+
timestamp: Optional[int] = None
|
|
29
|
+
signatures: List[str] = []
|
|
30
|
+
for piece in (signature_header or "").split(","):
|
|
31
|
+
name, _, value = piece.strip().partition("=")
|
|
32
|
+
if name == "t" and value.isdigit() and timestamp is None:
|
|
33
|
+
timestamp = int(value)
|
|
34
|
+
elif name == "v1" and value:
|
|
35
|
+
signatures.append(value)
|
|
36
|
+
if timestamp is None or not signatures:
|
|
37
|
+
raise AxivoyWebhookError("Missing or malformed Axivoy-Signature header.")
|
|
38
|
+
|
|
39
|
+
current = int(time.time()) if now is None else now
|
|
40
|
+
if abs(current - timestamp) > tolerance_seconds:
|
|
41
|
+
raise AxivoyWebhookError("Signature timestamp is outside the tolerance.")
|
|
42
|
+
|
|
43
|
+
expected = hmac.new(secret.encode(), str(timestamp).encode() + b"." + body, hashlib.sha256).hexdigest()
|
|
44
|
+
if not any(hmac.compare_digest(expected, signature) for signature in signatures):
|
|
45
|
+
raise AxivoyWebhookError("Signature does not match.")
|
|
46
|
+
|
|
47
|
+
event = json.loads(body.decode())
|
|
48
|
+
if not isinstance(event, dict):
|
|
49
|
+
raise AxivoyWebhookError("Body is not an event.")
|
|
50
|
+
return event
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "axivoy"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Typed client for the Axivoy API: operations, webhooks and errors."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
dependencies = []
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Typing :: Typed",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Documentation = "https://app.axivoy.ai/developers"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.version]
|
|
22
|
+
path = "axivoy/generated.py"
|
|
23
|
+
pattern = "API_VERSION = \"(?P<version>[^\"]+)\""
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["axivoy"]
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.sdist]
|
|
29
|
+
include = ["axivoy", "README.md"]
|