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 +155 -0
- arbitr/_constants.py +58 -0
- arbitr/_coverage.py +127 -0
- arbitr/_credentials.py +128 -0
- arbitr/_datetime.py +23 -0
- arbitr/_env.py +54 -0
- arbitr/_files.py +177 -0
- arbitr/_http.py +163 -0
- arbitr/_locales.py +54 -0
- arbitr/_parse.py +43 -0
- arbitr/_projects.py +133 -0
- arbitr/_spec.py +83 -0
- arbitr/_version.py +7 -0
- arbitr/_wait.py +59 -0
- arbitr/async_client.py +518 -0
- arbitr/cli.py +478 -0
- arbitr/client.py +519 -0
- arbitr/errors.py +354 -0
- arbitr/generated/__init__.py +61 -0
- arbitr/generated/models.py +413 -0
- arbitr/openapi.json +1 -0
- arbitr/py.typed +0 -0
- arbitr/webhooks.py +85 -0
- arbitr_sdk-0.2.0.dist-info/METADATA +166 -0
- arbitr_sdk-0.2.0.dist-info/RECORD +28 -0
- arbitr_sdk-0.2.0.dist-info/WHEEL +4 -0
- arbitr_sdk-0.2.0.dist-info/entry_points.txt +2 -0
- arbitr_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|