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.
Files changed (43) hide show
  1. storeshot/__init__.py +41 -0
  2. storeshot/_client.py +240 -0
  3. storeshot/_compat.py +19 -0
  4. storeshot/_core/__init__.py +1 -0
  5. storeshot/_core/clock.py +26 -0
  6. storeshot/_core/headers.py +67 -0
  7. storeshot/_core/operation.py +23 -0
  8. storeshot/_core/options.py +31 -0
  9. storeshot/_core/redact.py +38 -0
  10. storeshot/_core/request.py +316 -0
  11. storeshot/_core/retry.py +99 -0
  12. storeshot/_errors.py +153 -0
  13. storeshot/_generated/__init__.py +1 -0
  14. storeshot/_generated/operations.py +833 -0
  15. storeshot/_generated/schemas.py +1981 -0
  16. storeshot/_helpers/__init__.py +1 -0
  17. storeshot/_helpers/archive.py +61 -0
  18. storeshot/_helpers/files.py +49 -0
  19. storeshot/_helpers/pagination.py +29 -0
  20. storeshot/_helpers/wait.py +82 -0
  21. storeshot/_operation_map.py +91 -0
  22. storeshot/_resources/__init__.py +1 -0
  23. storeshot/_resources/activity.py +30 -0
  24. storeshot/_resources/api_keys.py +29 -0
  25. storeshot/_resources/auth.py +187 -0
  26. storeshot/_resources/billing.py +102 -0
  27. storeshot/_resources/catalog.py +43 -0
  28. storeshot/_resources/generations.py +88 -0
  29. storeshot/_resources/palettes.py +19 -0
  30. storeshot/_resources/projects.py +72 -0
  31. storeshot/_resources/screenshot_sets.py +160 -0
  32. storeshot/_resources/uploads.py +76 -0
  33. storeshot/_resources/usage.py +30 -0
  34. storeshot/_resources/webhooks.py +101 -0
  35. storeshot/_version.py +3 -0
  36. storeshot/py.typed +0 -0
  37. storeshot/types.py +32 -0
  38. storeshot/webhooks.py +95 -0
  39. storeshot-0.1.0.dist-info/METADATA +382 -0
  40. storeshot-0.1.0.dist-info/RECORD +43 -0
  41. storeshot-0.1.0.dist-info/WHEEL +4 -0
  42. storeshot-0.1.0.dist-info/licenses/LICENSE +202 -0
  43. 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."""
@@ -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 "…"