createrington-skin-api 1.0.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,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ dist/
6
+ build/
7
+ .mypy_cache/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .venv/
11
+ venv/
12
+ .coverage
13
+ htmlcov/
@@ -0,0 +1,24 @@
1
+ # createrington-skin-api (Python)
2
+
3
+ This changelog tracks the Python SDK only. It is versioned and released
4
+ independently of the rest of the repo, via `sdk-py-v<version>` git tags.
5
+
6
+ ## v1.0.0
7
+
8
+ Initial release on PyPI.
9
+
10
+ ### Surface
11
+
12
+ - `SkinApiClient` (sync) and `AsyncSkinApiClient` (async), both backed by
13
+ `httpx` and usable as context managers.
14
+ - `render(pose, *, uuid | username | skin_url | skin_base64 | png, slim,
15
+ width, height)` returning PNG `bytes`. Exactly one skin source is required.
16
+ - `api_key` falls back to the `SKIN_API_KEY` environment variable; `base_url`
17
+ defaults to `https://api.createrington.com`.
18
+ - `KNOWN_POSES` tuple + `KnownPose` literal type generated from the server's
19
+ pose data; `render` still accepts any pose string.
20
+ - Single `SkinApiError` carrying `code`, `status`, and `retry_after_ms`.
21
+ Server `UPPER_SNAKE` codes are normalized to the documented lowercase form.
22
+ - Retries `429`/`502`/`503`/`504` and network errors with exponential
23
+ backoff, honouring `retryAfterMs` on `429`.
24
+ - Ships `py.typed` for full mypy/pyright inference.
@@ -0,0 +1,27 @@
1
+ Copyright (c) 2026 Matej Hozlar. All Rights Reserved.
2
+
3
+ This software and its accompanying source code, documentation, assets, and any
4
+ related materials (the "Software") are the proprietary and confidential
5
+ property of Matej Hozlar ("the Author") and are protected by copyright law and
6
+ international treaties.
7
+
8
+ NO LICENSE IS GRANTED.
9
+
10
+ No part of the Software may be copied, reproduced, modified, merged,
11
+ published, distributed, sublicensed, sold, leased, rented, publicly displayed,
12
+ publicly performed, transmitted, reverse engineered, decompiled, disassembled,
13
+ or otherwise exploited in any form or by any means, in whole or in part,
14
+ without the prior express written permission of the Author.
15
+
16
+ Access to the Software, including via source repositories, build artifacts, or
17
+ deployed services, does not grant any license or right of use beyond what has
18
+ been explicitly authorized in writing by the Author.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN
24
+ ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION
25
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
26
+
27
+ All rights not expressly granted herein are reserved by the Author.
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.4
2
+ Name: createrington-skin-api
3
+ Version: 1.0.0
4
+ Summary: Official Python client for the Createrington Skin API.
5
+ Project-URL: Homepage, https://api.createrington.com
6
+ Project-URL: Repository, https://gitea.matejhoz.com/matejhozlar/skin-api
7
+ Author: Matej Hozlar
8
+ License: Copyright (c) 2026 Matej Hozlar. All Rights Reserved.
9
+
10
+ This software and its accompanying source code, documentation, assets, and any
11
+ related materials (the "Software") are the proprietary and confidential
12
+ property of Matej Hozlar ("the Author") and are protected by copyright law and
13
+ international treaties.
14
+
15
+ NO LICENSE IS GRANTED.
16
+
17
+ No part of the Software may be copied, reproduced, modified, merged,
18
+ published, distributed, sublicensed, sold, leased, rented, publicly displayed,
19
+ publicly performed, transmitted, reverse engineered, decompiled, disassembled,
20
+ or otherwise exploited in any form or by any means, in whole or in part,
21
+ without the prior express written permission of the Author.
22
+
23
+ Access to the Software, including via source repositories, build artifacts, or
24
+ deployed services, does not grant any license or right of use beyond what has
25
+ been explicitly authorized in writing by the Author.
26
+
27
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
28
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
29
+ FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE
30
+ AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN
31
+ ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION
32
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
33
+
34
+ All rights not expressly granted herein are reserved by the Author.
35
+ License-File: LICENSE
36
+ Keywords: api-client,createrington,minecraft,render,skin
37
+ Classifier: Development Status :: 5 - Production/Stable
38
+ Classifier: Intended Audience :: Developers
39
+ Classifier: Operating System :: OS Independent
40
+ Classifier: Programming Language :: Python :: 3
41
+ Classifier: Programming Language :: Python :: 3.10
42
+ Classifier: Programming Language :: Python :: 3.11
43
+ Classifier: Programming Language :: Python :: 3.12
44
+ Classifier: Programming Language :: Python :: 3.13
45
+ Classifier: Programming Language :: Python :: Implementation :: CPython
46
+ Classifier: Typing :: Typed
47
+ Requires-Python: >=3.10
48
+ Requires-Dist: httpx>=0.27
49
+ Provides-Extra: dev
50
+ Requires-Dist: mypy>=1.8; extra == 'dev'
51
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
52
+ Requires-Dist: pytest>=8.0; extra == 'dev'
53
+ Description-Content-Type: text/markdown
54
+
55
+ # createrington-skin-api
56
+
57
+ Official Python client for the Createrington Skin API. Renders Minecraft
58
+ player skins into named poses and returns PNG bytes. Sync and async clients,
59
+ fully typed.
60
+
61
+ ```sh
62
+ pip install createrington-skin-api
63
+ ```
64
+
65
+ > Access is invite-only. Request an API key at https://api.createrington.com.
66
+
67
+ ## Quickstart
68
+
69
+ ```python
70
+ from createrington_skin_api import SkinApiClient
71
+
72
+ client = SkinApiClient(api_key="sk_...") # or set SKIN_API_KEY
73
+
74
+ # Render a known pose for a Minecraft account by UUID.
75
+ png = client.render("wave", uuid="069a79f444e94726a5befca90e38aaf5")
76
+
77
+ # `png` is `bytes` of a PNG image.
78
+ with open("notch-waving.png", "wb") as f:
79
+ f.write(png)
80
+ ```
81
+
82
+ ### Async
83
+
84
+ ```python
85
+ import asyncio
86
+ from createrington_skin_api import AsyncSkinApiClient
87
+
88
+
89
+ async def main() -> None:
90
+ async with AsyncSkinApiClient(api_key="sk_...") as client:
91
+ png = await client.render("wave", username="Notch", slim=True)
92
+ with open("notch-waving.png", "wb") as f:
93
+ f.write(png)
94
+
95
+
96
+ asyncio.run(main())
97
+ ```
98
+
99
+ ## Client
100
+
101
+ ```python
102
+ SkinApiClient(
103
+ api_key=None, # required; falls back to the SKIN_API_KEY env var
104
+ base_url="https://api.createrington.com",
105
+ timeout=30.0, # seconds
106
+ retries=2, # retries 429/502/503/504 and network errors
107
+ user_agent="createrington-skin-api",
108
+ )
109
+ ```
110
+
111
+ `AsyncSkinApiClient` takes the same arguments. Both are usable as context
112
+ managers (`with` / `async with`) and expose `close()` / `aclose()` for
113
+ explicit cleanup of the underlying connection pool.
114
+
115
+ ## `render`
116
+
117
+ ```python
118
+ client.render(
119
+ pose, # a KNOWN_POSES name (e.g. "wave"), or any pose string
120
+ *,
121
+ # exactly one skin source:
122
+ uuid=None, # Mojang UUID, resolved server-side
123
+ username=None, # Mojang username, resolved server-side
124
+ skin_url=None, # public URL to a 64x64 PNG
125
+ skin_base64=None, # base64-encoded 64x64 PNG (data URL prefix optional)
126
+ png=None, # raw 64x64 PNG bytes, sent as multipart/form-data
127
+ # options:
128
+ slim=None, # override slim/Alex arm geometry; default uses skin metadata
129
+ width=None, # default 400 (64..2048)
130
+ height=None, # default 600 (64..2048)
131
+ ) -> bytes
132
+ ```
133
+
134
+ Exactly one skin source must be supplied; passing none or more than one
135
+ raises `ValueError`.
136
+
137
+ `pose` accepts any string, so server-side poses added after this release work
138
+ without an SDK upgrade. The bundled `KNOWN_POSES` tuple and `KnownPose` type
139
+ cover the poses known at publish time; fetch `GET /v1/poses` directly if you
140
+ need the live catalogue with descriptions.
141
+
142
+ ```python
143
+ from createrington_skin_api import KNOWN_POSES, KnownPose
144
+ ```
145
+
146
+ ## Errors
147
+
148
+ Every non-2xx response (and network/timeout failures) raises `SkinApiError`:
149
+
150
+ ```python
151
+ from createrington_skin_api import SkinApiError
152
+
153
+ try:
154
+ client.render("wave", uuid="bad-uuid")
155
+ except SkinApiError as err:
156
+ print(err.code, err.status, err)
157
+ if err.code == "rate_limited" and err.retry_after_ms:
158
+ ... # back off and retry
159
+ ```
160
+
161
+ `err.code` is one of `"bad_request"`, `"unauthorized"`, `"forbidden"`,
162
+ `"not_found"`, `"conflict"`, `"unsupported_media_type"`, `"rate_limited"`,
163
+ `"internal"`, `"render_failed"`, `"upstream_unavailable"`, `"timeout"`,
164
+ `"aborted"`, `"network_error"`, `"unknown"`. `err.status` is the HTTP status
165
+ (or `0` for network/timeout failures). `err.retry_after_ms` is populated on
166
+ `429` responses when the server reports it.
167
+
168
+ The client retries `429`, `502`, `503`, `504`, and network errors up to
169
+ `retries` times with exponential backoff; `429` responses honour the server's
170
+ `retryAfterMs` when present.
171
+
172
+ ## License
173
+
174
+ UNLICENSED. See repository for terms.
@@ -0,0 +1,120 @@
1
+ # createrington-skin-api
2
+
3
+ Official Python client for the Createrington Skin API. Renders Minecraft
4
+ player skins into named poses and returns PNG bytes. Sync and async clients,
5
+ fully typed.
6
+
7
+ ```sh
8
+ pip install createrington-skin-api
9
+ ```
10
+
11
+ > Access is invite-only. Request an API key at https://api.createrington.com.
12
+
13
+ ## Quickstart
14
+
15
+ ```python
16
+ from createrington_skin_api import SkinApiClient
17
+
18
+ client = SkinApiClient(api_key="sk_...") # or set SKIN_API_KEY
19
+
20
+ # Render a known pose for a Minecraft account by UUID.
21
+ png = client.render("wave", uuid="069a79f444e94726a5befca90e38aaf5")
22
+
23
+ # `png` is `bytes` of a PNG image.
24
+ with open("notch-waving.png", "wb") as f:
25
+ f.write(png)
26
+ ```
27
+
28
+ ### Async
29
+
30
+ ```python
31
+ import asyncio
32
+ from createrington_skin_api import AsyncSkinApiClient
33
+
34
+
35
+ async def main() -> None:
36
+ async with AsyncSkinApiClient(api_key="sk_...") as client:
37
+ png = await client.render("wave", username="Notch", slim=True)
38
+ with open("notch-waving.png", "wb") as f:
39
+ f.write(png)
40
+
41
+
42
+ asyncio.run(main())
43
+ ```
44
+
45
+ ## Client
46
+
47
+ ```python
48
+ SkinApiClient(
49
+ api_key=None, # required; falls back to the SKIN_API_KEY env var
50
+ base_url="https://api.createrington.com",
51
+ timeout=30.0, # seconds
52
+ retries=2, # retries 429/502/503/504 and network errors
53
+ user_agent="createrington-skin-api",
54
+ )
55
+ ```
56
+
57
+ `AsyncSkinApiClient` takes the same arguments. Both are usable as context
58
+ managers (`with` / `async with`) and expose `close()` / `aclose()` for
59
+ explicit cleanup of the underlying connection pool.
60
+
61
+ ## `render`
62
+
63
+ ```python
64
+ client.render(
65
+ pose, # a KNOWN_POSES name (e.g. "wave"), or any pose string
66
+ *,
67
+ # exactly one skin source:
68
+ uuid=None, # Mojang UUID, resolved server-side
69
+ username=None, # Mojang username, resolved server-side
70
+ skin_url=None, # public URL to a 64x64 PNG
71
+ skin_base64=None, # base64-encoded 64x64 PNG (data URL prefix optional)
72
+ png=None, # raw 64x64 PNG bytes, sent as multipart/form-data
73
+ # options:
74
+ slim=None, # override slim/Alex arm geometry; default uses skin metadata
75
+ width=None, # default 400 (64..2048)
76
+ height=None, # default 600 (64..2048)
77
+ ) -> bytes
78
+ ```
79
+
80
+ Exactly one skin source must be supplied; passing none or more than one
81
+ raises `ValueError`.
82
+
83
+ `pose` accepts any string, so server-side poses added after this release work
84
+ without an SDK upgrade. The bundled `KNOWN_POSES` tuple and `KnownPose` type
85
+ cover the poses known at publish time; fetch `GET /v1/poses` directly if you
86
+ need the live catalogue with descriptions.
87
+
88
+ ```python
89
+ from createrington_skin_api import KNOWN_POSES, KnownPose
90
+ ```
91
+
92
+ ## Errors
93
+
94
+ Every non-2xx response (and network/timeout failures) raises `SkinApiError`:
95
+
96
+ ```python
97
+ from createrington_skin_api import SkinApiError
98
+
99
+ try:
100
+ client.render("wave", uuid="bad-uuid")
101
+ except SkinApiError as err:
102
+ print(err.code, err.status, err)
103
+ if err.code == "rate_limited" and err.retry_after_ms:
104
+ ... # back off and retry
105
+ ```
106
+
107
+ `err.code` is one of `"bad_request"`, `"unauthorized"`, `"forbidden"`,
108
+ `"not_found"`, `"conflict"`, `"unsupported_media_type"`, `"rate_limited"`,
109
+ `"internal"`, `"render_failed"`, `"upstream_unavailable"`, `"timeout"`,
110
+ `"aborted"`, `"network_error"`, `"unknown"`. `err.status` is the HTTP status
111
+ (or `0` for network/timeout failures). `err.retry_after_ms` is populated on
112
+ `429` responses when the server reports it.
113
+
114
+ The client retries `429`, `502`, `503`, `504`, and network errors up to
115
+ `retries` times with exponential backoff; `429` responses honour the server's
116
+ `retryAfterMs` when present.
117
+
118
+ ## License
119
+
120
+ UNLICENSED. See repository for terms.
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "createrington-skin-api"
7
+ dynamic = ["version"]
8
+ description = "Official Python client for the Createrington Skin API."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "Matej Hozlar" }]
13
+ keywords = ["minecraft", "skin", "render", "createrington", "api-client"]
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Intended Audience :: Developers",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: Implementation :: CPython",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = ["httpx>=0.27"]
27
+
28
+ [project.urls]
29
+ Homepage = "https://api.createrington.com"
30
+ Repository = "https://gitea.matejhoz.com/matejhozlar/skin-api"
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest>=8.0",
35
+ "pytest-asyncio>=0.23",
36
+ "mypy>=1.8",
37
+ ]
38
+
39
+ [tool.hatch.version]
40
+ path = "src/createrington_skin_api/__init__.py"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/createrington_skin_api"]
44
+
45
+ [tool.hatch.build.targets.sdist]
46
+ include = ["src", "scripts", "tests", "README.md", "CHANGELOG.md", "LICENSE"]
47
+
48
+ [tool.mypy]
49
+ python_version = "3.10"
50
+ strict = true
51
+ files = ["src", "scripts"]
52
+
53
+ [tool.pytest.ini_options]
54
+ asyncio_mode = "auto"
55
+ testpaths = ["tests"]
@@ -0,0 +1,50 @@
1
+ """Regenerate src/createrington_skin_api/_poses.py from the renderer pose data.
2
+
3
+ The pose JSON files in packages/renderer/data/poses/ are the source of truth.
4
+ Run this after that data changes:
5
+
6
+ python scripts/generate_poses.py
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ SCRIPT_DIR = Path(__file__).resolve().parent
15
+ SDK_ROOT = SCRIPT_DIR.parent
16
+ REPO_ROOT = SDK_ROOT.parent.parent
17
+ POSES_DIR = REPO_ROOT / "packages" / "renderer" / "data" / "poses"
18
+ OUT_FILE = SDK_ROOT / "src" / "createrington_skin_api" / "_poses.py"
19
+
20
+
21
+ def _entries(names: list[str]) -> str:
22
+ return "\n".join(f' "{name}",' for name in names)
23
+
24
+
25
+ def main() -> int:
26
+ if not POSES_DIR.is_dir():
27
+ sys.stderr.write(f"pose data directory not found: {POSES_DIR}\n")
28
+ return 1
29
+
30
+ names = sorted(p.stem for p in POSES_DIR.glob("*.json"))
31
+ if not names:
32
+ sys.stderr.write(f"no pose JSON files found in {POSES_DIR}\n")
33
+ return 1
34
+
35
+ body = (
36
+ "# AUTO-GENERATED by scripts/generate_poses.py\n"
37
+ "# Do not edit by hand. Regenerated from packages/renderer/data/poses/.\n"
38
+ "from __future__ import annotations\n\n"
39
+ "from typing import Literal, Tuple\n\n"
40
+ f"KNOWN_POSES: Tuple[str, ...] = (\n{_entries(names)}\n)\n\n"
41
+ f"KnownPose = Literal[\n{_entries(names)}\n]\n"
42
+ )
43
+
44
+ OUT_FILE.write_text(body, encoding="utf-8")
45
+ sys.stdout.write(f"generated {OUT_FILE} ({len(names)} poses)\n")
46
+ return 0
47
+
48
+
49
+ if __name__ == "__main__":
50
+ raise SystemExit(main())
@@ -0,0 +1,22 @@
1
+ """Official Python client for the Createrington Skin API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._core import DEFAULT_BASE_URL
6
+ from ._poses import KNOWN_POSES, KnownPose
7
+ from .async_client import AsyncSkinApiClient
8
+ from .client import SkinApiClient
9
+ from .errors import SkinApiError, SkinApiErrorCode
10
+
11
+ __version__ = "1.0.0"
12
+
13
+ __all__ = [
14
+ "AsyncSkinApiClient",
15
+ "SkinApiClient",
16
+ "SkinApiError",
17
+ "SkinApiErrorCode",
18
+ "KNOWN_POSES",
19
+ "KnownPose",
20
+ "DEFAULT_BASE_URL",
21
+ "__version__",
22
+ ]
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ from dataclasses import dataclass
5
+ from typing import Tuple
6
+
7
+ DEFAULT_BASE_URL = "https://api.createrington.com"
8
+ DEFAULT_TIMEOUT = 30.0
9
+ DEFAULT_RETRIES = 2
10
+ DEFAULT_USER_AGENT = "createrington-skin-api"
11
+ API_KEY_ENV = "SKIN_API_KEY"
12
+
13
+ _BACKOFF_BASE_MS = 200
14
+ _BACKOFF_MAX_MS = 5_000
15
+ _RETRYABLE_STATUSES = frozenset({429, 502, 503, 504})
16
+
17
+ # render() keyword name -> JSON body field name. png is multipart, not JSON.
18
+ _JSON_SOURCE_FIELDS: dict[str, str] = {
19
+ "uuid": "uuid",
20
+ "username": "username",
21
+ "skin_url": "skinUrl",
22
+ "skin_base64": "skinBase64",
23
+ }
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class PreparedRender:
28
+ url: str
29
+ params: dict[str, str]
30
+ json: dict[str, str] | None = None
31
+ files: dict[str, Tuple[str, bytes, str]] | None = None
32
+
33
+
34
+ def _select_source(
35
+ *,
36
+ uuid: str | None,
37
+ username: str | None,
38
+ skin_url: str | None,
39
+ skin_base64: str | None,
40
+ png: bytes | bytearray | memoryview | None,
41
+ ) -> str:
42
+ given = [
43
+ name
44
+ for name, value in (
45
+ ("uuid", uuid),
46
+ ("username", username),
47
+ ("skin_url", skin_url),
48
+ ("skin_base64", skin_base64),
49
+ ("png", png),
50
+ )
51
+ if value is not None
52
+ ]
53
+
54
+ if not given:
55
+ raise ValueError(
56
+ "render() requires exactly one skin source: pass one of "
57
+ "uuid, username, skin_url, skin_base64, or png"
58
+ )
59
+ if len(given) > 1:
60
+ raise ValueError(
61
+ "render() accepts exactly one skin source, but several were given: "
62
+ + ", ".join(given)
63
+ )
64
+ return given[0]
65
+
66
+
67
+ def prepare_render(
68
+ base_url: str,
69
+ pose: str,
70
+ *,
71
+ uuid: str | None,
72
+ username: str | None,
73
+ skin_url: str | None,
74
+ skin_base64: str | None,
75
+ png: bytes | bytearray | memoryview | None,
76
+ slim: bool | None,
77
+ width: int | None,
78
+ height: int | None,
79
+ ) -> PreparedRender:
80
+ name = _select_source(
81
+ uuid=uuid,
82
+ username=username,
83
+ skin_url=skin_url,
84
+ skin_base64=skin_base64,
85
+ png=png,
86
+ )
87
+
88
+ params: dict[str, str] = {"pose": pose}
89
+ if slim is not None:
90
+ params["slim"] = "1" if slim else "0"
91
+ if width is not None:
92
+ params["width"] = str(width)
93
+ if height is not None:
94
+ params["height"] = str(height)
95
+
96
+ url = f"{base_url}/v1/render"
97
+
98
+ if name == "png":
99
+ assert png is not None
100
+ return PreparedRender(
101
+ url=url,
102
+ params=params,
103
+ files={"skin": ("skin.png", bytes(png), "image/png")},
104
+ )
105
+
106
+ value = {
107
+ "uuid": uuid,
108
+ "username": username,
109
+ "skin_url": skin_url,
110
+ "skin_base64": skin_base64,
111
+ }[name]
112
+ assert value is not None
113
+ return PreparedRender(
114
+ url=url,
115
+ params=params,
116
+ json={_JSON_SOURCE_FIELDS[name]: value},
117
+ )
118
+
119
+
120
+ def is_retryable_status(status: int) -> bool:
121
+ return status in _RETRYABLE_STATUSES
122
+
123
+
124
+ def _backoff_seconds(attempt: int) -> float:
125
+ capped = min(_BACKOFF_BASE_MS * (1 << attempt), _BACKOFF_MAX_MS)
126
+ jitter = capped * 0.25 * random.random()
127
+ return (capped + jitter) / 1000.0
128
+
129
+
130
+ def retry_delay_seconds(status: int, body: object, attempt: int) -> float:
131
+ if status == 429 and isinstance(body, dict):
132
+ info = body.get("error")
133
+ if isinstance(info, dict):
134
+ retry_after = info.get("retryAfterMs")
135
+ if isinstance(retry_after, (int, float)) and not isinstance(
136
+ retry_after, bool
137
+ ):
138
+ return float(retry_after) / 1000.0
139
+ return _backoff_seconds(attempt)
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+
6
+ def safe_json(response: httpx.Response) -> object:
7
+ """Parse a response body as JSON, returning None when it is not JSON."""
8
+ try:
9
+ return response.json()
10
+ except ValueError:
11
+ return None
12
+
13
+
14
+ def default_headers(api_key: str, user_agent: str) -> dict[str, str]:
15
+ return {
16
+ "authorization": f"Bearer {api_key}",
17
+ "user-agent": user_agent,
18
+ }
19
+
20
+
21
+ def resolve_api_key(api_key: str | None, env_value: str | None) -> str:
22
+ key = api_key if api_key is not None else env_value
23
+ if not key:
24
+ raise ValueError(
25
+ "api_key is required: pass api_key=... or set the "
26
+ "SKIN_API_KEY environment variable"
27
+ )
28
+ return key