storeshot 0.1.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.
- storeshot/__init__.py +41 -0
- storeshot/_client.py +240 -0
- storeshot/_compat.py +19 -0
- storeshot/_core/__init__.py +1 -0
- storeshot/_core/clock.py +26 -0
- storeshot/_core/headers.py +67 -0
- storeshot/_core/operation.py +23 -0
- storeshot/_core/options.py +31 -0
- storeshot/_core/redact.py +38 -0
- storeshot/_core/request.py +316 -0
- storeshot/_core/retry.py +99 -0
- storeshot/_errors.py +153 -0
- storeshot/_generated/__init__.py +1 -0
- storeshot/_generated/operations.py +833 -0
- storeshot/_generated/schemas.py +1981 -0
- storeshot/_helpers/__init__.py +1 -0
- storeshot/_helpers/archive.py +61 -0
- storeshot/_helpers/files.py +49 -0
- storeshot/_helpers/pagination.py +29 -0
- storeshot/_helpers/wait.py +82 -0
- storeshot/_operation_map.py +91 -0
- storeshot/_resources/__init__.py +1 -0
- storeshot/_resources/activity.py +30 -0
- storeshot/_resources/api_keys.py +29 -0
- storeshot/_resources/auth.py +187 -0
- storeshot/_resources/billing.py +102 -0
- storeshot/_resources/catalog.py +43 -0
- storeshot/_resources/generations.py +88 -0
- storeshot/_resources/palettes.py +19 -0
- storeshot/_resources/projects.py +72 -0
- storeshot/_resources/screenshot_sets.py +160 -0
- storeshot/_resources/uploads.py +76 -0
- storeshot/_resources/usage.py +30 -0
- storeshot/_resources/webhooks.py +101 -0
- storeshot/_version.py +3 -0
- storeshot/py.typed +0 -0
- storeshot/types.py +32 -0
- storeshot/webhooks.py +95 -0
- storeshot-0.1.0.dist-info/METADATA +382 -0
- storeshot-0.1.0.dist-info/RECORD +43 -0
- storeshot-0.1.0.dist-info/WHEEL +4 -0
- storeshot-0.1.0.dist-info/licenses/LICENSE +202 -0
- storeshot-0.1.0.dist-info/licenses/NOTICE +4 -0
storeshot/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""The official Python SDK for the Storeshot API (SS-026). See README.md."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._client import DEFAULT_BASE_URL, Storeshot
|
|
6
|
+
from ._errors import (
|
|
7
|
+
SDK_ERROR_CODES,
|
|
8
|
+
StoreshotConnectionError,
|
|
9
|
+
StoreshotError,
|
|
10
|
+
StoreshotTimeoutError,
|
|
11
|
+
StoreshotWebhookError,
|
|
12
|
+
)
|
|
13
|
+
from ._generated.operations import CONTRACT_VERSION, ERROR_CODES, WEBHOOK_EVENT_TYPES
|
|
14
|
+
from ._version import __version__
|
|
15
|
+
from .webhooks import (
|
|
16
|
+
DEFAULT_WEBHOOK_TOLERANCE_SECONDS,
|
|
17
|
+
WEBHOOK_SIGNATURE_HEADER,
|
|
18
|
+
construct_webhook_event,
|
|
19
|
+
verify_webhook_signature,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
SDK_VERSION = __version__
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"CONTRACT_VERSION",
|
|
26
|
+
"DEFAULT_BASE_URL",
|
|
27
|
+
"DEFAULT_WEBHOOK_TOLERANCE_SECONDS",
|
|
28
|
+
"ERROR_CODES",
|
|
29
|
+
"SDK_ERROR_CODES",
|
|
30
|
+
"SDK_VERSION",
|
|
31
|
+
"WEBHOOK_EVENT_TYPES",
|
|
32
|
+
"WEBHOOK_SIGNATURE_HEADER",
|
|
33
|
+
"Storeshot",
|
|
34
|
+
"StoreshotConnectionError",
|
|
35
|
+
"StoreshotError",
|
|
36
|
+
"StoreshotTimeoutError",
|
|
37
|
+
"StoreshotWebhookError",
|
|
38
|
+
"__version__",
|
|
39
|
+
"construct_webhook_event",
|
|
40
|
+
"verify_webhook_signature",
|
|
41
|
+
]
|
storeshot/_client.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""The Storeshot client (SS-026 §4.1, §7.2, §7.3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Callable, Mapping
|
|
10
|
+
from typing import Final, Literal, cast
|
|
11
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from ._compat import Unpack
|
|
16
|
+
from ._core.headers import assert_credential, client_header, invalid, user_headers
|
|
17
|
+
from ._core.options import ClientIdentity, RequestOptions
|
|
18
|
+
from ._core.redact import mask_key
|
|
19
|
+
from ._core.request import Requester, RequesterConfig, ResponseMeta
|
|
20
|
+
from ._errors import StoreshotError
|
|
21
|
+
from ._generated.schemas import Generation, HealthReport, Me, ScreenshotSet, StatusReport
|
|
22
|
+
from ._helpers.wait import (
|
|
23
|
+
DEFAULT_POLL_INTERVAL,
|
|
24
|
+
DEFAULT_WAIT_TIMEOUT,
|
|
25
|
+
wait_for_generation,
|
|
26
|
+
wait_for_screenshot_set,
|
|
27
|
+
)
|
|
28
|
+
from ._resources.activity import Activity
|
|
29
|
+
from ._resources.api_keys import ApiKeys
|
|
30
|
+
from ._resources.auth import Auth
|
|
31
|
+
from ._resources.billing import Billing
|
|
32
|
+
from ._resources.catalog import Devices, Stores
|
|
33
|
+
from ._resources.generations import Generations
|
|
34
|
+
from ._resources.palettes import Palettes
|
|
35
|
+
from ._resources.projects import Projects
|
|
36
|
+
from ._resources.screenshot_sets import ScreenshotSets
|
|
37
|
+
from ._resources.uploads import Uploads
|
|
38
|
+
from ._resources.usage import Credits, Usage
|
|
39
|
+
from ._resources.webhooks import Webhooks
|
|
40
|
+
from ._version import __version__
|
|
41
|
+
|
|
42
|
+
DEFAULT_BASE_URL: Final = "https://api.storeshot.art/v1"
|
|
43
|
+
DEFAULT_TIMEOUT: Final = 60.0
|
|
44
|
+
DEFAULT_MAX_RETRIES: Final = 2
|
|
45
|
+
DEFAULT_MAX_RETRY_AFTER: Final = 60.0
|
|
46
|
+
_LOOPBACK = re.compile(r"localhost|127(?:\.[0-9]{1,3}){3}|::1", re.ASCII)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _FromEnv:
|
|
50
|
+
def __repr__(self) -> str:
|
|
51
|
+
return "FROM_ENV"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
#: The default of `api_key`: read STORESHOT_API_KEY.
|
|
55
|
+
FROM_ENV: Final = _FromEnv()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _env(name: str) -> str | None:
|
|
59
|
+
return os.environ.get(name) or None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def normalise_base_url(value: str) -> str:
|
|
63
|
+
"""https:, or http: on loopback only; no credentials, query or fragment (the CLI/TS rule)."""
|
|
64
|
+
try:
|
|
65
|
+
parts = urlsplit(value)
|
|
66
|
+
_ = parts.port
|
|
67
|
+
except ValueError:
|
|
68
|
+
raise invalid(f'base_url must be an absolute URL (got "{value}").') from None
|
|
69
|
+
if not parts.scheme or not parts.netloc:
|
|
70
|
+
raise invalid(f'base_url must be an absolute URL (got "{value}").')
|
|
71
|
+
host = (parts.hostname or "").lower()
|
|
72
|
+
plain_local = parts.scheme == "http" and _LOOPBACK.fullmatch(host) is not None
|
|
73
|
+
if parts.scheme != "https" and not plain_local:
|
|
74
|
+
raise invalid("base_url must use https: (http: only for localhost / 127.0.0.1 / [::1]).")
|
|
75
|
+
if parts.username is not None or parts.password is not None or parts.query or parts.fragment:
|
|
76
|
+
raise invalid("base_url may not contain credentials, a query or a fragment.")
|
|
77
|
+
return urlunsplit((parts.scheme, parts.netloc.lower(), parts.path, "", "")).rstrip("/")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _number(name: str, value: float, maximum: float = math.inf) -> float:
|
|
81
|
+
if (
|
|
82
|
+
isinstance(value, bool)
|
|
83
|
+
or not isinstance(value, (int, float))
|
|
84
|
+
or not math.isfinite(value)
|
|
85
|
+
or not 0 <= value <= maximum
|
|
86
|
+
):
|
|
87
|
+
raise invalid(f"{name} must be a number between 0 and {maximum:g}.")
|
|
88
|
+
return float(value)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Storeshot:
|
|
92
|
+
"""The Storeshot API client. One instance per API key; it holds no per-request state.
|
|
93
|
+
|
|
94
|
+
`api_key` defaults to STORESHOT_API_KEY; `Storeshot(None)` is a key-less client for
|
|
95
|
+
`client.auth.*` only (device authorization).
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
api_key: str | None | _FromEnv = FROM_ENV,
|
|
101
|
+
*,
|
|
102
|
+
base_url: str | None = None,
|
|
103
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
104
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
105
|
+
max_retry_after: float = DEFAULT_MAX_RETRY_AFTER,
|
|
106
|
+
http_client: httpx.Client | None = None,
|
|
107
|
+
client_identity: ClientIdentity | None = None,
|
|
108
|
+
default_headers: Mapping[str, str] | None = None,
|
|
109
|
+
on_response: Callable[[ResponseMeta], None] | None = None,
|
|
110
|
+
environment: Literal["test", "live"] | None = None,
|
|
111
|
+
dangerously_allow_browser: bool = False,
|
|
112
|
+
) -> None:
|
|
113
|
+
key = _env("STORESHOT_API_KEY") if isinstance(api_key, _FromEnv) else api_key
|
|
114
|
+
if key == "" or (key is None and isinstance(api_key, _FromEnv)):
|
|
115
|
+
raise StoreshotError(
|
|
116
|
+
"MISSING_API_KEY",
|
|
117
|
+
"No API key: set STORESHOT_API_KEY (or pass api_key). Create a free test key "
|
|
118
|
+
"(ss_test_…) at https://console.storeshot.art/. Never paste a key into a chat, a "
|
|
119
|
+
"log or a commit.",
|
|
120
|
+
)
|
|
121
|
+
if key is not None:
|
|
122
|
+
assert_credential(key)
|
|
123
|
+
if environment is not None:
|
|
124
|
+
if environment not in ("test", "live"):
|
|
125
|
+
raise invalid('environment must be "test" or "live".')
|
|
126
|
+
if key is None or not key.startswith(f"ss_{environment}_"):
|
|
127
|
+
held = mask_key(key) if key else "no key"
|
|
128
|
+
raise invalid(
|
|
129
|
+
f'environment="{environment}" needs an ss_{environment}_ key; this client '
|
|
130
|
+
f"has {held}."
|
|
131
|
+
)
|
|
132
|
+
if sys.platform == "emscripten" and not dangerously_allow_browser:
|
|
133
|
+
raise StoreshotError(
|
|
134
|
+
"BROWSER_NOT_ALLOWED",
|
|
135
|
+
"Storeshot API keys are secrets: call the API from your server, not from a web "
|
|
136
|
+
"page (Pyodide). Pass dangerously_allow_browser=True only if you accept that every "
|
|
137
|
+
"visitor can read the key.",
|
|
138
|
+
)
|
|
139
|
+
self.base_url = normalise_base_url(
|
|
140
|
+
base_url or _env("STORESHOT_BASE_URL") or DEFAULT_BASE_URL
|
|
141
|
+
)
|
|
142
|
+
self._api_key = key
|
|
143
|
+
settings = RequesterConfig(
|
|
144
|
+
api_key=key,
|
|
145
|
+
base_url=self.base_url,
|
|
146
|
+
http=http_client if http_client is not None else httpx.Client(),
|
|
147
|
+
timeout=_number("timeout", timeout),
|
|
148
|
+
max_retries=math.floor(_number("max_retries", max_retries, 10)),
|
|
149
|
+
max_retry_after=_number("max_retry_after", max_retry_after),
|
|
150
|
+
client_header=client_header(client_identity),
|
|
151
|
+
user_agent=f"storeshot-sdk-python/{__version__}",
|
|
152
|
+
default_headers=user_headers(default_headers),
|
|
153
|
+
on_response=on_response,
|
|
154
|
+
)
|
|
155
|
+
self._owns_http = http_client is None
|
|
156
|
+
self._http = settings.http
|
|
157
|
+
self._requester = r = Requester(settings)
|
|
158
|
+
self.devices = Devices(r)
|
|
159
|
+
self.stores = Stores(r)
|
|
160
|
+
self.api_keys = ApiKeys(r)
|
|
161
|
+
self.credits = Credits(r)
|
|
162
|
+
self.usage = Usage(r)
|
|
163
|
+
self.uploads = Uploads(r)
|
|
164
|
+
self.generations = Generations(r)
|
|
165
|
+
self.screenshot_sets = ScreenshotSets(r)
|
|
166
|
+
self.projects = Projects(r)
|
|
167
|
+
self.palettes = Palettes(r)
|
|
168
|
+
self.webhooks = Webhooks(r)
|
|
169
|
+
self.activity = Activity(r)
|
|
170
|
+
self.billing = Billing(r)
|
|
171
|
+
#: Device authorization (RFC 8628): get an API key with your human's approval.
|
|
172
|
+
self.auth = Auth(r)
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def environment(self) -> Literal["test", "live"] | None:
|
|
176
|
+
"""The key environment from the key prefix (None for a JWT or a key-less client)."""
|
|
177
|
+
match = re.match(r"ss_(test|live)_", self._api_key or "")
|
|
178
|
+
return cast(Literal["test", "live"], match.group(1)) if match else None
|
|
179
|
+
|
|
180
|
+
def me(self, **options: Unpack[RequestOptions]) -> Me:
|
|
181
|
+
"""`GET /me`: the account and the credential (environment, scopes)."""
|
|
182
|
+
return cast(Me, self._requester.call("getMe", options=options))
|
|
183
|
+
|
|
184
|
+
def health(self, **options: Unpack[RequestOptions]) -> HealthReport:
|
|
185
|
+
"""`GET /health` (public)."""
|
|
186
|
+
return cast(HealthReport, self._requester.call("getHealth", options=options))
|
|
187
|
+
|
|
188
|
+
def status(self, **options: Unpack[RequestOptions]) -> StatusReport:
|
|
189
|
+
"""`GET /status` (public)."""
|
|
190
|
+
return cast(StatusReport, self._requester.call("getStatus", options=options))
|
|
191
|
+
|
|
192
|
+
def wait_for_generation(
|
|
193
|
+
self,
|
|
194
|
+
target: str | Generation,
|
|
195
|
+
/,
|
|
196
|
+
*,
|
|
197
|
+
timeout: float = DEFAULT_WAIT_TIMEOUT,
|
|
198
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
199
|
+
) -> Generation:
|
|
200
|
+
"""Returns the generation once it is succeeded, failed or canceled (long-polls with
|
|
201
|
+
Prefer: wait). Raises StoreshotTimeoutError WAIT_TIMEOUT after `timeout` seconds."""
|
|
202
|
+
return wait_for_generation(
|
|
203
|
+
lambda id, wait: self.generations.get(id, wait=wait),
|
|
204
|
+
target,
|
|
205
|
+
timeout=timeout,
|
|
206
|
+
poll_interval=poll_interval,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def wait_for_screenshot_set(
|
|
210
|
+
self,
|
|
211
|
+
target: str | ScreenshotSet,
|
|
212
|
+
/,
|
|
213
|
+
*,
|
|
214
|
+
timeout: float = DEFAULT_WAIT_TIMEOUT,
|
|
215
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
216
|
+
) -> ScreenshotSet:
|
|
217
|
+
"""Returns the set once it is succeeded, partially_failed, failed or canceled."""
|
|
218
|
+
return wait_for_screenshot_set(
|
|
219
|
+
lambda id: self.screenshot_sets.get(id),
|
|
220
|
+
target,
|
|
221
|
+
timeout=timeout,
|
|
222
|
+
poll_interval=poll_interval,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def close(self) -> None:
|
|
226
|
+
"""Closes the HTTP client the SDK created (never one you passed)."""
|
|
227
|
+
if self._owns_http:
|
|
228
|
+
self._http.close()
|
|
229
|
+
|
|
230
|
+
def __enter__(self) -> Storeshot:
|
|
231
|
+
return self
|
|
232
|
+
|
|
233
|
+
def __exit__(self, *_exc: object) -> None:
|
|
234
|
+
self.close()
|
|
235
|
+
|
|
236
|
+
def __repr__(self) -> str:
|
|
237
|
+
shown = "none" if self._api_key is None else mask_key(self._api_key)
|
|
238
|
+
return f"Storeshot(base_url={self.base_url}, api_key={shown})"
|
|
239
|
+
|
|
240
|
+
__str__ = __repr__
|
storeshot/_compat.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Typing names that moved into `typing` in Python 3.11 (SS-026 §3.2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
if sys.version_info >= (3, 11):
|
|
8
|
+
from typing import NotRequired, Required, TypedDict, Unpack, get_type_hints, is_typeddict
|
|
9
|
+
else: # pragma: no cover - exercised by the Python 3.10 CI leg
|
|
10
|
+
from typing_extensions import (
|
|
11
|
+
NotRequired,
|
|
12
|
+
Required,
|
|
13
|
+
TypedDict,
|
|
14
|
+
Unpack,
|
|
15
|
+
get_type_hints,
|
|
16
|
+
is_typeddict,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = ["NotRequired", "Required", "TypedDict", "Unpack", "get_type_hints", "is_typeddict"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The SDK core (SS-026 §6): pure helpers plus the one synchronous I/O loop."""
|
storeshot/_core/clock.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Time and randomness seams (SS-026 §6.2): tests replace these module attributes.
|
|
2
|
+
|
|
3
|
+
Callers import the module (`from . import clock`) and call `clock.sleep(...)`, so that
|
|
4
|
+
monkeypatching works.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import random as _random
|
|
10
|
+
import time as _time
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def sleep(seconds: float) -> None:
|
|
14
|
+
_time.sleep(seconds)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def monotonic() -> float:
|
|
18
|
+
return _time.monotonic()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def time() -> float:
|
|
22
|
+
return _time.time()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def random() -> float:
|
|
26
|
+
return _random.random()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Header and credential rules shared with the server and the TS SDK (SS-026 §6.1, §7.2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
|
|
8
|
+
from .._errors import StoreshotError
|
|
9
|
+
from .._version import __version__
|
|
10
|
+
from .options import ClientIdentity
|
|
11
|
+
|
|
12
|
+
#: Same rule as the server (v1/attribution.ts); an invalid header would be ignored there.
|
|
13
|
+
CLIENT_HEADER_PATTERN = re.compile(r"(?:api|web|mcp|cli)/[A-Za-z0-9._@+-]{1,100}", re.ASCII)
|
|
14
|
+
#: Same rule as the server's request-id middleware.
|
|
15
|
+
REQUEST_ID_PATTERN = re.compile(r"[A-Za-z0-9_\-=]{1,128}", re.ASCII)
|
|
16
|
+
IDEMPOTENCY_KEY_PATTERN = re.compile(r"[\x20-\x7e]{1,255}")
|
|
17
|
+
CREDENTIAL_PATTERN = re.compile(r"[\x21-\x7e]+")
|
|
18
|
+
HEADER_VALUE_PATTERN = re.compile(r"[\t\x20-\x7e]*")
|
|
19
|
+
#: Headers the SDK owns; default and per-call headers may not set them.
|
|
20
|
+
RESERVED_HEADERS = ("authorization", "x-storeshot-client", "idempotency-key")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def invalid(message: str) -> StoreshotError:
|
|
24
|
+
return StoreshotError("INVALID_CONFIGURATION", message)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def default_client_name() -> str:
|
|
28
|
+
return f"storeshot-sdk-python@{__version__}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def client_header(identity: ClientIdentity | None) -> str:
|
|
32
|
+
"""The X-Storeshot-Client value, validated so the server never silently drops it."""
|
|
33
|
+
identity = identity or {}
|
|
34
|
+
value = f"{identity.get('surface', 'api')}/{identity.get('name', default_client_name())}"
|
|
35
|
+
if not CLIENT_HEADER_PATTERN.fullmatch(value):
|
|
36
|
+
raise invalid(
|
|
37
|
+
f'Invalid client identity "{value}": surface api|web|mcp|cli, name of 1–100 '
|
|
38
|
+
"characters A-Z a-z 0-9 . _ @ + -."
|
|
39
|
+
)
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def assert_credential(value: str) -> None:
|
|
44
|
+
"""Credentials: printable ASCII without spaces (no header injection, no stray newline)."""
|
|
45
|
+
if not CREDENTIAL_PATTERN.fullmatch(value):
|
|
46
|
+
raise invalid("The API key contains whitespace or non-printable characters.")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def assert_header_value(name: str, value: str) -> None:
|
|
50
|
+
if not HEADER_VALUE_PATTERN.fullmatch(value):
|
|
51
|
+
raise invalid(f"Header {name} has a line break or a non-ASCII character.")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def assert_request_id(value: str) -> None:
|
|
55
|
+
if not REQUEST_ID_PATTERN.fullmatch(value):
|
|
56
|
+
raise invalid("request_id must be 1–128 characters of A-Z a-z 0-9 _ - =.")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def user_headers(headers: Mapping[str, str] | None) -> dict[str, str]:
|
|
60
|
+
"""Validates user-supplied headers and refuses the ones the SDK owns."""
|
|
61
|
+
out: dict[str, str] = {}
|
|
62
|
+
for name, value in (headers or {}).items():
|
|
63
|
+
if name.lower() in RESERVED_HEADERS:
|
|
64
|
+
raise invalid(f"Header {name} is set by the SDK; use the matching option instead.")
|
|
65
|
+
assert_header_value(name, value)
|
|
66
|
+
out[name] = value
|
|
67
|
+
return out
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""One API operation's method, path and request traits (SS-026 §5; generated in operations.py)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Operation:
|
|
10
|
+
method: str
|
|
11
|
+
path: str
|
|
12
|
+
tag: str | None
|
|
13
|
+
idempotency_key: bool
|
|
14
|
+
wait: bool
|
|
15
|
+
path_params: tuple[str, ...]
|
|
16
|
+
query_params: tuple[str, ...]
|
|
17
|
+
body: str
|
|
18
|
+
success_statuses: tuple[int, ...]
|
|
19
|
+
binary: bool
|
|
20
|
+
#: Component name of the JSON request body, if any.
|
|
21
|
+
request_schema: str | None
|
|
22
|
+
#: Component names a 2xx JSON answer may have (anyOf flattened), sorted.
|
|
23
|
+
response_schemas: tuple[str, ...]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Per-call options and the client identity (SS-026 §4.1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from typing import TYPE_CHECKING, Literal
|
|
7
|
+
|
|
8
|
+
from .._compat import TypedDict
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .request import ResponseMeta
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RequestOptions(TypedDict, total=False):
|
|
15
|
+
"""Keyword options every method accepts."""
|
|
16
|
+
|
|
17
|
+
timeout: float
|
|
18
|
+
"""Seconds per network operation of each attempt (overrides the client's)."""
|
|
19
|
+
max_retries: int
|
|
20
|
+
request_id: str
|
|
21
|
+
"""Sent as X-Request-Id (1–128 of A-Z a-z 0-9 _ - =) to correlate with your logs."""
|
|
22
|
+
headers: Mapping[str, str]
|
|
23
|
+
on_response: Callable[[ResponseMeta], None]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ClientIdentity(TypedDict, total=False):
|
|
27
|
+
"""X-Storeshot-Client: `api` by default; `cli`/`mcp` for such tools (usage labels)."""
|
|
28
|
+
|
|
29
|
+
surface: Literal["api", "web", "mcp", "cli"]
|
|
30
|
+
name: str
|
|
31
|
+
"""1–100 of A-Z a-z 0-9 . _ @ + -, for example `my-tool@1.2.0`."""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Secrets never leave the SDK in a message (SS-026 §7.1): same rules as the TS SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
#: Storeshot API keys anywhere in a text (the secret part is at least 8 characters).
|
|
10
|
+
KEY_PATTERN = re.compile(r"\bss_(live|test)_[A-Za-z0-9_-]{8,}", re.ASCII)
|
|
11
|
+
MAX_DEPTH = 8
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def scrub(text: str, secrets: Sequence[str] = ()) -> str:
|
|
15
|
+
"""Removes the given secrets and anything shaped like an API key from a text."""
|
|
16
|
+
for secret in secrets:
|
|
17
|
+
if secret:
|
|
18
|
+
text = text.replace(secret, "[redacted]")
|
|
19
|
+
return KEY_PATTERN.sub(lambda m: f"ss_{m.group(1)}_[redacted]", text)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def scrub_value(value: Any, secrets: Sequence[str] = (), depth: int = 0) -> Any:
|
|
23
|
+
"""`scrub` applied to every string of a JSON-like value (error details)."""
|
|
24
|
+
if isinstance(value, str):
|
|
25
|
+
return scrub(value, secrets)
|
|
26
|
+
if depth >= MAX_DEPTH:
|
|
27
|
+
return value
|
|
28
|
+
if isinstance(value, list):
|
|
29
|
+
return [scrub_value(item, secrets, depth + 1) for item in value]
|
|
30
|
+
if isinstance(value, dict):
|
|
31
|
+
return {key: scrub_value(item, secrets, depth + 1) for key, item in value.items()}
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def mask_key(key: str) -> str:
|
|
36
|
+
"""What may be shown of a credential: the key environment prefix only."""
|
|
37
|
+
match = re.match(r"ss_(live|test)_", key)
|
|
38
|
+
return f"{match.group(0)}…" if match else "…"
|