pixelvault 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.
pixelvault/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from ._core import __version__
2
+ from .client import AsyncPixelVault, PixelVault
3
+ from .errors import PixelVaultError
4
+
5
+ __all__ = ["PixelVault", "AsyncPixelVault", "PixelVaultError", "__version__"]
pixelvault/_core.py ADDED
@@ -0,0 +1,144 @@
1
+ """Transport-independent pieces shared by the sync and async clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import mimetypes
6
+ import os
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any, BinaryIO, Dict, List, Optional, Tuple, Union
10
+
11
+ import httpx
12
+
13
+ from .errors import PixelVaultError
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ DEFAULT_BASE_URL = "https://api.pixelvault.dev"
18
+ MAX_BACKOFF = 30.0
19
+
20
+ #: Bytes, a path, or a binary file object.
21
+ FileInput = Union[bytes, bytearray, str, "os.PathLike[str]", BinaryIO]
22
+
23
+
24
+ @dataclass
25
+ class Request:
26
+ method: str
27
+ path: str
28
+ params: Dict[str, Any] = field(default_factory=dict)
29
+ json: Optional[Dict[str, Any]] = None
30
+ files: Optional[Dict[str, Tuple[str, Any, str]]] = None
31
+ data: Optional[Dict[str, str]] = None
32
+ no_auth: bool = False
33
+
34
+
35
+ def drop_none(d: Dict[str, Any]) -> Dict[str, Any]:
36
+ return {k: v for k, v in d.items() if v is not None}
37
+
38
+
39
+ def resolve_api_key(api_key: Optional[str]) -> Optional[str]:
40
+ return api_key or os.environ.get("PIXELVAULT_API_KEY") or None
41
+
42
+
43
+ def headers_for(api_key: Optional[str], req: Request) -> Dict[str, str]:
44
+ headers = {
45
+ "Accept": "application/json",
46
+ "X-PixelVault-Client": f"sdk-python/{__version__}",
47
+ }
48
+ if api_key and not req.no_auth:
49
+ headers["Authorization"] = f"Bearer {api_key}"
50
+ return headers
51
+
52
+
53
+ def upload_request(
54
+ *,
55
+ file: Optional[FileInput],
56
+ url: Optional[str],
57
+ filename: Optional[str],
58
+ content_type: Optional[str],
59
+ folder: Optional[str],
60
+ visibility: Optional[str],
61
+ sign_expires_in: Optional[int],
62
+ expires_in: Optional[int],
63
+ ) -> Request:
64
+ if (file is None) == (url is None):
65
+ raise ValueError("pass exactly one of `file` or `url`")
66
+
67
+ fields = drop_none(
68
+ {
69
+ "folder": folder,
70
+ "visibility": visibility,
71
+ "sign_expires_in": sign_expires_in,
72
+ "expires_in": expires_in,
73
+ }
74
+ )
75
+
76
+ if url is not None:
77
+ return Request("POST", "/v1/images", json={"url": url, **fields})
78
+
79
+ assert file is not None
80
+ content: bytes
81
+ if isinstance(file, (str, os.PathLike)):
82
+ path = Path(file)
83
+ name = filename or path.name
84
+ content = path.read_bytes()
85
+ elif isinstance(file, (bytes, bytearray)):
86
+ name = filename or "upload"
87
+ content = bytes(file)
88
+ else:
89
+ name = filename or os.path.basename(getattr(file, "name", "") or "") or "upload"
90
+ content = file.read()
91
+ mime = content_type or mimetypes.guess_type(name)[0] or "application/octet-stream"
92
+ return Request(
93
+ "POST",
94
+ "/v1/images",
95
+ files={"file": (name, content, mime)},
96
+ data={k: str(v) for k, v in fields.items()},
97
+ )
98
+
99
+
100
+ def batch_request(collection: Dict[str, Any], images: List[Dict[str, Any]]) -> Request:
101
+ return Request(
102
+ "POST", "/v1/images/batch", json={"collection": collection, "images": images}
103
+ )
104
+
105
+
106
+ def retryable(method: str, status: int) -> bool:
107
+ """429 is always safe to retry (rejected before running). 5xx only for
108
+ idempotent methods, so a lost upload response can never duplicate an image."""
109
+ if status == 429:
110
+ return True
111
+ return method in ("GET", "DELETE") and status >= 500
112
+
113
+
114
+ def retry_after(res: httpx.Response) -> Optional[float]:
115
+ raw = res.headers.get("Retry-After")
116
+ if raw is None:
117
+ return None
118
+ try:
119
+ value = float(raw)
120
+ except ValueError:
121
+ return None
122
+ return value if value >= 0 else None
123
+
124
+
125
+ def backoff(attempt: int, res: Optional[httpx.Response]) -> Optional[float]:
126
+ """Seconds to sleep, or None when the wait is too long to be worth it."""
127
+ after = retry_after(res) if res is not None else None
128
+ wait = after if after is not None else 0.5 * (2**attempt)
129
+ return wait if wait <= MAX_BACKOFF else None
130
+
131
+
132
+ def error_from(res: httpx.Response) -> PixelVaultError:
133
+ code, message = "unknown_error", f"Request failed with status {res.status_code}"
134
+ try:
135
+ err = res.json().get("error") or {}
136
+ code = err.get("code") or code
137
+ message = err.get("message") or message
138
+ except Exception:
139
+ pass
140
+ return PixelVaultError(message, res.status_code, code, retry_after(res))
141
+
142
+
143
+ def network_error(exc: Exception) -> PixelVaultError:
144
+ return PixelVaultError(f"Network error: {exc}", 0, "network_error")
@@ -0,0 +1,99 @@
1
+ """Request builders and response parsers. No I/O lives here."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Optional, cast
6
+ from urllib.parse import quote
7
+
8
+ from ._core import Request, batch_request, drop_none, upload_request
9
+ from .types import ImagePage, ListMeta
10
+
11
+
12
+ def seg(value: str) -> str:
13
+ return quote(value, safe="")
14
+
15
+
16
+ def upload(**kwargs: Any) -> Request:
17
+ return upload_request(**kwargs)
18
+
19
+
20
+ def batch(collection: Dict[str, Any], images: List[Dict[str, Any]]) -> Request:
21
+ return batch_request(collection, images)
22
+
23
+
24
+ def list_images(page: Optional[int], per_page: Optional[int]) -> Request:
25
+ return Request("GET", "/v1/images", params=drop_none({"page": page, "per_page": per_page}))
26
+
27
+
28
+ def get_image(image_id: str) -> Request:
29
+ return Request("GET", f"/v1/images/{seg(image_id)}")
30
+
31
+
32
+ def delete_image(image_id: str) -> Request:
33
+ return Request("DELETE", f"/v1/images/{seg(image_id)}")
34
+
35
+
36
+ def sign_url(image_id: str, expires_in: Optional[int]) -> Request:
37
+ body = drop_none({"expires_in": expires_in})
38
+ return Request("POST", f"/v1/images/{seg(image_id)}/sign-url", json=body)
39
+
40
+
41
+ def get_usage() -> Request:
42
+ return Request("GET", "/v1/usage")
43
+
44
+
45
+ def list_keys() -> Request:
46
+ return Request("GET", "/v1/keys")
47
+
48
+
49
+ def create_key(label: Optional[str], environment: Optional[str]) -> Request:
50
+ return Request("POST", "/v1/keys", json=drop_none({"label": label, "environment": environment}))
51
+
52
+
53
+ def revoke_key(key_id: str) -> Request:
54
+ return Request("DELETE", f"/v1/keys/{seg(key_id)}")
55
+
56
+
57
+ def register(email: str, password: Optional[str]) -> Request:
58
+ return Request(
59
+ "POST",
60
+ "/v1/auth/register",
61
+ json=drop_none({"email": email, "password": password}),
62
+ no_auth=True,
63
+ )
64
+
65
+
66
+ def device_start(email: str) -> Request:
67
+ return Request("POST", "/v1/auth/device/start", json={"email": email}, no_auth=True)
68
+
69
+
70
+ def device_complete(
71
+ email: str, code: str, label: Optional[str], replace_existing: Optional[bool]
72
+ ) -> Request:
73
+ return Request(
74
+ "POST",
75
+ "/v1/auth/device/complete",
76
+ json=drop_none(
77
+ {"email": email, "code": code, "label": label, "replace_existing": replace_existing}
78
+ ),
79
+ no_auth=True,
80
+ )
81
+
82
+
83
+ def data(body: Dict[str, Any]) -> Any:
84
+ return body["data"]
85
+
86
+
87
+ def page(body: Dict[str, Any], page_no: Optional[int], per_page: Optional[int]) -> ImagePage:
88
+ meta = cast(
89
+ ListMeta,
90
+ body.get("meta") or {"total": 0, "page": page_no or 1, "per_page": per_page or 0},
91
+ )
92
+ return {"data": body.get("data") or [], "meta": meta}
93
+
94
+
95
+ def has_more(result: ImagePage, page_no: int) -> bool:
96
+ meta = result["meta"]
97
+ per_page = meta.get("per_page") or len(result["data"])
98
+ return bool(result["data"]) and page_no * per_page < meta.get("total", 0)
99
+
pixelvault/client.py ADDED
@@ -0,0 +1,374 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from typing import Any, AsyncIterator, Dict, Iterator, List, Optional
6
+
7
+ import httpx
8
+
9
+ from . import _resources as r
10
+ from ._core import (
11
+ DEFAULT_BASE_URL,
12
+ FileInput,
13
+ Request,
14
+ backoff,
15
+ error_from,
16
+ headers_for,
17
+ network_error,
18
+ resolve_api_key,
19
+ retryable,
20
+ )
21
+ from .errors import PixelVaultError
22
+ from .types import (
23
+ ApiKey,
24
+ CreatedKey,
25
+ DeviceLoginResult,
26
+ Image,
27
+ ImagePage,
28
+ JsonDict,
29
+ SignedUrl,
30
+ Usage,
31
+ )
32
+
33
+
34
+ class _Config:
35
+ def __init__(
36
+ self,
37
+ api_key: Optional[str],
38
+ base_url: str,
39
+ max_retries: int,
40
+ timeout: float,
41
+ ) -> None:
42
+ self.api_key = resolve_api_key(api_key)
43
+ self.base_url = base_url.rstrip("/")
44
+ self.max_retries = max_retries
45
+ self.timeout = timeout
46
+
47
+ def build(self, http: Any, req: Request) -> httpx.Request:
48
+ built: httpx.Request = http.build_request(
49
+ req.method,
50
+ self.base_url + req.path,
51
+ params=req.params or None,
52
+ json=req.json,
53
+ files=req.files,
54
+ data=req.data,
55
+ headers=headers_for(self.api_key, req),
56
+ timeout=self.timeout,
57
+ )
58
+ return built
59
+
60
+
61
+ def _body(res: httpx.Response) -> Dict[str, Any]:
62
+ body: Dict[str, Any] = res.json()
63
+ return body
64
+
65
+
66
+ class PixelVault:
67
+ """Synchronous client. Use as a context manager or call :meth:`close`."""
68
+
69
+ def __init__(
70
+ self,
71
+ api_key: Optional[str] = None,
72
+ *,
73
+ base_url: str = DEFAULT_BASE_URL,
74
+ max_retries: int = 2,
75
+ timeout: float = 60.0,
76
+ http_client: Optional[httpx.Client] = None,
77
+ ) -> None:
78
+ self._cfg = _Config(api_key, base_url, max_retries, timeout)
79
+ self._http = http_client or httpx.Client()
80
+ self.images = _Images(self)
81
+ self.usage = _Usage(self)
82
+ self.keys = _Keys(self)
83
+
84
+ def __enter__(self) -> "PixelVault":
85
+ return self
86
+
87
+ def __exit__(self, *exc: Any) -> None:
88
+ self.close()
89
+
90
+ def close(self) -> None:
91
+ self._http.close()
92
+
93
+ def _send(self, req: Request) -> Dict[str, Any]:
94
+ attempt = 0
95
+ while True:
96
+ try:
97
+ res = self._http.send(self._cfg.build(self._http, req))
98
+ except httpx.HTTPError as exc:
99
+ if attempt < self._cfg.max_retries and req.method in ("GET", "DELETE"):
100
+ time.sleep(backoff(attempt, None) or 0)
101
+ attempt += 1
102
+ continue
103
+ raise network_error(exc) from exc
104
+ if res.is_success:
105
+ return _body(res)
106
+ if attempt < self._cfg.max_retries and retryable(req.method, res.status_code):
107
+ wait = backoff(attempt, res)
108
+ if wait is not None:
109
+ time.sleep(wait)
110
+ attempt += 1
111
+ continue
112
+ raise error_from(res)
113
+
114
+ def register(self, email: str, password: Optional[str] = None) -> JsonDict:
115
+ """Create an account. Returns a first live API key once; store it."""
116
+ return r.data(self._send(r.register(email, password))) # type: ignore[no-any-return]
117
+
118
+ def device_login_start(self, email: str) -> None:
119
+ """Step 1 of device login: email a one-time code."""
120
+ self._send(r.device_start(email))
121
+
122
+ def device_login_complete(
123
+ self,
124
+ email: str,
125
+ code: str,
126
+ *,
127
+ label: Optional[str] = None,
128
+ replace_existing: Optional[bool] = None,
129
+ ) -> DeviceLoginResult:
130
+ """Step 2: trade the emailed code for an API key (shown once)."""
131
+ return r.data( # type: ignore[no-any-return]
132
+ self._send(r.device_complete(email, code, label, replace_existing))
133
+ )
134
+
135
+
136
+ class _Images:
137
+ def __init__(self, c: PixelVault) -> None:
138
+ self._c = c
139
+
140
+ def upload(
141
+ self,
142
+ file: Optional[FileInput] = None,
143
+ *,
144
+ url: Optional[str] = None,
145
+ filename: Optional[str] = None,
146
+ content_type: Optional[str] = None,
147
+ folder: Optional[str] = None,
148
+ visibility: Optional[str] = None,
149
+ sign_expires_in: Optional[int] = None,
150
+ expires_in: Optional[int] = None,
151
+ ) -> Image:
152
+ """Upload bytes, a path or a file object, or import a remote ``url``.
153
+
154
+ Without an API key the upload is anonymous and expires after 30 days.
155
+ """
156
+ req = r.upload(
157
+ file=file,
158
+ url=url,
159
+ filename=filename,
160
+ content_type=content_type,
161
+ folder=folder,
162
+ visibility=visibility,
163
+ sign_expires_in=sign_expires_in,
164
+ expires_in=expires_in,
165
+ )
166
+ return r.data(self._c._send(req)) # type: ignore[no-any-return]
167
+
168
+ def upload_batch(
169
+ self, collection: Dict[str, Any], images: List[Dict[str, Any]]
170
+ ) -> JsonDict:
171
+ """Up to 50 images in one collection. Fields use the API's snake_case names."""
172
+ return r.data(self._c._send(r.batch(collection, images))) # type: ignore[no-any-return]
173
+
174
+ def list(self, page: Optional[int] = None, per_page: Optional[int] = None) -> ImagePage:
175
+ return r.page(self._c._send(r.list_images(page, per_page)), page, per_page)
176
+
177
+ def list_all(self, per_page: Optional[int] = None) -> Iterator[Image]:
178
+ """Iterate every image across pages."""
179
+ page_no = 1
180
+ while True:
181
+ result = self.list(page_no, per_page)
182
+ yield from result["data"]
183
+ if not r.has_more(result, page_no):
184
+ return
185
+ page_no += 1
186
+
187
+ def get(self, image_id: str) -> Image:
188
+ return r.data(self._c._send(r.get_image(image_id))) # type: ignore[no-any-return]
189
+
190
+ def delete(self, image_id: str) -> None:
191
+ self._c._send(r.delete_image(image_id))
192
+
193
+ def sign_url(self, image_id: str, expires_in: Optional[int] = None) -> SignedUrl:
194
+ """Signed URL for a private image (60-2592000 seconds, default 3600)."""
195
+ return r.data(self._c._send(r.sign_url(image_id, expires_in))) # type: ignore[no-any-return]
196
+
197
+
198
+ class _Usage:
199
+ def __init__(self, c: PixelVault) -> None:
200
+ self._c = c
201
+
202
+ def get(self) -> Usage:
203
+ """Plan, usage, limits and what is left this month."""
204
+ return r.data(self._c._send(r.get_usage())) # type: ignore[no-any-return]
205
+
206
+
207
+ class _Keys:
208
+ def __init__(self, c: PixelVault) -> None:
209
+ self._c = c
210
+
211
+ def list(self) -> List[ApiKey]:
212
+ return r.data(self._c._send(r.list_keys())) # type: ignore[no-any-return]
213
+
214
+ def create(
215
+ self, label: Optional[str] = None, environment: Optional[str] = None
216
+ ) -> CreatedKey:
217
+ """Mint a secret key. The raw key is in the response once and never again."""
218
+ return r.data(self._c._send(r.create_key(label, environment))) # type: ignore[no-any-return]
219
+
220
+ def revoke(self, key_id: str) -> None:
221
+ """Revoke a key. The key making the request cannot revoke itself."""
222
+ self._c._send(r.revoke_key(key_id))
223
+
224
+
225
+ class AsyncPixelVault:
226
+ """Asyncio client with the same surface as :class:`PixelVault`."""
227
+
228
+ def __init__(
229
+ self,
230
+ api_key: Optional[str] = None,
231
+ *,
232
+ base_url: str = DEFAULT_BASE_URL,
233
+ max_retries: int = 2,
234
+ timeout: float = 60.0,
235
+ http_client: Optional[httpx.AsyncClient] = None,
236
+ ) -> None:
237
+ self._cfg = _Config(api_key, base_url, max_retries, timeout)
238
+ self._http = http_client or httpx.AsyncClient()
239
+ self.images = _AsyncImages(self)
240
+ self.usage = _AsyncUsage(self)
241
+ self.keys = _AsyncKeys(self)
242
+
243
+ async def __aenter__(self) -> "AsyncPixelVault":
244
+ return self
245
+
246
+ async def __aexit__(self, *exc: Any) -> None:
247
+ await self.close()
248
+
249
+ async def close(self) -> None:
250
+ await self._http.aclose()
251
+
252
+ async def _send(self, req: Request) -> Dict[str, Any]:
253
+ attempt = 0
254
+ while True:
255
+ try:
256
+ res = await self._http.send(self._cfg.build(self._http, req))
257
+ except httpx.HTTPError as exc:
258
+ if attempt < self._cfg.max_retries and req.method in ("GET", "DELETE"):
259
+ await asyncio.sleep(backoff(attempt, None) or 0)
260
+ attempt += 1
261
+ continue
262
+ raise network_error(exc) from exc
263
+ if res.is_success:
264
+ return _body(res)
265
+ if attempt < self._cfg.max_retries and retryable(req.method, res.status_code):
266
+ wait = backoff(attempt, res)
267
+ if wait is not None:
268
+ await asyncio.sleep(wait)
269
+ attempt += 1
270
+ continue
271
+ raise error_from(res)
272
+
273
+ async def register(self, email: str, password: Optional[str] = None) -> JsonDict:
274
+ return r.data(await self._send(r.register(email, password))) # type: ignore[no-any-return]
275
+
276
+ async def device_login_start(self, email: str) -> None:
277
+ await self._send(r.device_start(email))
278
+
279
+ async def device_login_complete(
280
+ self,
281
+ email: str,
282
+ code: str,
283
+ *,
284
+ label: Optional[str] = None,
285
+ replace_existing: Optional[bool] = None,
286
+ ) -> DeviceLoginResult:
287
+ return r.data( # type: ignore[no-any-return]
288
+ await self._send(r.device_complete(email, code, label, replace_existing))
289
+ )
290
+
291
+
292
+ class _AsyncImages:
293
+ def __init__(self, c: AsyncPixelVault) -> None:
294
+ self._c = c
295
+
296
+ async def upload(
297
+ self,
298
+ file: Optional[FileInput] = None,
299
+ *,
300
+ url: Optional[str] = None,
301
+ filename: Optional[str] = None,
302
+ content_type: Optional[str] = None,
303
+ folder: Optional[str] = None,
304
+ visibility: Optional[str] = None,
305
+ sign_expires_in: Optional[int] = None,
306
+ expires_in: Optional[int] = None,
307
+ ) -> Image:
308
+ req = r.upload(
309
+ file=file,
310
+ url=url,
311
+ filename=filename,
312
+ content_type=content_type,
313
+ folder=folder,
314
+ visibility=visibility,
315
+ sign_expires_in=sign_expires_in,
316
+ expires_in=expires_in,
317
+ )
318
+ return r.data(await self._c._send(req)) # type: ignore[no-any-return]
319
+
320
+ async def upload_batch(
321
+ self, collection: Dict[str, Any], images: List[Dict[str, Any]]
322
+ ) -> JsonDict:
323
+ return r.data(await self._c._send(r.batch(collection, images))) # type: ignore[no-any-return]
324
+
325
+ async def list(
326
+ self, page: Optional[int] = None, per_page: Optional[int] = None
327
+ ) -> ImagePage:
328
+ return r.page(await self._c._send(r.list_images(page, per_page)), page, per_page)
329
+
330
+ async def list_all(self, per_page: Optional[int] = None) -> AsyncIterator[Image]:
331
+ page_no = 1
332
+ while True:
333
+ result = await self.list(page_no, per_page)
334
+ for image in result["data"]:
335
+ yield image
336
+ if not r.has_more(result, page_no):
337
+ return
338
+ page_no += 1
339
+
340
+ async def get(self, image_id: str) -> Image:
341
+ return r.data(await self._c._send(r.get_image(image_id))) # type: ignore[no-any-return]
342
+
343
+ async def delete(self, image_id: str) -> None:
344
+ await self._c._send(r.delete_image(image_id))
345
+
346
+ async def sign_url(self, image_id: str, expires_in: Optional[int] = None) -> SignedUrl:
347
+ return r.data(await self._c._send(r.sign_url(image_id, expires_in))) # type: ignore[no-any-return]
348
+
349
+
350
+ class _AsyncUsage:
351
+ def __init__(self, c: AsyncPixelVault) -> None:
352
+ self._c = c
353
+
354
+ async def get(self) -> Usage:
355
+ return r.data(await self._c._send(r.get_usage())) # type: ignore[no-any-return]
356
+
357
+
358
+ class _AsyncKeys:
359
+ def __init__(self, c: AsyncPixelVault) -> None:
360
+ self._c = c
361
+
362
+ async def list(self) -> List[ApiKey]:
363
+ return r.data(await self._c._send(r.list_keys())) # type: ignore[no-any-return]
364
+
365
+ async def create(
366
+ self, label: Optional[str] = None, environment: Optional[str] = None
367
+ ) -> CreatedKey:
368
+ return r.data(await self._c._send(r.create_key(label, environment))) # type: ignore[no-any-return]
369
+
370
+ async def revoke(self, key_id: str) -> None:
371
+ await self._c._send(r.revoke_key(key_id))
372
+
373
+
374
+ __all__ = ["PixelVault", "AsyncPixelVault", "PixelVaultError"]
pixelvault/errors.py ADDED
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+
6
+ class PixelVaultError(Exception):
7
+ """Raised for every non-2xx API response and for network failures."""
8
+
9
+ def __init__(
10
+ self,
11
+ message: str,
12
+ status: int,
13
+ code: str,
14
+ retry_after: Optional[float] = None,
15
+ ) -> None:
16
+ super().__init__(message)
17
+ self.message = message
18
+ #: HTTP status, or 0 when the request never got a response.
19
+ self.status = status
20
+ #: Machine-readable code from the API, e.g. ``upload_limit_reached``.
21
+ self.code = code
22
+ #: Seconds to wait before retrying, when the API sent ``Retry-After``.
23
+ self.retry_after = retry_after
24
+
25
+ def __str__(self) -> str:
26
+ return f"{self.message} (status={self.status}, code={self.code})"
pixelvault/py.typed ADDED
File without changes
pixelvault/types.py ADDED
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, List, Optional, TypedDict
4
+
5
+ import sys
6
+
7
+ if sys.version_info >= (3, 11):
8
+ from typing import NotRequired
9
+ else: # pragma: no cover
10
+ from typing_extensions import NotRequired
11
+
12
+
13
+ class Image(TypedDict):
14
+ id: str
15
+ url: str
16
+ mime_type: str
17
+ size: int
18
+ width: Optional[int]
19
+ height: Optional[int]
20
+ filename: str
21
+ folder: Optional[str]
22
+ created_at: str
23
+ #: Present only on auto-expiring uploads.
24
+ expires_in: NotRequired[int]
25
+ temporary: NotRequired[bool]
26
+
27
+
28
+ class ListMeta(TypedDict):
29
+ total: int
30
+ page: int
31
+ per_page: int
32
+
33
+
34
+ class ImagePage(TypedDict):
35
+ data: List[Image]
36
+ meta: ListMeta
37
+
38
+
39
+ class SignedUrl(TypedDict):
40
+ url: str
41
+ expires: int
42
+
43
+
44
+ class UsageCounts(TypedDict):
45
+ storage_bytes: int
46
+ uploads: int
47
+
48
+
49
+ class UsageLimits(TypedDict):
50
+ storage_bytes: int
51
+ uploads_per_month: int
52
+ bandwidth_bytes_per_month: int
53
+
54
+
55
+ class Usage(TypedDict):
56
+ plan: str
57
+ period: str
58
+ resets_at: str
59
+ usage: UsageCounts
60
+ limits: UsageLimits
61
+ remaining: UsageCounts
62
+
63
+
64
+ class ApiKey(TypedDict):
65
+ id: str
66
+ type: str
67
+ environment: str
68
+ label: Optional[str]
69
+ key_prefix: str
70
+ allowed_origins: Optional[List[str]]
71
+ created_at: str
72
+ last_used_at: Optional[str]
73
+ #: True for the key that authenticated this request.
74
+ current: bool
75
+
76
+
77
+ class CreatedKey(TypedDict):
78
+ id: str
79
+ #: The raw key. Shown only in this response.
80
+ api_key: str
81
+ type: str
82
+ environment: str
83
+ label: Optional[str]
84
+ created_at: str
85
+
86
+
87
+ class DeviceLoginResult(TypedDict):
88
+ account_id: str
89
+ key_id: str
90
+ api_key: str
91
+ project_id: str
92
+ email: str
93
+ email_verified: bool
94
+
95
+
96
+ # Registration and batch responses are passed through as plain dicts.
97
+ JsonDict = Dict[str, Any]
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.5
2
+ Name: pixelvault
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for PixelVault, agent-first image hosting
5
+ Project-URL: Homepage, https://pixelvault.dev/docs
6
+ Project-URL: Repository, https://github.com/pixelvault-dev/sdk-python
7
+ Author: PixelVault
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: agent,image-hosting,pixelvault,sdk,upload
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.9
14
+ Requires-Dist: httpx<1,>=0.27
15
+ Requires-Dist: typing-extensions>=4.0; python_version < '3.11'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # pixelvault
19
+
20
+ Official Python SDK for [PixelVault](https://pixelvault.dev), agent-first image hosting.
21
+ Sync and asyncio clients on top of `httpx`. Python 3.9+.
22
+
23
+ ```bash
24
+ pip install pixelvault
25
+ ```
26
+
27
+ ## Quick start
28
+
29
+ ```python
30
+ from pixelvault import PixelVault
31
+
32
+ pv = PixelVault() # reads PIXELVAULT_API_KEY, or pass api_key="pv_live_..."
33
+
34
+ # A path, bytes or a binary file object
35
+ image = pv.images.upload("screenshot.png")
36
+ print(image["url"]) # https://img.pixelvault.dev/...
37
+
38
+ # Import from a URL, auto-delete after one hour
39
+ pv.images.upload(url="https://example.com/a.png", expires_in=3600)
40
+ ```
41
+
42
+ With no API key at all, `images.upload(file)` makes an anonymous upload that expires after 30 days.
43
+
44
+ Asyncio:
45
+
46
+ ```python
47
+ from pixelvault import AsyncPixelVault
48
+
49
+ async with AsyncPixelVault() as pv:
50
+ image = await pv.images.upload("screenshot.png")
51
+ async for img in pv.images.list_all():
52
+ print(img["id"])
53
+ ```
54
+
55
+ ## API
56
+
57
+ | Call | What it does |
58
+ |---|---|
59
+ | `images.upload(file \| url=, folder=, visibility=, expires_in=, sign_expires_in=)` | Upload a file or import a URL |
60
+ | `images.upload_batch(collection, images)` | Up to 50 images in one collection |
61
+ | `images.list(page=, per_page=)` / `images.list_all()` | One page / iterator over all images |
62
+ | `images.get(id)` / `images.delete(id)` | Fetch / delete one image |
63
+ | `images.sign_url(id, expires_in=)` | Signed URL for a private image |
64
+ | `usage.get()` | Plan, usage, limits and what is left this month |
65
+ | `keys.list()` / `keys.create(label=, environment=)` / `keys.revoke(id)` | Manage your API keys (secret keys only) |
66
+ | `register(email)` | Create an account, returns a first key once |
67
+ | `device_login_start(email)` + `device_login_complete(email, code)` | Passwordless key minting |
68
+
69
+ ## Errors and retries
70
+
71
+ Every failure raises `PixelVaultError` with `.status` (0 for network errors), `.code` (for example
72
+ `upload_limit_reached`) and `.retry_after` (seconds, when sent).
73
+
74
+ ```python
75
+ from pixelvault import PixelVault, PixelVaultError
76
+
77
+ try:
78
+ pv.images.upload("big.png")
79
+ except PixelVaultError as err:
80
+ if err.code == "upload_limit_reached":
81
+ print(err.message) # links to the pricing page
82
+ ```
83
+
84
+ The client retries `429` (honoring `Retry-After` up to 30 seconds) and, for `GET`/`DELETE` only, `5xx` and
85
+ network errors. Uploads are never retried after a `5xx`, so a lost response cannot create a duplicate image.
86
+ Tune with `max_retries` (default 2) and `timeout` (seconds, default 60).
87
+
88
+ ## License
89
+
90
+ MIT
@@ -0,0 +1,11 @@
1
+ pixelvault/__init__.py,sha256=jBy_xkhrxiJ892FjGm-bvzrx6sk8vjHVtbwxM03Nxfc,194
2
+ pixelvault/_core.py,sha256=N2su2mVy6clDN-4rw6_Y9baRP-WzEG4NmPaTembck_s,4358
3
+ pixelvault/_resources.py,sha256=NaUALtZBUFr5ZZFNQpG_KZi8yEAYff3kM3GDq3fWTMs,2823
4
+ pixelvault/client.py,sha256=iEI0NhueQytkDyJpKARNjkQQ9M3t7l-svK6kCYiwT6c,12377
5
+ pixelvault/errors.py,sha256=43ibr-f2rvEI2KsUT-1kMmV3T_DWDToLmb-o6NKAzOg,817
6
+ pixelvault/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ pixelvault/types.py,sha256=3RPU7YaXUWl4vDOucybTL2fyah3jksPtwGYvvKi7FMs,1825
8
+ pixelvault-0.1.0.dist-info/METADATA,sha256=w5oygkaxc0TZ5sK2A2PILA0WHZSBV6fVLg7CYDpiPCc,3075
9
+ pixelvault-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
10
+ pixelvault-0.1.0.dist-info/licenses/LICENSE,sha256=xcPCa_hXNL9ljovcuj2WSIKfF7Vwz1m9RDmN6IEiwLA,1067
11
+ pixelvault-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PixelVault
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.