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 +5 -0
- pixelvault/_core.py +144 -0
- pixelvault/_resources.py +99 -0
- pixelvault/client.py +374 -0
- pixelvault/errors.py +26 -0
- pixelvault/py.typed +0 -0
- pixelvault/types.py +97 -0
- pixelvault-0.1.0.dist-info/METADATA +90 -0
- pixelvault-0.1.0.dist-info/RECORD +11 -0
- pixelvault-0.1.0.dist-info/WHEEL +4 -0
- pixelvault-0.1.0.dist-info/licenses/LICENSE +21 -0
pixelvault/__init__.py
ADDED
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")
|
pixelvault/_resources.py
ADDED
|
@@ -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,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.
|