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.
@@ -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
@@ -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())