arbitr-sdk 0.2.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.
- arbitr_sdk-0.2.0/.gitignore +19 -0
- arbitr_sdk-0.2.0/LICENSE +21 -0
- arbitr_sdk-0.2.0/PKG-INFO +166 -0
- arbitr_sdk-0.2.0/README.md +137 -0
- arbitr_sdk-0.2.0/pyproject.toml +93 -0
- arbitr_sdk-0.2.0/scripts/check_operation_coverage.py +52 -0
- arbitr_sdk-0.2.0/scripts/check_pinned_spec.py +141 -0
- arbitr_sdk-0.2.0/scripts/generate_models.py +153 -0
- arbitr_sdk-0.2.0/src/arbitr/__init__.py +155 -0
- arbitr_sdk-0.2.0/src/arbitr/_constants.py +58 -0
- arbitr_sdk-0.2.0/src/arbitr/_coverage.py +127 -0
- arbitr_sdk-0.2.0/src/arbitr/_credentials.py +128 -0
- arbitr_sdk-0.2.0/src/arbitr/_datetime.py +23 -0
- arbitr_sdk-0.2.0/src/arbitr/_env.py +54 -0
- arbitr_sdk-0.2.0/src/arbitr/_files.py +177 -0
- arbitr_sdk-0.2.0/src/arbitr/_http.py +163 -0
- arbitr_sdk-0.2.0/src/arbitr/_locales.py +54 -0
- arbitr_sdk-0.2.0/src/arbitr/_parse.py +43 -0
- arbitr_sdk-0.2.0/src/arbitr/_projects.py +133 -0
- arbitr_sdk-0.2.0/src/arbitr/_spec.py +83 -0
- arbitr_sdk-0.2.0/src/arbitr/_version.py +7 -0
- arbitr_sdk-0.2.0/src/arbitr/_wait.py +59 -0
- arbitr_sdk-0.2.0/src/arbitr/async_client.py +518 -0
- arbitr_sdk-0.2.0/src/arbitr/cli.py +478 -0
- arbitr_sdk-0.2.0/src/arbitr/client.py +519 -0
- arbitr_sdk-0.2.0/src/arbitr/errors.py +354 -0
- arbitr_sdk-0.2.0/src/arbitr/generated/__init__.py +61 -0
- arbitr_sdk-0.2.0/src/arbitr/generated/models.py +413 -0
- arbitr_sdk-0.2.0/src/arbitr/openapi.json +1 -0
- arbitr_sdk-0.2.0/src/arbitr/py.typed +0 -0
- arbitr_sdk-0.2.0/src/arbitr/webhooks.py +85 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Secrets — never commit API keys.
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
!.env.example
|
|
5
|
+
|
|
6
|
+
__pycache__/
|
|
7
|
+
.venv/
|
|
8
|
+
venv/
|
|
9
|
+
*.pyc
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
.ruff_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.hypothesis/
|
|
14
|
+
.coverage
|
|
15
|
+
htmlcov/
|
|
16
|
+
dist/
|
|
17
|
+
build/
|
|
18
|
+
*.egg-info/
|
|
19
|
+
.DS_Store
|
arbitr_sdk-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Straker
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: arbitr-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Official Python client and CLI for the Arbitr External API
|
|
5
|
+
Project-URL: Homepage, https://github.com/strakergroup/arbitr-python
|
|
6
|
+
Project-URL: Documentation, https://api-arbitr.straker.ai/docs
|
|
7
|
+
Project-URL: Source, https://github.com/strakergroup/arbitr-python
|
|
8
|
+
Author: Straker
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api,arbitr,localisation,localization,sdk,straker,translation
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Topic :: Software Development :: Localization
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: httpx>=0.27
|
|
26
|
+
Requires-Dist: pydantic>=2
|
|
27
|
+
Requires-Dist: typer>=0.27.1
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# Arbitr Python
|
|
31
|
+
|
|
32
|
+
Official Python client and `arbitr` CLI for the [Arbitr External API](https://api-arbitr.straker.ai/docs).
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install arbitr-sdk
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The PyPI package is `arbitr-sdk`. The import and CLI stay `arbitr`.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from arbitr import ArbitrClient
|
|
42
|
+
|
|
43
|
+
client = ArbitrClient.from_env() # ARBITR_API_KEY, optional ARBITR_BASE_URL
|
|
44
|
+
project = client.projects.submit(
|
|
45
|
+
files=["report.docx"],
|
|
46
|
+
name="Q3 report",
|
|
47
|
+
target_language_codes=["ko-kr", "fr-fr"],
|
|
48
|
+
idempotency_key="run-2026-08-report",
|
|
49
|
+
)
|
|
50
|
+
final = client.projects.wait(project.id)
|
|
51
|
+
client.projects.download_zip(final.id, "out/deliverables.zip")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Async:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from arbitr import AsyncArbitrClient
|
|
58
|
+
|
|
59
|
+
async with AsyncArbitrClient.from_env() as client:
|
|
60
|
+
me = await client.me()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Coding agents: give your agent this prompt:
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
Set up arbitr for me. Fetch https://arbitr.apidocumentation.com/getting-started/agent-setup/index.md and follow it.
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Or install the persistent agent skill:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npx -y skills add strakergroup/arbitr-python --skill arbitr --yes --global
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
CLI:
|
|
76
|
+
|
|
77
|
+
Mint a key at [https://arbitr.straker.ai/settings/api-keys](https://arbitr.straker.ai/settings/api-keys).
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
export ARBITR_API_KEY=abr_live_...
|
|
81
|
+
arbitr me
|
|
82
|
+
arbitr submit report.docx --locales ko-kr,fr-fr --wait --out out/
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Exit codes: `0` ok, `1` API error, `2` usage/config/network/timeout, `3` parked
|
|
86
|
+
at a human gate.
|
|
87
|
+
|
|
88
|
+
Default host is production: `https://api-arbitr.straker.ai`.
|
|
89
|
+
|
|
90
|
+
Language codes are lowercase BCP-47 tags (`ko-kr`, `fr-fr`). Bare codes (`ko`)
|
|
91
|
+
are rejected by the API — `client.languages.resolve(["ko"])` expands them, or
|
|
92
|
+
`arbitr submit --resolve-locales` does it for you.
|
|
93
|
+
|
|
94
|
+
The client wraps the **published** OpenAPI surface only. Deprecated aliases
|
|
95
|
+
(agent-selection, `/deliverables/zip`, `/resume`) are not wrapped; use the
|
|
96
|
+
canonical replacements (`wait()` / the Arbitr UI, `?format=zip`, `/resumptions`).
|
|
97
|
+
|
|
98
|
+
## Errors
|
|
99
|
+
|
|
100
|
+
Everything this package raises derives from `ArbitrBaseError`, so one `except`
|
|
101
|
+
is enough to be safe. Below it there are two branches:
|
|
102
|
+
|
|
103
|
+
| Branch | Raised when | Notable members |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `ArbitrError` | the API returned a non-2xx response | `AuthenticationError`, `PaymentRequiredError`, `NotFoundError`, `ConflictError`, `ValidationError`, `RateLimitError`, `GoneError`, `ServerError` |
|
|
106
|
+
| `ArbitrClientError` | the call failed before or instead of an error envelope | `TransportError` (`ConnectionFailedError`, `RequestTimeoutError`), `ClientInputError`, `ActionRequiredError`, `ProjectWaitTimeoutError`, `ResponseParseError` |
|
|
107
|
+
|
|
108
|
+
`httpx` exceptions never escape the client — connection and timeout failures
|
|
109
|
+
arrive as `ConnectionFailedError` / `RequestTimeoutError` with the original
|
|
110
|
+
exception on `__cause__`.
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from arbitr import ArbitrBaseError, PaymentRequiredError
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
client.projects.resume(project_id)
|
|
117
|
+
except PaymentRequiredError as exc:
|
|
118
|
+
print(f"short by {exc.shortfall} credits")
|
|
119
|
+
except ArbitrBaseError as exc:
|
|
120
|
+
print(f"call failed: {exc}")
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Retries and rate limits
|
|
124
|
+
|
|
125
|
+
Retries are opt-in and off by default on the library client:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
client = ArbitrClient.from_env(max_retries=3)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The CLI retries GET up to 3 times (`--max-retries` / `ARBITR_MAX_RETRIES`).
|
|
132
|
+
|
|
133
|
+
Only GET is replayed — `Retry-After` is honoured on 429 and 5xx back off
|
|
134
|
+
exponentially (capped at 60s). `POST /v1/projects` is never retried
|
|
135
|
+
automatically because its multipart body streams file handles; pass
|
|
136
|
+
`idempotency_key=` and retry it yourself. After any call,
|
|
137
|
+
`client.rate_limit` holds the latest `X-RateLimit-*` values.
|
|
138
|
+
|
|
139
|
+
## Develop
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
uv sync
|
|
143
|
+
uv run pytest
|
|
144
|
+
uv run ruff check src tests scripts
|
|
145
|
+
uv run ty check
|
|
146
|
+
uv run python scripts/generate_models.py # after refreshing the pinned spec
|
|
147
|
+
uv run python scripts/check_operation_coverage.py
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Pin a fresh production spec:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
curl -sS https://api-arbitr.straker.ai/openapi.json -o src/arbitr/openapi.json
|
|
154
|
+
uv run python scripts/generate_models.py
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The snapshot ships inside the package, so an installed copy can diff itself
|
|
158
|
+
against a live host:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from arbitr import pinned_spec
|
|
162
|
+
|
|
163
|
+
print(sorted(pinned_spec()["paths"]))
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Do not edit `src/arbitr/generated/models.py` by hand.
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Arbitr Python
|
|
2
|
+
|
|
3
|
+
Official Python client and `arbitr` CLI for the [Arbitr External API](https://api-arbitr.straker.ai/docs).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install arbitr-sdk
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The PyPI package is `arbitr-sdk`. The import and CLI stay `arbitr`.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from arbitr import ArbitrClient
|
|
13
|
+
|
|
14
|
+
client = ArbitrClient.from_env() # ARBITR_API_KEY, optional ARBITR_BASE_URL
|
|
15
|
+
project = client.projects.submit(
|
|
16
|
+
files=["report.docx"],
|
|
17
|
+
name="Q3 report",
|
|
18
|
+
target_language_codes=["ko-kr", "fr-fr"],
|
|
19
|
+
idempotency_key="run-2026-08-report",
|
|
20
|
+
)
|
|
21
|
+
final = client.projects.wait(project.id)
|
|
22
|
+
client.projects.download_zip(final.id, "out/deliverables.zip")
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Async:
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from arbitr import AsyncArbitrClient
|
|
29
|
+
|
|
30
|
+
async with AsyncArbitrClient.from_env() as client:
|
|
31
|
+
me = await client.me()
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Coding agents: give your agent this prompt:
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
Set up arbitr for me. Fetch https://arbitr.apidocumentation.com/getting-started/agent-setup/index.md and follow it.
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or install the persistent agent skill:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npx -y skills add strakergroup/arbitr-python --skill arbitr --yes --global
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
CLI:
|
|
47
|
+
|
|
48
|
+
Mint a key at [https://arbitr.straker.ai/settings/api-keys](https://arbitr.straker.ai/settings/api-keys).
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
export ARBITR_API_KEY=abr_live_...
|
|
52
|
+
arbitr me
|
|
53
|
+
arbitr submit report.docx --locales ko-kr,fr-fr --wait --out out/
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Exit codes: `0` ok, `1` API error, `2` usage/config/network/timeout, `3` parked
|
|
57
|
+
at a human gate.
|
|
58
|
+
|
|
59
|
+
Default host is production: `https://api-arbitr.straker.ai`.
|
|
60
|
+
|
|
61
|
+
Language codes are lowercase BCP-47 tags (`ko-kr`, `fr-fr`). Bare codes (`ko`)
|
|
62
|
+
are rejected by the API — `client.languages.resolve(["ko"])` expands them, or
|
|
63
|
+
`arbitr submit --resolve-locales` does it for you.
|
|
64
|
+
|
|
65
|
+
The client wraps the **published** OpenAPI surface only. Deprecated aliases
|
|
66
|
+
(agent-selection, `/deliverables/zip`, `/resume`) are not wrapped; use the
|
|
67
|
+
canonical replacements (`wait()` / the Arbitr UI, `?format=zip`, `/resumptions`).
|
|
68
|
+
|
|
69
|
+
## Errors
|
|
70
|
+
|
|
71
|
+
Everything this package raises derives from `ArbitrBaseError`, so one `except`
|
|
72
|
+
is enough to be safe. Below it there are two branches:
|
|
73
|
+
|
|
74
|
+
| Branch | Raised when | Notable members |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `ArbitrError` | the API returned a non-2xx response | `AuthenticationError`, `PaymentRequiredError`, `NotFoundError`, `ConflictError`, `ValidationError`, `RateLimitError`, `GoneError`, `ServerError` |
|
|
77
|
+
| `ArbitrClientError` | the call failed before or instead of an error envelope | `TransportError` (`ConnectionFailedError`, `RequestTimeoutError`), `ClientInputError`, `ActionRequiredError`, `ProjectWaitTimeoutError`, `ResponseParseError` |
|
|
78
|
+
|
|
79
|
+
`httpx` exceptions never escape the client — connection and timeout failures
|
|
80
|
+
arrive as `ConnectionFailedError` / `RequestTimeoutError` with the original
|
|
81
|
+
exception on `__cause__`.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from arbitr import ArbitrBaseError, PaymentRequiredError
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
client.projects.resume(project_id)
|
|
88
|
+
except PaymentRequiredError as exc:
|
|
89
|
+
print(f"short by {exc.shortfall} credits")
|
|
90
|
+
except ArbitrBaseError as exc:
|
|
91
|
+
print(f"call failed: {exc}")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Retries and rate limits
|
|
95
|
+
|
|
96
|
+
Retries are opt-in and off by default on the library client:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
client = ArbitrClient.from_env(max_retries=3)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The CLI retries GET up to 3 times (`--max-retries` / `ARBITR_MAX_RETRIES`).
|
|
103
|
+
|
|
104
|
+
Only GET is replayed — `Retry-After` is honoured on 429 and 5xx back off
|
|
105
|
+
exponentially (capped at 60s). `POST /v1/projects` is never retried
|
|
106
|
+
automatically because its multipart body streams file handles; pass
|
|
107
|
+
`idempotency_key=` and retry it yourself. After any call,
|
|
108
|
+
`client.rate_limit` holds the latest `X-RateLimit-*` values.
|
|
109
|
+
|
|
110
|
+
## Develop
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
uv sync
|
|
114
|
+
uv run pytest
|
|
115
|
+
uv run ruff check src tests scripts
|
|
116
|
+
uv run ty check
|
|
117
|
+
uv run python scripts/generate_models.py # after refreshing the pinned spec
|
|
118
|
+
uv run python scripts/check_operation_coverage.py
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Pin a fresh production spec:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
curl -sS https://api-arbitr.straker.ai/openapi.json -o src/arbitr/openapi.json
|
|
125
|
+
uv run python scripts/generate_models.py
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The snapshot ships inside the package, so an installed copy can diff itself
|
|
129
|
+
against a live host:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from arbitr import pinned_spec
|
|
133
|
+
|
|
134
|
+
print(sorted(pinned_spec()["paths"]))
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Do not edit `src/arbitr/generated/models.py` by hand.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "arbitr-sdk"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Official Python client and CLI for the Arbitr External API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "Straker" }]
|
|
13
|
+
keywords = ["arbitr", "straker", "translation", "localization", "localisation", "api", "sdk"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
24
|
+
"Topic :: Software Development :: Localization",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"httpx>=0.27",
|
|
30
|
+
"pydantic>=2",
|
|
31
|
+
"typer>=0.27.1",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
arbitr = "arbitr.cli:entrypoint"
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/strakergroup/arbitr-python"
|
|
39
|
+
Documentation = "https://api-arbitr.straker.ai/docs"
|
|
40
|
+
Source = "https://github.com/strakergroup/arbitr-python"
|
|
41
|
+
|
|
42
|
+
[dependency-groups]
|
|
43
|
+
dev = [
|
|
44
|
+
"datamodel-code-generator>=0.75.1",
|
|
45
|
+
"hypothesis>=6.165.10",
|
|
46
|
+
"pytest>=9.1.1",
|
|
47
|
+
"pytest-asyncio>=1.4.0",
|
|
48
|
+
"respx>=0.23.1",
|
|
49
|
+
"ruff>=0.16.4",
|
|
50
|
+
"ty>=0.0.74",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[tool.hatch.version]
|
|
54
|
+
path = "src/arbitr/_version.py"
|
|
55
|
+
|
|
56
|
+
[tool.hatch.build.targets.wheel]
|
|
57
|
+
packages = ["src/arbitr"]
|
|
58
|
+
|
|
59
|
+
[tool.hatch.build.targets.sdist]
|
|
60
|
+
include = ["src", "scripts", "README.md", "LICENSE"]
|
|
61
|
+
|
|
62
|
+
[tool.uv]
|
|
63
|
+
package = true
|
|
64
|
+
|
|
65
|
+
[tool.pytest.ini_options]
|
|
66
|
+
testpaths = ["tests"]
|
|
67
|
+
addopts = "-q"
|
|
68
|
+
asyncio_mode = "auto"
|
|
69
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
70
|
+
|
|
71
|
+
[tool.ruff]
|
|
72
|
+
target-version = "py311"
|
|
73
|
+
line-length = 100
|
|
74
|
+
src = ["src", "tests", "scripts"]
|
|
75
|
+
|
|
76
|
+
[tool.ruff.lint]
|
|
77
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "C4", "RUF", "ANN", "ASYNC", "S"]
|
|
78
|
+
ignore = ["ANN401"]
|
|
79
|
+
|
|
80
|
+
[tool.ruff.lint.per-file-ignores]
|
|
81
|
+
"tests/**" = ["S101", "S105", "S106"]
|
|
82
|
+
"scripts/**" = ["S603", "S607"]
|
|
83
|
+
"src/arbitr/generated/**" = ["ALL"]
|
|
84
|
+
|
|
85
|
+
[tool.ty.environment]
|
|
86
|
+
python-version = "3.11"
|
|
87
|
+
extra-paths = ["src"]
|
|
88
|
+
|
|
89
|
+
[tool.ty.rules]
|
|
90
|
+
# Resource `.list()` methods shadow the builtin `list` in sibling annotations.
|
|
91
|
+
# Runtime is fine (PEP 563). Generated models also emit Pydantic `constr()`
|
|
92
|
+
# calls that ty rejects as type expressions.
|
|
93
|
+
invalid-type-form = "ignore"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Fail if a published OpenAPI operationId is missing from both clients.
|
|
2
|
+
|
|
3
|
+
The mapping tables live in ``arbitr._coverage`` so this script and
|
|
4
|
+
``tests/test_operation_coverage.py`` cannot drift apart.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from arbitr import ArbitrClient, AsyncArbitrClient
|
|
13
|
+
from arbitr._coverage import (
|
|
14
|
+
IGNORED_OPERATION_IDS,
|
|
15
|
+
OPERATION_METHODS,
|
|
16
|
+
audit_spec_mapping,
|
|
17
|
+
missing_client_methods,
|
|
18
|
+
published_operation_ids,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _client_errors() -> list[str]:
|
|
23
|
+
"""Check both clients expose every mapped operation, closing them after."""
|
|
24
|
+
errors: list[str] = []
|
|
25
|
+
with ArbitrClient(api_key="abr_test_coverage") as sync_client:
|
|
26
|
+
errors += [f"ArbitrClient missing {item}" for item in missing_client_methods(sync_client)]
|
|
27
|
+
|
|
28
|
+
async def check_async() -> list[str]:
|
|
29
|
+
async with AsyncArbitrClient(api_key="abr_test_coverage") as async_client:
|
|
30
|
+
return [
|
|
31
|
+
f"AsyncArbitrClient missing {item}" for item in missing_client_methods(async_client)
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
return errors + asyncio.run(check_async())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main() -> int:
|
|
38
|
+
"""Exit 1 when the snapshot and client methods drift."""
|
|
39
|
+
errors = audit_spec_mapping().problems() + _client_errors()
|
|
40
|
+
|
|
41
|
+
if errors:
|
|
42
|
+
print("operation coverage failed:", file=sys.stderr)
|
|
43
|
+
for line in errors:
|
|
44
|
+
print(f" {line}", file=sys.stderr)
|
|
45
|
+
return 1
|
|
46
|
+
ignored = len(published_operation_ids() & IGNORED_OPERATION_IDS)
|
|
47
|
+
print(f"ok: {len(OPERATION_METHODS)} operations on both clients; {ignored} ignored aliases")
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Fail if the packaged OpenAPI pin differs from live production.
|
|
2
|
+
|
|
3
|
+
Compares ``src/arbitr/openapi.json`` (via ``pinned_spec()``) to
|
|
4
|
+
``PROD_OPENAPI_URL``. Pytest stays offline; this script is the scheduled
|
|
5
|
+
CI entrypoint.
|
|
6
|
+
|
|
7
|
+
uv run python scripts/check_pinned_spec.py
|
|
8
|
+
uv run python scripts/check_pinned_spec.py --other-file /tmp/openapi.json
|
|
9
|
+
|
|
10
|
+
Exit 0 if they match after canonicalize, 1 on drift, 2 when the live
|
|
11
|
+
spec is unreadable (CI retries), 3 on unexpected script failures, 4 when
|
|
12
|
+
the packaged pin is unreadable (fail immediately; not a prod flake).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import sys
|
|
19
|
+
import traceback
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from arbitr._spec import (
|
|
25
|
+
PROD_OPENAPI_URL,
|
|
26
|
+
OpenAPIDocumentError,
|
|
27
|
+
openapi_document_diff,
|
|
28
|
+
parse_openapi_document,
|
|
29
|
+
pinned_spec,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_DIFF_HEAD_LINES = 200
|
|
33
|
+
_FETCH_TIMEOUT_SECONDS = 30.0
|
|
34
|
+
|
|
35
|
+
EXIT_MATCH = 0
|
|
36
|
+
EXIT_DRIFT = 1
|
|
37
|
+
EXIT_UNREADABLE = 2
|
|
38
|
+
EXIT_UNEXPECTED = 3
|
|
39
|
+
EXIT_PIN_UNREADABLE = 4
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SpecFetchError(Exception):
|
|
43
|
+
"""The live OpenAPI URL could not be fetched."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SpecReadError(Exception):
|
|
47
|
+
"""An OpenAPI JSON file could not be read from disk."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _print_cli_error(exc: BaseException) -> None:
|
|
51
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
52
|
+
if exc.__cause__ is not None:
|
|
53
|
+
print(f"cause: {exc.__cause__}", file=sys.stderr)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _print_unexpected(exc: BaseException) -> None:
|
|
57
|
+
print(f"error: unexpected failure: {exc}", file=sys.stderr)
|
|
58
|
+
traceback.print_exc(file=sys.stderr)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def fetch_openapi_url(url: str) -> dict[str, object]:
|
|
62
|
+
"""GET ``url`` and parse it as an OpenAPI object.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
SpecFetchError: On transport or HTTP failure.
|
|
66
|
+
OpenAPIDocumentError: If the body is not a JSON object.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
response = httpx.get(url, timeout=_FETCH_TIMEOUT_SECONDS, follow_redirects=True)
|
|
70
|
+
response.raise_for_status()
|
|
71
|
+
except httpx.HTTPError as exc:
|
|
72
|
+
raise SpecFetchError(f"failed to fetch {url}") from exc
|
|
73
|
+
return parse_openapi_document(response.text, source=url)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_openapi_file(path: Path) -> dict[str, object]:
|
|
77
|
+
"""Read an OpenAPI JSON file.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
SpecReadError: If the file cannot be read.
|
|
81
|
+
OpenAPIDocumentError: If the text is not a JSON object OpenAPI document.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
raw = path.read_text(encoding="utf-8")
|
|
85
|
+
except OSError as exc:
|
|
86
|
+
raise SpecReadError(f"cannot read {path}") from exc
|
|
87
|
+
return parse_openapi_document(raw, source=str(path))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main(argv: list[str] | None = None) -> int:
|
|
91
|
+
"""Compare the pin to a live URL or a local file. Returns a process exit code."""
|
|
92
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
93
|
+
source = parser.add_mutually_exclusive_group()
|
|
94
|
+
source.add_argument(
|
|
95
|
+
"--url",
|
|
96
|
+
default=None,
|
|
97
|
+
help=f"live OpenAPI URL (default: {PROD_OPENAPI_URL})",
|
|
98
|
+
)
|
|
99
|
+
source.add_argument(
|
|
100
|
+
"--other-file",
|
|
101
|
+
type=Path,
|
|
102
|
+
default=None,
|
|
103
|
+
help="compare the pin to this JSON file instead of fetching",
|
|
104
|
+
)
|
|
105
|
+
args = parser.parse_args(argv)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
pin = pinned_spec()
|
|
109
|
+
except (OpenAPIDocumentError, OSError) as exc:
|
|
110
|
+
_print_cli_error(exc)
|
|
111
|
+
return EXIT_PIN_UNREADABLE
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
_print_unexpected(exc)
|
|
114
|
+
return EXIT_UNEXPECTED
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
if args.other_file is not None:
|
|
118
|
+
live = load_openapi_file(args.other_file)
|
|
119
|
+
else:
|
|
120
|
+
live = fetch_openapi_url(args.url or PROD_OPENAPI_URL)
|
|
121
|
+
diff = openapi_document_diff(pin, live)
|
|
122
|
+
except (SpecFetchError, SpecReadError, OpenAPIDocumentError) as exc:
|
|
123
|
+
_print_cli_error(exc)
|
|
124
|
+
return EXIT_UNREADABLE
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
_print_unexpected(exc)
|
|
127
|
+
return EXIT_UNEXPECTED
|
|
128
|
+
|
|
129
|
+
if diff is None:
|
|
130
|
+
print("pinned spec matches live OpenAPI after canonicalize")
|
|
131
|
+
return EXIT_MATCH
|
|
132
|
+
|
|
133
|
+
lines = diff.splitlines(keepends=True)
|
|
134
|
+
sys.stdout.write("".join(lines[:_DIFF_HEAD_LINES]))
|
|
135
|
+
if len(lines) > _DIFF_HEAD_LINES:
|
|
136
|
+
print(f"... ({len(lines) - _DIFF_HEAD_LINES} more diff lines truncated)")
|
|
137
|
+
return EXIT_DRIFT
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
raise SystemExit(main())
|