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.
- createrington_skin_api-1.0.0/.gitignore +13 -0
- createrington_skin_api-1.0.0/CHANGELOG.md +24 -0
- createrington_skin_api-1.0.0/LICENSE +27 -0
- createrington_skin_api-1.0.0/PKG-INFO +174 -0
- createrington_skin_api-1.0.0/README.md +120 -0
- createrington_skin_api-1.0.0/pyproject.toml +55 -0
- createrington_skin_api-1.0.0/scripts/generate_poses.py +50 -0
- createrington_skin_api-1.0.0/src/createrington_skin_api/__init__.py +22 -0
- createrington_skin_api-1.0.0/src/createrington_skin_api/_core.py +139 -0
- createrington_skin_api-1.0.0/src/createrington_skin_api/_http.py +28 -0
- createrington_skin_api-1.0.0/src/createrington_skin_api/_poses.py +55 -0
- createrington_skin_api-1.0.0/src/createrington_skin_api/async_client.py +138 -0
- createrington_skin_api-1.0.0/src/createrington_skin_api/client.py +138 -0
- createrington_skin_api-1.0.0/src/createrington_skin_api/errors.py +116 -0
- createrington_skin_api-1.0.0/src/createrington_skin_api/py.typed +0 -0
- createrington_skin_api-1.0.0/tests/test_async_client.py +127 -0
- createrington_skin_api-1.0.0/tests/test_client.py +241 -0
|
@@ -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
|