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.
- studio99-0.1.0/.github/workflows/publish.yml +58 -0
- studio99-0.1.0/.gitignore +8 -0
- studio99-0.1.0/LICENSE +25 -0
- studio99-0.1.0/PKG-INFO +104 -0
- studio99-0.1.0/README.md +85 -0
- studio99-0.1.0/pyproject.toml +30 -0
- studio99-0.1.0/src/studio99/__init__.py +5 -0
- studio99-0.1.0/src/studio99/client.py +211 -0
- studio99-0.1.0/tests/test_client.py +128 -0
|
@@ -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
|
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.
|
studio99-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](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
|
studio99-0.1.0/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# studio99-python
|
|
2
|
+
|
|
3
|
+
[](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,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()
|