studio99 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,58 @@
1
+ # Publishes to PyPI when a version tag (v0.1.0, v0.2.0, ...) is pushed.
2
+ # Uses PyPI Trusted Publishing: no API token is stored anywhere.
3
+ # PyPI side: project "studio99" trusts owner studio99-app, repo studio99-python,
4
+ # workflow publish.yml, environment "pypi".
5
+ name: publish
6
+
7
+ on:
8
+ push:
9
+ tags: ["v*"]
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ jobs:
15
+ test:
16
+ runs-on: ubuntu-latest
17
+ strategy:
18
+ matrix:
19
+ python: ["3.9", "3.13"]
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: actions/setup-python@v5
23
+ with:
24
+ python-version: ${{ matrix.python }}
25
+ - run: python -m unittest discover -s tests -v
26
+
27
+ build:
28
+ needs: test
29
+ runs-on: ubuntu-latest
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+ - uses: actions/setup-python@v5
33
+ with:
34
+ python-version: "3.13"
35
+ - name: Tag must match pyproject version
36
+ run: |
37
+ v=$(python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
38
+ test "v$v" = "${GITHUB_REF_NAME}" || { echo "tag ${GITHUB_REF_NAME} != pyproject v$v"; exit 1; }
39
+ - run: python -m pip install build twine
40
+ - run: python -m build
41
+ - run: python -m twine check dist/*
42
+ - uses: actions/upload-artifact@v4
43
+ with:
44
+ name: dist
45
+ path: dist/
46
+
47
+ publish:
48
+ needs: build
49
+ runs-on: ubuntu-latest
50
+ environment: pypi
51
+ permissions:
52
+ id-token: write # the only permission Trusted Publishing needs
53
+ steps:
54
+ - uses: actions/download-artifact@v4
55
+ with:
56
+ name: dist
57
+ path: dist/
58
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ .venv/
7
+ .env*
8
+ *.svg
studio99-0.1.0/LICENSE ADDED
@@ -0,0 +1,25 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ArtoMania Studio Private Limited
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.
22
+
23
+ This licence covers the code in this repository only. The Studio99 Indic
24
+ Typography API, its fonts and the artwork it returns are governed by the API
25
+ terms at https://studio99.app/developers/terms.
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.5
2
+ Name: studio99
3
+ Version: 0.1.0
4
+ Summary: Official Python client for the Studio99 Indic Typography API: exact Hindi, Marathi, Gujarati and English typography as editable SVG and PNG.
5
+ Project-URL: Homepage, https://studio99.app/developers
6
+ Project-URL: Documentation, https://studio99.app/developers/docs
7
+ Project-URL: Source, https://github.com/studio99-app/studio99-python
8
+ Project-URL: Changelog, https://studio99.app/developers/changelog
9
+ Author-email: ArtoMania Studio Private Limited <reach@studio99.app>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: calligraphy,devanagari,gujarati,hindi,indic,marathi,studio99,svg,typography
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Multimedia :: Graphics
16
+ Classifier: Topic :: Text Processing :: Fonts
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+
20
+ # studio99-python
21
+
22
+ [![PyPI](https://img.shields.io/pypi/v/studio99)](https://pypi.org/project/studio99/)
23
+
24
+ Official Python client for the **Studio99 Indic Typography API**: exact Hindi, Marathi, Gujarati and English text as editable SVG and PNG, from real calligraphy fonts.
25
+
26
+ - Standard library only (no dependencies), Python 3.9+
27
+ - Server-side use: keep your API key on your server
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install studio99
33
+ ```
34
+
35
+ Get an API key at https://accounts.studio99.app/dashboard/products/studio99-api (Free plan: 100 credits a month, watermarked previews).
36
+
37
+ ## Quick start
38
+
39
+ ```python
40
+ from studio99 import Studio99
41
+
42
+ s99 = Studio99() # reads STUDIO99_API_KEY
43
+
44
+ res = s99.generate(
45
+ "shubh vivah", # Latin letters are transliterated; Devanagari/Gujarati work as-is
46
+ language="hindi",
47
+ use_case="wedding",
48
+ count=4, # 1 credit per variant
49
+ )
50
+
51
+ for i, variant in enumerate(res.data["generatedResults"], 1):
52
+ if variant.get("svg"):
53
+ with open(f"variant-{i}.svg", "w", encoding="utf-8") as f:
54
+ f.write(variant["svg"]["svgString"]) # complete, editable SVG
55
+
56
+ print("Credits left:", res.usage["remaining"])
57
+ ```
58
+
59
+ ## Methods
60
+
61
+ | Method | Endpoint | Cost |
62
+ |---|---|---|
63
+ | `generate(text, **options)` | `POST /generate` | 1 credit per variant |
64
+ | `render(text, font_id, font_size=, format=, png_width=)` | `POST /render` | 1 credit |
65
+ | `fonts(language=, mood=, use_case=, limit=)` | `GET /fonts` | free |
66
+ | `library.search(q, category=, language=, page=, limit=)` | `GET /library/search` | free |
67
+ | `library.get(id)` | `GET /library/{id}` | free |
68
+ | `library.download(id, format="PNG")` | `GET /library/{id}/download` | 1 credit |
69
+ | `library.render_svg(id)` | `GET /library/{id}/render-svg` | 1 credit |
70
+ | `capabilities()`, `health()` | meta | free |
71
+
72
+ Every method returns a `Response` with `.data`, `.usage` and `.rate_limit`. `generate` options use the API's own field names (`language`, `count`, `format`, `pngWidth`, `fontId`, `use_case`, `mood`, `align`, `lines`, `seed`, ...); see the [OpenAPI spec](https://github.com/studio99-app/openapi).
73
+
74
+ ### Exact rendering in one font
75
+
76
+ ```python
77
+ fonts = s99.fonts(language="marathi", mood="festive", limit=5).data["fonts"]
78
+ out = s99.render("दिवाळीच्या शुभेच्छा", fonts[0]["id"], format="svg").data
79
+ print(out["svgString"])
80
+ ```
81
+
82
+ ### Free plan
83
+
84
+ Free-plan calls return a small watermarked JPG in `preview` (no commercial licence) instead of `svg`/`png`.
85
+
86
+ ## Errors and retries
87
+
88
+ Failed calls raise `Studio99Error` with `.code` (API error code such as `INSUFFICIENT_CREDITS`, `TEXT_TOO_LONG`, `FONT_NOT_FOUND`), `.status` and `.rate_limit`.
89
+
90
+ `RATE_LIMIT_EXCEEDED` is retried after the reset time (rate-limited calls are not charged). GET requests are also retried on network errors and 502-504. `generate` and `render` calls that may have reached the engine are **never** retried automatically, so you are never charged twice. Tune with `Studio99(max_retries=..., timeout=...)`.
91
+
92
+ ## Keep your key safe
93
+
94
+ Keep it in an environment variable on your server; never commit it or put it in a browser or mobile app. `repr(client)` never prints the key. A revoked key stops working within a minute.
95
+
96
+ ## Links
97
+
98
+ [Docs](https://studio99.app/developers/docs) · [Pricing](https://studio99.app/developers/pricing) · [Changelog](https://studio99.app/developers/changelog) · [Status](https://studio99.app/developers/status) · [API terms](https://studio99.app/developers/terms) · [Examples](https://github.com/studio99-app/examples)
99
+
100
+ ## Licence
101
+
102
+ This client: MIT. The API, its fonts and the artwork it returns are governed by the [API terms](https://studio99.app/developers/terms); the fonts are proprietary and never leave our servers.
103
+
104
+ Built by [ArtoMania Studio](https://artomaniastudio.com), Pune · reach@studio99.app
@@ -0,0 +1,85 @@
1
+ # studio99-python
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/studio99)](https://pypi.org/project/studio99/)
4
+
5
+ Official Python client for the **Studio99 Indic Typography API**: exact Hindi, Marathi, Gujarati and English text as editable SVG and PNG, from real calligraphy fonts.
6
+
7
+ - Standard library only (no dependencies), Python 3.9+
8
+ - Server-side use: keep your API key on your server
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install studio99
14
+ ```
15
+
16
+ Get an API key at https://accounts.studio99.app/dashboard/products/studio99-api (Free plan: 100 credits a month, watermarked previews).
17
+
18
+ ## Quick start
19
+
20
+ ```python
21
+ from studio99 import Studio99
22
+
23
+ s99 = Studio99() # reads STUDIO99_API_KEY
24
+
25
+ res = s99.generate(
26
+ "shubh vivah", # Latin letters are transliterated; Devanagari/Gujarati work as-is
27
+ language="hindi",
28
+ use_case="wedding",
29
+ count=4, # 1 credit per variant
30
+ )
31
+
32
+ for i, variant in enumerate(res.data["generatedResults"], 1):
33
+ if variant.get("svg"):
34
+ with open(f"variant-{i}.svg", "w", encoding="utf-8") as f:
35
+ f.write(variant["svg"]["svgString"]) # complete, editable SVG
36
+
37
+ print("Credits left:", res.usage["remaining"])
38
+ ```
39
+
40
+ ## Methods
41
+
42
+ | Method | Endpoint | Cost |
43
+ |---|---|---|
44
+ | `generate(text, **options)` | `POST /generate` | 1 credit per variant |
45
+ | `render(text, font_id, font_size=, format=, png_width=)` | `POST /render` | 1 credit |
46
+ | `fonts(language=, mood=, use_case=, limit=)` | `GET /fonts` | free |
47
+ | `library.search(q, category=, language=, page=, limit=)` | `GET /library/search` | free |
48
+ | `library.get(id)` | `GET /library/{id}` | free |
49
+ | `library.download(id, format="PNG")` | `GET /library/{id}/download` | 1 credit |
50
+ | `library.render_svg(id)` | `GET /library/{id}/render-svg` | 1 credit |
51
+ | `capabilities()`, `health()` | meta | free |
52
+
53
+ Every method returns a `Response` with `.data`, `.usage` and `.rate_limit`. `generate` options use the API's own field names (`language`, `count`, `format`, `pngWidth`, `fontId`, `use_case`, `mood`, `align`, `lines`, `seed`, ...); see the [OpenAPI spec](https://github.com/studio99-app/openapi).
54
+
55
+ ### Exact rendering in one font
56
+
57
+ ```python
58
+ fonts = s99.fonts(language="marathi", mood="festive", limit=5).data["fonts"]
59
+ out = s99.render("दिवाळीच्या शुभेच्छा", fonts[0]["id"], format="svg").data
60
+ print(out["svgString"])
61
+ ```
62
+
63
+ ### Free plan
64
+
65
+ Free-plan calls return a small watermarked JPG in `preview` (no commercial licence) instead of `svg`/`png`.
66
+
67
+ ## Errors and retries
68
+
69
+ Failed calls raise `Studio99Error` with `.code` (API error code such as `INSUFFICIENT_CREDITS`, `TEXT_TOO_LONG`, `FONT_NOT_FOUND`), `.status` and `.rate_limit`.
70
+
71
+ `RATE_LIMIT_EXCEEDED` is retried after the reset time (rate-limited calls are not charged). GET requests are also retried on network errors and 502-504. `generate` and `render` calls that may have reached the engine are **never** retried automatically, so you are never charged twice. Tune with `Studio99(max_retries=..., timeout=...)`.
72
+
73
+ ## Keep your key safe
74
+
75
+ Keep it in an environment variable on your server; never commit it or put it in a browser or mobile app. `repr(client)` never prints the key. A revoked key stops working within a minute.
76
+
77
+ ## Links
78
+
79
+ [Docs](https://studio99.app/developers/docs) · [Pricing](https://studio99.app/developers/pricing) · [Changelog](https://studio99.app/developers/changelog) · [Status](https://studio99.app/developers/status) · [API terms](https://studio99.app/developers/terms) · [Examples](https://github.com/studio99-app/examples)
80
+
81
+ ## Licence
82
+
83
+ This client: MIT. The API, its fonts and the artwork it returns are governed by the [API terms](https://studio99.app/developers/terms); the fonts are proprietary and never leave our servers.
84
+
85
+ Built by [ArtoMania Studio](https://artomaniastudio.com), Pune · reach@studio99.app
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "studio99"
7
+ version = "0.1.0"
8
+ description = "Official Python client for the Studio99 Indic Typography API: exact Hindi, Marathi, Gujarati and English typography as editable SVG and PNG."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [{ name = "ArtoMania Studio Private Limited", email = "reach@studio99.app" }]
14
+ keywords = ["studio99", "hindi", "marathi", "gujarati", "devanagari", "calligraphy", "typography", "indic", "svg"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: OS Independent",
18
+ "Topic :: Text Processing :: Fonts",
19
+ "Topic :: Multimedia :: Graphics",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.urls]
24
+ Homepage = "https://studio99.app/developers"
25
+ Documentation = "https://studio99.app/developers/docs"
26
+ Source = "https://github.com/studio99-app/studio99-python"
27
+ Changelog = "https://studio99.app/developers/changelog"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/studio99"]
@@ -0,0 +1,5 @@
1
+ """Official Python client for the Studio99 Indic Typography API."""
2
+
3
+ from .client import DEFAULT_BASE_URL, RateLimit, Response, Studio99, Studio99Error, __version__
4
+
5
+ __all__ = ["Studio99", "Studio99Error", "Response", "RateLimit", "DEFAULT_BASE_URL", "__version__"]
@@ -0,0 +1,211 @@
1
+ """Client for the Studio99 Indic Typography API (v1).
2
+
3
+ Standard library only. Server-side only: never ship your API key to a browser or app.
4
+ Docs: https://studio99.app/developers/docs
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import random
12
+ import socket
13
+ import time
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, Dict, Generic, Mapping, Optional, TypeVar
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ DEFAULT_BASE_URL = "https://studio99.app/api/v1"
23
+
24
+ T = TypeVar("T")
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class RateLimit:
29
+ limit: Optional[int] = None
30
+ remaining: Optional[int] = None
31
+ reset: Optional[int] = None # unix seconds
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Response(Generic[T]):
36
+ """What every method returns: the payload plus the metering that came with it."""
37
+
38
+ data: T
39
+ usage: Optional[Dict[str, Any]] = None
40
+ rate_limit: RateLimit = field(default_factory=RateLimit)
41
+
42
+
43
+ class Studio99Error(Exception):
44
+ """A failed call. ``code`` is the API error code, e.g. ``INSUFFICIENT_CREDITS``."""
45
+
46
+ def __init__(self, message: str, code: str, status: int, rate_limit: Optional[RateLimit] = None):
47
+ super().__init__(f"{code}: {message}")
48
+ self.message = message
49
+ self.code = code
50
+ self.status = status # HTTP status, 0 for network/timeout errors
51
+ self.rate_limit = rate_limit
52
+
53
+
54
+ class _Library:
55
+ def __init__(self, client: "Studio99"):
56
+ self._c = client
57
+
58
+ def search(self, q: Optional[str] = None, *, category: Optional[str] = None, language: Optional[str] = None,
59
+ page: Optional[int] = None, limit: Optional[int] = None) -> Response[Dict[str, Any]]:
60
+ """Free read."""
61
+ return self._c._request("GET", "/library/search",
62
+ query={"q": q, "category": category, "language": language, "page": page, "limit": limit})
63
+
64
+ def get(self, id: str) -> Response[Dict[str, Any]]:
65
+ """Free read. ``id`` may be the id, shortId or slug."""
66
+ return self._c._request("GET", f"/library/{urllib.parse.quote(id, safe='')}")
67
+
68
+ def download(self, id: str, format: str = "PNG") -> Response[Dict[str, Any]]:
69
+ """1 credit. Returns a signed ``downloadUrl`` valid for 5 minutes."""
70
+ return self._c._request("GET", f"/library/{urllib.parse.quote(id, safe='')}/download", query={"format": format})
71
+
72
+ def render_svg(self, id: str) -> Response[Dict[str, Any]]:
73
+ """1 credit. Only for artworks whose detail has ``canRenderSvg: true``."""
74
+ return self._c._request("GET", f"/library/{urllib.parse.quote(id, safe='')}/render-svg")
75
+
76
+
77
+ class Studio99:
78
+ """Studio99 Indic Typography API client.
79
+
80
+ >>> s99 = Studio99() # reads STUDIO99_API_KEY
81
+ >>> res = s99.generate("shubh vivah", language="hindi", use_case="wedding", count=4)
82
+ >>> res.data["generatedResults"][0]["svg"]["svgString"]
83
+ """
84
+
85
+ def __init__(self, api_key: Optional[str] = None, *, base_url: str = DEFAULT_BASE_URL,
86
+ timeout: float = 60.0, max_retries: int = 2):
87
+ key = api_key or os.environ.get("STUDIO99_API_KEY")
88
+ if not key:
89
+ raise ValueError("Studio99: missing API key. Pass api_key=... or set STUDIO99_API_KEY.")
90
+ self._api_key = key
91
+ self._base_url = base_url.rstrip("/")
92
+ self._timeout = timeout
93
+ self._max_retries = max_retries
94
+ self.library = _Library(self)
95
+
96
+ def __repr__(self) -> str: # never print the key
97
+ return f"Studio99(base_url={self._base_url!r})"
98
+
99
+ # ---- metered -----------------------------------------------------------
100
+
101
+ def generate(self, text: str, **options: Any) -> Response[Dict[str, Any]]:
102
+ """Styled calligraphy variants of ``text``. 1 credit per variant returned.
103
+
104
+ Options (all optional): language, count, format ("svg" | "png"), pngWidth, fontId,
105
+ use_case, mood, engine, align, lines, lineGap, recipe, seed.
106
+ """
107
+ return self._request("POST", "/generate", body={"text": text, **options})
108
+
109
+ def render(self, text: str, font_id: str, *, font_size: Optional[float] = None,
110
+ format: Optional[str] = None, png_width: Optional[int] = None) -> Response[Dict[str, Any]]:
111
+ """``text`` in one exact font. Deterministic. 1 credit."""
112
+ body: Dict[str, Any] = {"text": text, "fontId": font_id}
113
+ if font_size is not None:
114
+ body["fontSize"] = font_size
115
+ if format is not None:
116
+ body["format"] = format
117
+ if png_width is not None:
118
+ body["pngWidth"] = png_width
119
+ return self._request("POST", "/render", body=body)
120
+
121
+ # ---- free reads ---------------------------------------------------------
122
+
123
+ def fonts(self, *, language: Optional[str] = None, mood: Optional[str] = None,
124
+ use_case: Optional[str] = None, limit: Optional[int] = None) -> Response[Dict[str, Any]]:
125
+ """The curated font slate. Free read."""
126
+ return self._request("GET", "/fonts", query={"language": language, "mood": mood, "use_case": use_case, "limit": limit})
127
+
128
+ def capabilities(self) -> Response[Dict[str, Any]]:
129
+ return self._request("GET", "/capabilities")
130
+
131
+ def health(self) -> Response[Dict[str, Any]]:
132
+ return self._request("GET", "/health", unwrapped=True)
133
+
134
+ # ---- transport ----------------------------------------------------------
135
+
136
+ def _request(self, method: str, path: str, *, query: Optional[Mapping[str, Any]] = None,
137
+ body: Optional[Mapping[str, Any]] = None, unwrapped: bool = False) -> Response[Any]:
138
+ url = self._base_url + path
139
+ params = {k: v for k, v in (query or {}).items() if v is not None and v != ""}
140
+ if params:
141
+ url += "?" + urllib.parse.urlencode(params)
142
+ headers = {
143
+ "X-API-Key": self._api_key,
144
+ "Accept": "application/json",
145
+ "User-Agent": f"studio99-python/{__version__}",
146
+ }
147
+ data = None
148
+ if body is not None:
149
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8")
150
+ headers["Content-Type"] = "application/json"
151
+ idempotent = method == "GET"
152
+
153
+ attempt = 0
154
+ while True:
155
+ can_retry = attempt < self._max_retries
156
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
157
+ try:
158
+ with urllib.request.urlopen(req, timeout=self._timeout) as res:
159
+ status, raw, hdrs = res.status, res.read(), res.headers
160
+ except urllib.error.HTTPError as e:
161
+ status, raw, hdrs = e.code, e.read(), e.headers
162
+ except (urllib.error.URLError, socket.timeout, TimeoutError, ConnectionError) as e:
163
+ # A POST may have reached the engine: never retry it, so nobody is charged twice.
164
+ if idempotent and can_retry:
165
+ time.sleep(_backoff(attempt))
166
+ attempt += 1
167
+ continue
168
+ timed_out = isinstance(e, (socket.timeout, TimeoutError)) or "timed out" in str(e)
169
+ raise Studio99Error(str(e), "TIMEOUT" if timed_out else "NETWORK_ERROR", 0) from None
170
+
171
+ rate_limit = _rate_limit(hdrs)
172
+ try:
173
+ payload = json.loads(raw.decode("utf-8")) if raw else None
174
+ except ValueError:
175
+ payload = None
176
+
177
+ ok = 200 <= status < 300
178
+ if ok and unwrapped and payload is not None:
179
+ return Response(data=payload, rate_limit=rate_limit)
180
+ if ok and isinstance(payload, dict) and payload.get("success"):
181
+ return Response(data=payload.get("data"), usage=payload.get("usage"), rate_limit=rate_limit)
182
+
183
+ err = (payload or {}).get("error") if isinstance(payload, dict) else None
184
+ code = (err or {}).get("code") or "UNKNOWN"
185
+ message = (err or {}).get("message") or f"HTTP {status}"
186
+
187
+ if can_retry and status == 429 and code == "RATE_LIMIT_EXCEEDED":
188
+ wait = (rate_limit.reset - time.time() + 0.25) if rate_limit.reset else _backoff(attempt)
189
+ time.sleep(min(max(wait, 0.25), 60.0))
190
+ attempt += 1
191
+ continue
192
+ if can_retry and idempotent and 502 <= status <= 504:
193
+ time.sleep(_backoff(attempt))
194
+ attempt += 1
195
+ continue
196
+ raise Studio99Error(message, code, status, rate_limit)
197
+
198
+
199
+ def _rate_limit(headers: Any) -> RateLimit:
200
+ def num(name: str) -> Optional[int]:
201
+ v = headers.get(name) if headers is not None else None
202
+ try:
203
+ return int(v) if v is not None else None
204
+ except ValueError:
205
+ return None
206
+
207
+ return RateLimit(num("X-RateLimit-Limit"), num("X-RateLimit-Remaining"), num("X-RateLimit-Reset"))
208
+
209
+
210
+ def _backoff(attempt: int) -> float:
211
+ return 0.5 * (2 ** attempt) + random.random() * 0.25
@@ -0,0 +1,128 @@
1
+ """Offline tests: urlopen is replaced by a fake. Run: python -m unittest discover -s tests"""
2
+
3
+ import io
4
+ import json
5
+ import os
6
+ import sys
7
+ import unittest
8
+ import urllib.error
9
+ from email.message import Message
10
+ from unittest import mock
11
+
12
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
13
+
14
+ from studio99 import Studio99, Studio99Error # noqa: E402
15
+
16
+
17
+ def _headers(d=None):
18
+ m = Message()
19
+ for k, v in (d or {}).items():
20
+ m[k] = v
21
+ return m
22
+
23
+
24
+ class _Resp:
25
+ def __init__(self, body, status=200, headers=None):
26
+ self.status = status
27
+ self._raw = json.dumps(body).encode()
28
+ self.headers = _headers(headers)
29
+
30
+ def read(self):
31
+ return self._raw
32
+
33
+ def __enter__(self):
34
+ return self
35
+
36
+ def __exit__(self, *a):
37
+ return False
38
+
39
+
40
+ def _http_error(status, body, headers=None):
41
+ return urllib.error.HTTPError("u", status, "err", _headers(headers), io.BytesIO(json.dumps(body).encode()))
42
+
43
+
44
+ class Fake:
45
+ def __init__(self, *responses):
46
+ self.responses = list(responses)
47
+ self.calls = []
48
+
49
+ def __call__(self, req, timeout=None):
50
+ self.calls.append(req)
51
+ r = self.responses.pop(0)
52
+ if isinstance(r, BaseException):
53
+ raise r
54
+ return r
55
+
56
+
57
+ class ClientTests(unittest.TestCase):
58
+ def test_generate_sends_key_and_body(self):
59
+ fake = Fake(_Resp({"success": True, "data": {"generatedResults": [], "metadata": {}},
60
+ "usage": {"monthlyUsed": 4, "monthlyLimit": 100, "remaining": 96}},
61
+ headers={"X-RateLimit-Limit": "5", "X-RateLimit-Remaining": "4", "X-RateLimit-Reset": "1790000000"}))
62
+ with mock.patch("urllib.request.urlopen", fake):
63
+ res = Studio99("k_test").generate("shubh vivah", language="hindi", count=4)
64
+ req = fake.calls[0]
65
+ self.assertEqual(req.full_url, "https://studio99.app/api/v1/generate")
66
+ self.assertEqual(req.get_method(), "POST")
67
+ self.assertEqual(req.get_header("X-api-key"), "k_test")
68
+ self.assertEqual(json.loads(req.data), {"text": "shubh vivah", "language": "hindi", "count": 4})
69
+ self.assertEqual(res.usage["remaining"], 96)
70
+ self.assertEqual(res.rate_limit.remaining, 4)
71
+
72
+ def test_unicode_body_is_utf8(self):
73
+ fake = Fake(_Resp({"success": True, "data": {}}))
74
+ with mock.patch("urllib.request.urlopen", fake):
75
+ Studio99("k").render("शुभ विवाह", "font-1", font_size=96)
76
+ self.assertEqual(json.loads(fake.calls[0].data.decode("utf-8"))["text"], "शुभ विवाह")
77
+
78
+ def test_query_skips_none(self):
79
+ fake = Fake(_Resp({"success": True, "data": {"fonts": []}}))
80
+ with mock.patch("urllib.request.urlopen", fake):
81
+ Studio99("k").fonts(language="marathi", limit=10)
82
+ self.assertEqual(fake.calls[0].full_url, "https://studio99.app/api/v1/fonts?language=marathi&limit=10")
83
+
84
+ def test_api_error_not_retried_for_credits(self):
85
+ fake = Fake(_http_error(429, {"success": False, "error": {"code": "INSUFFICIENT_CREDITS", "message": "used up"}}))
86
+ with mock.patch("urllib.request.urlopen", fake):
87
+ with self.assertRaises(Studio99Error) as ctx:
88
+ Studio99("k").generate("x")
89
+ self.assertEqual(ctx.exception.code, "INSUFFICIENT_CREDITS")
90
+ self.assertEqual(ctx.exception.status, 429)
91
+ self.assertEqual(len(fake.calls), 1)
92
+
93
+ def test_rate_limit_is_retried(self):
94
+ fake = Fake(_http_error(429, {"success": False, "error": {"code": "RATE_LIMIT_EXCEEDED", "message": "slow"}}),
95
+ _Resp({"success": True, "data": {"fonts": []}}))
96
+ with mock.patch("urllib.request.urlopen", fake), mock.patch("time.sleep"):
97
+ res = Studio99("k").fonts()
98
+ self.assertEqual(res.data, {"fonts": []})
99
+ self.assertEqual(len(fake.calls), 2)
100
+
101
+ def test_post_network_error_not_retried(self):
102
+ fake = Fake(urllib.error.URLError("reset"), _Resp({"success": True, "data": {}}))
103
+ with mock.patch("urllib.request.urlopen", fake):
104
+ with self.assertRaises(Studio99Error) as ctx:
105
+ Studio99("k").generate("x")
106
+ self.assertEqual(ctx.exception.code, "NETWORK_ERROR")
107
+ self.assertEqual(len(fake.calls), 1)
108
+
109
+ def test_get_network_error_is_retried(self):
110
+ fake = Fake(urllib.error.URLError("reset"), _Resp({"success": True, "data": {"fonts": []}}))
111
+ with mock.patch("urllib.request.urlopen", fake), mock.patch("time.sleep"):
112
+ Studio99("k").fonts()
113
+ self.assertEqual(len(fake.calls), 2)
114
+
115
+ def test_health_unwrapped(self):
116
+ fake = Fake(_Resp({"status": "ok", "version": "1.2", "product": "Studio99 Indic Typography API"}))
117
+ with mock.patch("urllib.request.urlopen", fake):
118
+ self.assertEqual(Studio99("k").health().data["version"], "1.2")
119
+
120
+ def test_requires_key_and_hides_it(self):
121
+ with mock.patch.dict(os.environ, {}, clear=True):
122
+ with self.assertRaises(ValueError):
123
+ Studio99()
124
+ self.assertNotIn("secret", repr(Studio99("secret")))
125
+
126
+
127
+ if __name__ == "__main__":
128
+ unittest.main()