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.
- pixelvault-0.1.0/.github/workflows/ci.yml +33 -0
- pixelvault-0.1.0/.github/workflows/release.yml +25 -0
- pixelvault-0.1.0/.gitignore +8 -0
- pixelvault-0.1.0/LICENSE +21 -0
- pixelvault-0.1.0/PKG-INFO +90 -0
- pixelvault-0.1.0/README.md +73 -0
- pixelvault-0.1.0/pyproject.toml +40 -0
- pixelvault-0.1.0/scripts/check_spec_coverage.py +43 -0
- pixelvault-0.1.0/src/pixelvault/__init__.py +5 -0
- pixelvault-0.1.0/src/pixelvault/_core.py +144 -0
- pixelvault-0.1.0/src/pixelvault/_resources.py +99 -0
- pixelvault-0.1.0/src/pixelvault/client.py +374 -0
- pixelvault-0.1.0/src/pixelvault/errors.py +26 -0
- pixelvault-0.1.0/src/pixelvault/py.typed +0 -0
- pixelvault-0.1.0/src/pixelvault/types.py +97 -0
- pixelvault-0.1.0/tests/test_client.py +209 -0
- pixelvault-0.1.0/uv.lock +740 -0
|
@@ -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
|
pixelvault-0.1.0/LICENSE
ADDED
|
@@ -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,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
|
+
|