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.
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ dist/
3
+ __pycache__/
4
+ *.egg-info/
5
+ axivoy/generated.py
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"]