arbitr-sdk 0.2.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.
arbitr/__init__.py ADDED
@@ -0,0 +1,155 @@
1
+ """Official Python client and CLI for the Arbitr External API."""
2
+
3
+ from arbitr._constants import (
4
+ ACTION_REQUIRED_STATUSES,
5
+ ALLOWED_UPLOAD_EXTENSIONS,
6
+ DEFAULT_BASE_URL,
7
+ MAX_UPLOAD_FILES,
8
+ MAX_UPLOAD_TOTAL_BYTES,
9
+ SUPPORTED_FORMATS,
10
+ TERMINAL_STATUSES,
11
+ )
12
+ from arbitr._datetime import UtcDatetime
13
+ from arbitr._projects import ProjectResumptionResponse
14
+ from arbitr._spec import OpenAPIDocumentError, pinned_spec
15
+ from arbitr._version import __version__
16
+ from arbitr.async_client import AsyncArbitrClient
17
+ from arbitr.client import ArbitrClient, new_idempotency_key
18
+ from arbitr.errors import (
19
+ ActionRequiredError,
20
+ AmbiguousLocaleCodesError,
21
+ ArbitrBaseError,
22
+ ArbitrClientError,
23
+ ArbitrError,
24
+ AuthenticationError,
25
+ BareLocaleCodeError,
26
+ ClientInputError,
27
+ ConflictError,
28
+ ConnectionFailedError,
29
+ DisallowedFileExtensionError,
30
+ FindingsKeysetError,
31
+ GoneError,
32
+ MissingApiKeyError,
33
+ NotFoundError,
34
+ PaymentRequiredError,
35
+ ProjectWaitTimeoutError,
36
+ RateLimitError,
37
+ RequestTimeoutError,
38
+ ResponseDecodeError,
39
+ ResponseParseError,
40
+ ServerError,
41
+ TransportError,
42
+ UnknownLocaleCodesError,
43
+ ValidationError,
44
+ )
45
+ from arbitr.generated.models import (
46
+ AgentFinding,
47
+ ApiKeyMode,
48
+ Assessment,
49
+ AssessmentCredits,
50
+ ChainOfCustodyResponse,
51
+ CreatedVia,
52
+ CreditBalanceResponse,
53
+ CreditWallet,
54
+ DeliverableGeneration,
55
+ DeliverableListResponse,
56
+ FindingListResponse,
57
+ FindingPage,
58
+ FindingSeverity,
59
+ FindingStatus,
60
+ FindingType,
61
+ FlagFinding,
62
+ HumanReviewResponse,
63
+ HumanReviewStatus,
64
+ LanguageListResponse,
65
+ LanguageResponse,
66
+ MeResponse,
67
+ Page,
68
+ ProjectDeliverableResponse,
69
+ ProjectListResponse,
70
+ ProjectResponse,
71
+ Review,
72
+ SourceFileIdentity,
73
+ )
74
+ from arbitr.webhooks import (
75
+ KNOWN_EVENTS,
76
+ WebhookVerificationError,
77
+ compute_signature,
78
+ parse_event,
79
+ verify_signature,
80
+ )
81
+
82
+ __all__ = [
83
+ "ACTION_REQUIRED_STATUSES",
84
+ "ALLOWED_UPLOAD_EXTENSIONS",
85
+ "DEFAULT_BASE_URL",
86
+ "KNOWN_EVENTS",
87
+ "MAX_UPLOAD_FILES",
88
+ "MAX_UPLOAD_TOTAL_BYTES",
89
+ "SUPPORTED_FORMATS",
90
+ "TERMINAL_STATUSES",
91
+ "ActionRequiredError",
92
+ "AgentFinding",
93
+ "AmbiguousLocaleCodesError",
94
+ "ApiKeyMode",
95
+ "ArbitrBaseError",
96
+ "ArbitrClient",
97
+ "ArbitrClientError",
98
+ "ArbitrError",
99
+ "Assessment",
100
+ "AssessmentCredits",
101
+ "AsyncArbitrClient",
102
+ "AuthenticationError",
103
+ "BareLocaleCodeError",
104
+ "ChainOfCustodyResponse",
105
+ "ClientInputError",
106
+ "ConflictError",
107
+ "ConnectionFailedError",
108
+ "CreatedVia",
109
+ "CreditBalanceResponse",
110
+ "CreditWallet",
111
+ "DeliverableGeneration",
112
+ "DeliverableListResponse",
113
+ "DisallowedFileExtensionError",
114
+ "FindingListResponse",
115
+ "FindingPage",
116
+ "FindingSeverity",
117
+ "FindingStatus",
118
+ "FindingType",
119
+ "FindingsKeysetError",
120
+ "FlagFinding",
121
+ "GoneError",
122
+ "HumanReviewResponse",
123
+ "HumanReviewStatus",
124
+ "LanguageListResponse",
125
+ "LanguageResponse",
126
+ "MeResponse",
127
+ "MissingApiKeyError",
128
+ "NotFoundError",
129
+ "OpenAPIDocumentError",
130
+ "Page",
131
+ "PaymentRequiredError",
132
+ "ProjectDeliverableResponse",
133
+ "ProjectListResponse",
134
+ "ProjectResponse",
135
+ "ProjectResumptionResponse",
136
+ "ProjectWaitTimeoutError",
137
+ "RateLimitError",
138
+ "RequestTimeoutError",
139
+ "ResponseDecodeError",
140
+ "ResponseParseError",
141
+ "Review",
142
+ "ServerError",
143
+ "SourceFileIdentity",
144
+ "TransportError",
145
+ "UnknownLocaleCodesError",
146
+ "UtcDatetime",
147
+ "ValidationError",
148
+ "WebhookVerificationError",
149
+ "__version__",
150
+ "compute_signature",
151
+ "new_idempotency_key",
152
+ "parse_event",
153
+ "pinned_spec",
154
+ "verify_signature",
155
+ ]
arbitr/_constants.py ADDED
@@ -0,0 +1,58 @@
1
+ """Constants shared by the sync and async clients (no I/O)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ DEFAULT_BASE_URL = "https://api-arbitr.straker.ai"
8
+
9
+ TERMINAL_STATUSES = frozenset({"cancelled", "completed", "published", "failed"})
10
+ ACTION_REQUIRED_STATUSES = frozenset({"agent_selection", "awaiting_payment"})
11
+ AGENT_SELECTION_CONFIRM_POLLS = 2
12
+
13
+ # Leading-byte-checked upload allowlist from the published OpenAPI snapshot.
14
+ ALLOWED_UPLOAD_EXTENSIONS = frozenset(
15
+ {
16
+ ".csv",
17
+ ".dita",
18
+ ".ditamap",
19
+ ".docx",
20
+ ".htm",
21
+ ".html",
22
+ ".idml",
23
+ ".json",
24
+ ".markdown",
25
+ ".md",
26
+ ".pdf",
27
+ ".po",
28
+ ".pptx",
29
+ ".properties",
30
+ ".srt",
31
+ ".strings",
32
+ ".ts",
33
+ ".txt",
34
+ ".vtt",
35
+ ".xlf",
36
+ ".xliff",
37
+ ".xlsx",
38
+ ".xml",
39
+ }
40
+ )
41
+ SUPPORTED_FORMATS: list[str] = sorted(ALLOWED_UPLOAD_EXTENSIONS)
42
+
43
+ MAX_UPLOAD_FILES = 100
44
+ MAX_UPLOAD_TOTAL_BYTES = 200 * 1024 * 1024
45
+
46
+ DEFAULT_WORKFLOW = ("AI_TRANSLATION",)
47
+ DEFAULT_SOURCE_LANGUAGE = "en-us"
48
+
49
+ # Retries are opt-in via ``max_retries``. 429 carries Retry-After; the 5xx
50
+ # codes back off exponentially. Other statuses are never retried. Only GET
51
+ # is replayed — POST /v1/projects streams file handles that cannot be
52
+ # rewound, so callers retry it themselves with ``idempotency_key``.
53
+ RETRY_STATUS_CODES = frozenset({429, 500, 502, 503, 504})
54
+ RETRY_BACKOFF_BASE_SECONDS = 0.5
55
+ RETRY_BACKOFF_MAX_SECONDS = 60.0
56
+
57
+ OnActionRequired = Literal["raise", "wait"]
58
+ ExtensionCheck = Literal["allowlist", "off"]
arbitr/_coverage.py ADDED
@@ -0,0 +1,127 @@
1
+ """Published-surface coverage rules, shared by the CI script and the test suite.
2
+
3
+ Single source of truth for which OpenAPI operations this package wraps. Both
4
+ ``scripts/check_operation_coverage.py`` and ``tests/test_operation_coverage.py``
5
+ read these tables, so a new operation cannot be half-registered.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ from arbitr._spec import pinned_spec
13
+
14
+ # Deprecated aliases still in the published spec. The canonical replacements
15
+ # are implemented; these ids must not grow a wrapper.
16
+ IGNORED_OPERATION_IDS = frozenset(
17
+ {
18
+ "getAgentSelection",
19
+ "submitAgentSelection",
20
+ "downloadDeliverablesZip",
21
+ "downloadDeliverable",
22
+ "resumeProject",
23
+ "resumeHumanReview",
24
+ }
25
+ )
26
+
27
+ # operationId -> dotted attribute on ArbitrClient / AsyncArbitrClient
28
+ OPERATION_METHODS: dict[str, str] = {
29
+ "getCurrentKey": "me",
30
+ "createProject": "projects.submit",
31
+ "listProjects": "projects.list",
32
+ "getProject": "projects.get",
33
+ "listDeliverables": "projects.deliverables",
34
+ "getDeliverable": "projects.deliverable",
35
+ "listProjectFindings": "projects.findings",
36
+ "getProjectChainOfCustody": "projects.chain_of_custody",
37
+ "createProjectResumption": "projects.resume",
38
+ "createReviewResumption": "projects.resume_human_review",
39
+ "listLanguages": "languages.list",
40
+ "getCreditBalance": "credits.balance",
41
+ }
42
+
43
+ _HTTP_METHODS = frozenset({"get", "put", "post", "delete", "patch", "head", "options", "trace"})
44
+
45
+
46
+ def published_operation_ids() -> set[str]:
47
+ """Every operationId in the pinned snapshot."""
48
+ ids: set[str] = set()
49
+ paths = pinned_spec().get("paths")
50
+ if not isinstance(paths, dict):
51
+ return ids
52
+ for item in paths.values():
53
+ if not isinstance(item, dict):
54
+ continue
55
+ for name, op in item.items():
56
+ if name.lower() in _HTTP_METHODS and isinstance(op, dict) and "operationId" in op:
57
+ ids.add(str(op["operationId"]))
58
+ return ids
59
+
60
+
61
+ def resolve_attr(root: object, dotted: str) -> object:
62
+ """Walk a dotted attribute path, raising AttributeError if any part is missing."""
63
+ current = root
64
+ for part in dotted.split("."):
65
+ current = getattr(current, part)
66
+ return current
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class CoverageReport:
71
+ """Drift between the pinned snapshot and the mapping tables."""
72
+
73
+ unmapped: list[str] = field(default_factory=list)
74
+ """Published operationIds that are neither mapped nor ignored."""
75
+
76
+ unpublished: list[str] = field(default_factory=list)
77
+ """Mapped operationIds absent from the snapshot."""
78
+
79
+ stale_ignores: list[str] = field(default_factory=list)
80
+ """Ignored operationIds absent from the snapshot."""
81
+
82
+ wrapped_ignores: list[str] = field(default_factory=list)
83
+ """Ignored operationIds that wrongly grew a method mapping."""
84
+
85
+ @property
86
+ def ok(self) -> bool:
87
+ """True when the snapshot and the tables agree."""
88
+ return not (self.unmapped or self.unpublished or self.stale_ignores or self.wrapped_ignores)
89
+
90
+ def problems(self) -> list[str]:
91
+ """Human-readable lines describing each drift, empty when ``ok``."""
92
+ lines: list[str] = []
93
+ if self.unmapped:
94
+ lines.append(f"published operationIds with no method mapping: {self.unmapped}")
95
+ if self.unpublished:
96
+ lines.append(f"mapped operationIds not in the snapshot: {self.unpublished}")
97
+ if self.stale_ignores:
98
+ lines.append(f"ignored operationIds not in the snapshot: {self.stale_ignores}")
99
+ if self.wrapped_ignores:
100
+ lines.append(f"ignored operationIds must not be wrapped: {self.wrapped_ignores}")
101
+ return lines
102
+
103
+
104
+ def audit_spec_mapping() -> CoverageReport:
105
+ """Compare the pinned snapshot against the mapping tables."""
106
+ published = published_operation_ids()
107
+ mapped = set(OPERATION_METHODS)
108
+ return CoverageReport(
109
+ unmapped=sorted(published - IGNORED_OPERATION_IDS - mapped),
110
+ unpublished=sorted(mapped - published),
111
+ stale_ignores=sorted(IGNORED_OPERATION_IDS - published),
112
+ wrapped_ignores=sorted(IGNORED_OPERATION_IDS & mapped),
113
+ )
114
+
115
+
116
+ def missing_client_methods(client: object) -> list[str]:
117
+ """Mapped operations whose dotted attribute is missing or not callable."""
118
+ missing: list[str] = []
119
+ for op_id, dotted in sorted(OPERATION_METHODS.items()):
120
+ try:
121
+ attr = resolve_attr(client, dotted)
122
+ except AttributeError:
123
+ missing.append(f"{dotted} (operationId {op_id})")
124
+ continue
125
+ if not callable(attr):
126
+ missing.append(f"{dotted} is not callable (operationId {op_id})")
127
+ return missing
arbitr/_credentials.py ADDED
@@ -0,0 +1,128 @@
1
+ """API-key mode and dotenv construction shared by both clients (no network)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from arbitr._constants import DEFAULT_BASE_URL
10
+ from arbitr._env import pick_env_value, read_env_file
11
+ from arbitr._http import derive_ui_url, parse_max_retries
12
+ from arbitr.errors import ClientInputError, MissingApiKeyError
13
+
14
+ CLI_DEFAULT_MAX_RETRIES = 3
15
+
16
+
17
+ def api_key_mode(api_key: str) -> str:
18
+ """Best-effort key mode from the prefix: ``live``, ``test``, or ``unknown``."""
19
+ if "_live_" in api_key:
20
+ return "live"
21
+ if "_test_" in api_key:
22
+ return "test"
23
+ return "unknown"
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class HostSettings:
28
+ """API and UI hosts resolved from env / dotenv / explicit overrides."""
29
+
30
+ base_url: str
31
+ ui_base_url: str
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ClientSettings:
36
+ """Resolved constructor inputs from env / dotenv / explicit overrides."""
37
+
38
+ api_key: str
39
+ base_url: str
40
+ ui_base_url: str | None
41
+ extra: dict[str, Any]
42
+
43
+
44
+ def _parse_env_max_retries(raw: str) -> int:
45
+ """Parse ``ARBITR_MAX_RETRIES`` from a dotenv / env string."""
46
+ try:
47
+ value = int(raw.strip())
48
+ except ValueError:
49
+ raise ClientInputError("max_retries must be a non-negative integer") from None
50
+ return parse_max_retries(value)
51
+
52
+
53
+ def load_host_settings(
54
+ env_file: str | Path = ".env",
55
+ *,
56
+ base_url: str | None = None,
57
+ ui_base_url: str | None = None,
58
+ ) -> HostSettings:
59
+ """Resolve API and UI hosts without requiring an API key."""
60
+ file_vals = read_env_file(env_file)
61
+ url = (
62
+ base_url
63
+ or pick_env_value(
64
+ "ARBITR_BASE_URL",
65
+ "ARBITR_API_DOMAIN",
66
+ "arbitr_api_domain",
67
+ file_vals=file_vals,
68
+ )
69
+ or DEFAULT_BASE_URL
70
+ )
71
+ ui = (
72
+ ui_base_url
73
+ or pick_env_value("ARBITR_UI_URL", "arbitr_ui_url", file_vals=file_vals)
74
+ or derive_ui_url(url)
75
+ )
76
+ return HostSettings(base_url=url.rstrip("/"), ui_base_url=ui.rstrip("/"))
77
+
78
+
79
+ def resolve_cli_max_retries(
80
+ env_file: str | Path,
81
+ *,
82
+ max_retries: int | None,
83
+ ) -> int:
84
+ """CLI retry count: flag, then env, then ``CLI_DEFAULT_MAX_RETRIES``."""
85
+ if max_retries is not None:
86
+ return parse_max_retries(max_retries)
87
+ file_vals = read_env_file(env_file)
88
+ raw = pick_env_value("ARBITR_MAX_RETRIES", "arbitr_max_retries", file_vals=file_vals)
89
+ if raw is not None:
90
+ return _parse_env_max_retries(raw)
91
+ return CLI_DEFAULT_MAX_RETRIES
92
+
93
+
94
+ def load_client_settings(
95
+ env_file: str | Path = ".env",
96
+ *,
97
+ api_key: str | None = None,
98
+ base_url: str | None = None,
99
+ **kwargs: Any,
100
+ ) -> ClientSettings:
101
+ """Resolve API key, base URL, and UI URL for client construction.
102
+
103
+ Raises:
104
+ MissingApiKeyError: If no key is in the arguments, environment, or dotenv file.
105
+ """
106
+ file_vals = read_env_file(env_file)
107
+ host = load_host_settings(
108
+ env_file,
109
+ base_url=base_url,
110
+ ui_base_url=kwargs.pop("ui_base_url", None),
111
+ )
112
+ key = api_key or pick_env_value("ARBITR_API_KEY", "arbitr_api_key", file_vals=file_vals)
113
+ if "max_retries" not in kwargs:
114
+ raw = pick_env_value("ARBITR_MAX_RETRIES", "arbitr_max_retries", file_vals=file_vals)
115
+ if raw is not None:
116
+ kwargs["max_retries"] = _parse_env_max_retries(raw)
117
+ if not key:
118
+ raise MissingApiKeyError(
119
+ "No API key found. Set ARBITR_API_KEY in the environment or "
120
+ "arbitr_api_key in the env file. Mint a key at "
121
+ "https://arbitr.straker.ai/settings/api-keys"
122
+ )
123
+ return ClientSettings(
124
+ api_key=key,
125
+ base_url=host.base_url,
126
+ ui_base_url=host.ui_base_url,
127
+ extra=kwargs,
128
+ )
arbitr/_datetime.py ADDED
@@ -0,0 +1,23 @@
1
+ """The timestamp type every generated model uses for ``format: date-time``.
2
+
3
+ ``AwareDatetime`` would reject a naive ISO string outright, and a plain
4
+ ``datetime`` would hand the caller a naive value that raises ``TypeError`` the
5
+ moment it is compared against an aware one. Both are worse than assuming UTC:
6
+ every timestamp the API returns is UTC, it just has not always carried the
7
+ offset. ``scripts/generate_models.py`` rewrites ``AwareDatetime`` to this.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from datetime import UTC, datetime
13
+ from typing import Annotated
14
+
15
+ from pydantic import AfterValidator
16
+
17
+
18
+ def assume_utc(value: datetime) -> datetime:
19
+ """Tag a naive timestamp as UTC; convert an aware one to UTC."""
20
+ return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
21
+
22
+
23
+ UtcDatetime = Annotated[datetime, AfterValidator(assume_utc)]
arbitr/_env.py ADDED
@@ -0,0 +1,54 @@
1
+ """Dotenv + env-var loading for client construction (no network)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def _unquote(value: str) -> str:
10
+ """Strip one matched pair of surrounding quotes.
11
+
12
+ Only a matched pair is removed, so a value that merely ends in a quote
13
+ keeps it.
14
+ """
15
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
16
+ return value[1:-1]
17
+ return value
18
+
19
+
20
+ def read_env_file(path: str | Path) -> dict[str, str]:
21
+ """Parse a KEY=VALUE dotenv file; missing files yield an empty dict.
22
+
23
+ Understands ``export KEY=VALUE`` and ignores blank and ``#`` comment
24
+ lines. Values are not expanded and inline comments are not stripped —
25
+ a ``#`` inside a value is part of the value.
26
+ """
27
+ file_path = Path(path)
28
+ values: dict[str, str] = {}
29
+ if not file_path.is_file():
30
+ return values
31
+ for line in file_path.read_text().splitlines():
32
+ stripped = line.strip()
33
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
34
+ continue
35
+ if stripped.startswith("export "):
36
+ stripped = stripped[len("export ") :].lstrip()
37
+ key, value = stripped.split("=", 1)
38
+ values[key.strip()] = _unquote(value.strip())
39
+ return values
40
+
41
+
42
+ def pick_env_value(*names: str, file_vals: dict[str, str]) -> str | None:
43
+ """Return the first non-empty value from os.environ, then the dotenv file.
44
+
45
+ Every alias is checked in the environment before any dotenv value, so a
46
+ file ``ARBITR_API_KEY`` cannot override an env ``arbitr_api_key``.
47
+ """
48
+ for name in names:
49
+ if os.environ.get(name):
50
+ return os.environ[name]
51
+ for name in names:
52
+ if file_vals.get(name):
53
+ return file_vals[name]
54
+ return None