pixelvault 0.1.0__tar.gz

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.
@@ -0,0 +1,33 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+ schedule:
9
+ - cron: "0 6 * * 1"
10
+
11
+ jobs:
12
+ check:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ matrix:
16
+ python: ["3.9", "3.11", "3.13"]
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: astral-sh/setup-uv@v5
20
+ - run: uv python install ${{ matrix.python }}
21
+ - run: uv sync --python ${{ matrix.python }}
22
+ - run: uv run mypy
23
+ - run: uv run pytest -q
24
+ - run: uv build
25
+
26
+ spec-coverage:
27
+ runs-on: ubuntu-latest
28
+ steps:
29
+ - uses: actions/checkout@v4
30
+ - uses: actions/setup-python@v5
31
+ with:
32
+ python-version: "3.13"
33
+ - run: python scripts/check_spec_coverage.py
@@ -0,0 +1,25 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ environment: pypi
12
+ permissions:
13
+ id-token: write # PyPI trusted publishing, no stored token
14
+ contents: read
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: astral-sh/setup-uv@v5
18
+ - name: Tag must match pyproject version
19
+ run: |
20
+ v="$(python3 -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")"
21
+ test "v$v" = "${GITHUB_REF_NAME}"
22
+ - run: uv sync
23
+ - run: uv run pytest -q
24
+ - run: uv build
25
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ *.egg-info/
6
+ .mypy_cache/
7
+ .pytest_cache/
8
+ .DS_Store
@@ -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.
@@ -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,73 @@
1
+ # pixelvault
2
+
3
+ Official Python SDK for [PixelVault](https://pixelvault.dev), agent-first image hosting.
4
+ Sync and asyncio clients on top of `httpx`. Python 3.9+.
5
+
6
+ ```bash
7
+ pip install pixelvault
8
+ ```
9
+
10
+ ## Quick start
11
+
12
+ ```python
13
+ from pixelvault import PixelVault
14
+
15
+ pv = PixelVault() # reads PIXELVAULT_API_KEY, or pass api_key="pv_live_..."
16
+
17
+ # A path, bytes or a binary file object
18
+ image = pv.images.upload("screenshot.png")
19
+ print(image["url"]) # https://img.pixelvault.dev/...
20
+
21
+ # Import from a URL, auto-delete after one hour
22
+ pv.images.upload(url="https://example.com/a.png", expires_in=3600)
23
+ ```
24
+
25
+ With no API key at all, `images.upload(file)` makes an anonymous upload that expires after 30 days.
26
+
27
+ Asyncio:
28
+
29
+ ```python
30
+ from pixelvault import AsyncPixelVault
31
+
32
+ async with AsyncPixelVault() as pv:
33
+ image = await pv.images.upload("screenshot.png")
34
+ async for img in pv.images.list_all():
35
+ print(img["id"])
36
+ ```
37
+
38
+ ## API
39
+
40
+ | Call | What it does |
41
+ |---|---|
42
+ | `images.upload(file \| url=, folder=, visibility=, expires_in=, sign_expires_in=)` | Upload a file or import a URL |
43
+ | `images.upload_batch(collection, images)` | Up to 50 images in one collection |
44
+ | `images.list(page=, per_page=)` / `images.list_all()` | One page / iterator over all images |
45
+ | `images.get(id)` / `images.delete(id)` | Fetch / delete one image |
46
+ | `images.sign_url(id, expires_in=)` | Signed URL for a private image |
47
+ | `usage.get()` | Plan, usage, limits and what is left this month |
48
+ | `keys.list()` / `keys.create(label=, environment=)` / `keys.revoke(id)` | Manage your API keys (secret keys only) |
49
+ | `register(email)` | Create an account, returns a first key once |
50
+ | `device_login_start(email)` + `device_login_complete(email, code)` | Passwordless key minting |
51
+
52
+ ## Errors and retries
53
+
54
+ Every failure raises `PixelVaultError` with `.status` (0 for network errors), `.code` (for example
55
+ `upload_limit_reached`) and `.retry_after` (seconds, when sent).
56
+
57
+ ```python
58
+ from pixelvault import PixelVault, PixelVaultError
59
+
60
+ try:
61
+ pv.images.upload("big.png")
62
+ except PixelVaultError as err:
63
+ if err.code == "upload_limit_reached":
64
+ print(err.message) # links to the pricing page
65
+ ```
66
+
67
+ The client retries `429` (honoring `Retry-After` up to 30 seconds) and, for `GET`/`DELETE` only, `5xx` and
68
+ network errors. Uploads are never retried after a `5xx`, so a lost response cannot create a duplicate image.
69
+ Tune with `max_retries` (default 2) and `timeout` (seconds, default 60).
70
+
71
+ ## License
72
+
73
+ MIT
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pixelvault"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for PixelVault, agent-first image hosting"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [{ name = "PixelVault" }]
14
+ keywords = ["pixelvault", "image-hosting", "sdk", "upload", "agent"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = [
20
+ "httpx>=0.27,<1",
21
+ "typing_extensions>=4.0; python_version < '3.11'",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://pixelvault.dev/docs"
26
+ Repository = "https://github.com/pixelvault-dev/sdk-python"
27
+
28
+ [dependency-groups]
29
+ dev = ["pytest>=8", "pytest-asyncio>=0.23", "mypy>=1.10"]
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/pixelvault"]
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
36
+ asyncio_mode = "auto"
37
+
38
+ [tool.mypy]
39
+ strict = true
40
+ files = ["src"]
@@ -0,0 +1,43 @@
1
+ """Fail when the live API gains an operation this SDK neither wraps nor ignores.
2
+
3
+ Run in CI on every push and weekly, so an API change turns this repo red
4
+ until someone decides whether the SDK should cover it.
5
+ """
6
+
7
+ import json
8
+ import sys
9
+ import urllib.request
10
+
11
+ COVERED = {
12
+ "upload_image", "batch_upload", "list_images", "get_image", "delete_image",
13
+ "sign_image_url", "get_usage", "list_keys", "create_key", "revoke_key",
14
+ "register_account", "device_login_start", "device_login_complete",
15
+ }
16
+ # Not wrapped on purpose: dashboard-session endpoints, the public playground,
17
+ # health, and surfaces the SDK does not cover yet.
18
+ IGNORED = {
19
+ "claim_upload", "dashboard_upload", "playground_upload", "health_check", "serve_image",
20
+ "list_collections", "get_collection", "close_collection",
21
+ "start_export", "get_export_job", "download_export",
22
+ }
23
+
24
+ req = urllib.request.Request(
25
+ "https://api.pixelvault.dev/openapi.json",
26
+ headers={"User-Agent": "pixelvault-sdk-python-ci"},
27
+ )
28
+ with urllib.request.urlopen(req) as res:
29
+ spec = json.load(res)
30
+
31
+ live = {
32
+ op["operationId"]
33
+ for item in spec["paths"].values()
34
+ for op in item.values()
35
+ if isinstance(op, dict) and "operationId" in op
36
+ }
37
+ new = sorted(live - COVERED - IGNORED)
38
+ gone = sorted(COVERED - live)
39
+ if new or gone:
40
+ print(f"New operations to cover or ignore: {new}")
41
+ print(f"Covered operations no longer in the API: {gone}")
42
+ sys.exit(1)
43
+ print(f"spec coverage ok: {len(COVERED)} covered, {len(IGNORED)} ignored")
@@ -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__"]
@@ -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
+