heroshots 0.2.2__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.
- heroshots-0.2.2/LICENSE +21 -0
- heroshots-0.2.2/PKG-INFO +104 -0
- heroshots-0.2.2/README.md +87 -0
- heroshots-0.2.2/pyproject.toml +26 -0
- heroshots-0.2.2/setup.cfg +4 -0
- heroshots-0.2.2/src/heroshots/__init__.py +48 -0
- heroshots-0.2.2/src/heroshots/_polling.py +66 -0
- heroshots-0.2.2/src/heroshots/client.py +205 -0
- heroshots-0.2.2/src/heroshots/errors.py +153 -0
- heroshots-0.2.2/src/heroshots/models.py +34 -0
- heroshots-0.2.2/src/heroshots/resources/__init__.py +46 -0
- heroshots-0.2.2/src/heroshots/resources/avatars.py +25 -0
- heroshots-0.2.2/src/heroshots/resources/images.py +74 -0
- heroshots-0.2.2/src/heroshots/resources/longvideo.py +61 -0
- heroshots-0.2.2/src/heroshots/resources/stilllife.py +71 -0
- heroshots-0.2.2/src/heroshots/resources/storyboard.py +158 -0
- heroshots-0.2.2/src/heroshots/resources/uploads.py +30 -0
- heroshots-0.2.2/src/heroshots/resources/videos.py +42 -0
- heroshots-0.2.2/src/heroshots.egg-info/PKG-INFO +104 -0
- heroshots-0.2.2/src/heroshots.egg-info/SOURCES.txt +23 -0
- heroshots-0.2.2/src/heroshots.egg-info/dependency_links.txt +1 -0
- heroshots-0.2.2/src/heroshots.egg-info/requires.txt +5 -0
- heroshots-0.2.2/src/heroshots.egg-info/top_level.txt +1 -0
- heroshots-0.2.2/tests/test_client.py +126 -0
- heroshots-0.2.2/tests/test_resources.py +124 -0
heroshots-0.2.2/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jason Priore
|
|
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.
|
heroshots-0.2.2/PKG-INFO
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: heroshots
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Official Python SDK for the HeroShots / StudioHero platform (JWT auth).
|
|
5
|
+
Author: HeroShots
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://heroshots.ai
|
|
8
|
+
Project-URL: Repository, https://github.com/JasonPriore/HeroShotsSDK-Python
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: requests>=2.28
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
15
|
+
Requires-Dist: responses>=0.23; extra == "dev"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# heroshots (Python SDK)
|
|
19
|
+
|
|
20
|
+
High-level Python client for the HeroShots / StudioHero platform, authenticated
|
|
21
|
+
with a **portal JWT**. Covers the full JWT-reachable API surface.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install heroshots # from a registry
|
|
27
|
+
pip install -e ".[dev]" # local dev (runs the test suite)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Requires Python 3.9+. Only runtime dependency: `requests`.
|
|
31
|
+
|
|
32
|
+
## Authenticate
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from heroshots import Client
|
|
36
|
+
|
|
37
|
+
# Email/password login (recommended): stores the JWT, auto re-logins on expiry.
|
|
38
|
+
# Base URL defaults to https://app.heroshots.ai.
|
|
39
|
+
client = Client.from_login("dev@partner.com", "secret")
|
|
40
|
+
|
|
41
|
+
# Or reuse an existing token.
|
|
42
|
+
client = Client(token="eyJ...")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
# Local file paths are auto-uploaded to hosted URLs.
|
|
49
|
+
briefs = client.storyboard.brief_suggestions(
|
|
50
|
+
product_image_url="product.jpg", subject_image_url="model.jpg",
|
|
51
|
+
duration_seconds=30, language_code="it",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
candidates = client.storyboard.candidates(
|
|
55
|
+
custom_prompt=briefs[0], duration_seconds=30,
|
|
56
|
+
product_image_url="product.jpg", subject_image_url="model.jpg",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
job = client.storyboard.generate(
|
|
60
|
+
custom_prompt=briefs[0], duration_seconds=30, selected_candidate=candidates[0],
|
|
61
|
+
product_image_url="product.jpg", subject_image_url="model.jpg",
|
|
62
|
+
)
|
|
63
|
+
sb = client.storyboard.wait(job["job_id"]) # polls until done/failed
|
|
64
|
+
|
|
65
|
+
vjob = client.longvideo.start(image_url="product.jpg", storyboard=sb["storyboard"])
|
|
66
|
+
final = client.longvideo.wait(vjob["job_id"])
|
|
67
|
+
print(final["final_video_url"]) # also: final.final_video_url
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Responses support both `result["key"]` and `result.key` access. `/api/v1`
|
|
71
|
+
billing metadata is under `result["_meta"]`.
|
|
72
|
+
|
|
73
|
+
## Resources
|
|
74
|
+
|
|
75
|
+
`uploads` · `images` · `videos` · `longvideo` · `stilllife` · `storyboard` ·
|
|
76
|
+
`avatars`
|
|
77
|
+
|
|
78
|
+
Plus on the client: `login()`, `me()`, `balance()`.
|
|
79
|
+
|
|
80
|
+
Every method maps 1:1 to an endpoint; pass extra backend fields as keyword
|
|
81
|
+
arguments (they are forwarded verbatim).
|
|
82
|
+
|
|
83
|
+
## Errors
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from heroshots import ValidationError, TokenExpiredError, RateLimitError
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
client.storyboard.candidates(custom_prompt="", duration_seconds=30,
|
|
90
|
+
product_image_url="p.jpg")
|
|
91
|
+
except ValidationError as e:
|
|
92
|
+
print(e.code, e.message, e.details)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Test
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
pytest # offline, mocked HTTP
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Billing
|
|
102
|
+
|
|
103
|
+
Generations cost credits. Check remaining credits with `client.balance()`
|
|
104
|
+
(`hs_credits` + `plan.wallet_balance`). See `examples/full_storyboard_flow.py`.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# heroshots (Python SDK)
|
|
2
|
+
|
|
3
|
+
High-level Python client for the HeroShots / StudioHero platform, authenticated
|
|
4
|
+
with a **portal JWT**. Covers the full JWT-reachable API surface.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install heroshots # from a registry
|
|
10
|
+
pip install -e ".[dev]" # local dev (runs the test suite)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires Python 3.9+. Only runtime dependency: `requests`.
|
|
14
|
+
|
|
15
|
+
## Authenticate
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from heroshots import Client
|
|
19
|
+
|
|
20
|
+
# Email/password login (recommended): stores the JWT, auto re-logins on expiry.
|
|
21
|
+
# Base URL defaults to https://app.heroshots.ai.
|
|
22
|
+
client = Client.from_login("dev@partner.com", "secret")
|
|
23
|
+
|
|
24
|
+
# Or reuse an existing token.
|
|
25
|
+
client = Client(token="eyJ...")
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
# Local file paths are auto-uploaded to hosted URLs.
|
|
32
|
+
briefs = client.storyboard.brief_suggestions(
|
|
33
|
+
product_image_url="product.jpg", subject_image_url="model.jpg",
|
|
34
|
+
duration_seconds=30, language_code="it",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
candidates = client.storyboard.candidates(
|
|
38
|
+
custom_prompt=briefs[0], duration_seconds=30,
|
|
39
|
+
product_image_url="product.jpg", subject_image_url="model.jpg",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
job = client.storyboard.generate(
|
|
43
|
+
custom_prompt=briefs[0], duration_seconds=30, selected_candidate=candidates[0],
|
|
44
|
+
product_image_url="product.jpg", subject_image_url="model.jpg",
|
|
45
|
+
)
|
|
46
|
+
sb = client.storyboard.wait(job["job_id"]) # polls until done/failed
|
|
47
|
+
|
|
48
|
+
vjob = client.longvideo.start(image_url="product.jpg", storyboard=sb["storyboard"])
|
|
49
|
+
final = client.longvideo.wait(vjob["job_id"])
|
|
50
|
+
print(final["final_video_url"]) # also: final.final_video_url
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Responses support both `result["key"]` and `result.key` access. `/api/v1`
|
|
54
|
+
billing metadata is under `result["_meta"]`.
|
|
55
|
+
|
|
56
|
+
## Resources
|
|
57
|
+
|
|
58
|
+
`uploads` · `images` · `videos` · `longvideo` · `stilllife` · `storyboard` ·
|
|
59
|
+
`avatars`
|
|
60
|
+
|
|
61
|
+
Plus on the client: `login()`, `me()`, `balance()`.
|
|
62
|
+
|
|
63
|
+
Every method maps 1:1 to an endpoint; pass extra backend fields as keyword
|
|
64
|
+
arguments (they are forwarded verbatim).
|
|
65
|
+
|
|
66
|
+
## Errors
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from heroshots import ValidationError, TokenExpiredError, RateLimitError
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
client.storyboard.candidates(custom_prompt="", duration_seconds=30,
|
|
73
|
+
product_image_url="p.jpg")
|
|
74
|
+
except ValidationError as e:
|
|
75
|
+
print(e.code, e.message, e.details)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Test
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pytest # offline, mocked HTTP
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Billing
|
|
85
|
+
|
|
86
|
+
Generations cost credits. Check remaining credits with `client.balance()`
|
|
87
|
+
(`hs_credits` + `plan.wallet_balance`). See `examples/full_storyboard_flow.py`.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "heroshots"
|
|
7
|
+
version = "0.2.2"
|
|
8
|
+
description = "Official Python SDK for the HeroShots / StudioHero platform (JWT auth)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "HeroShots" }]
|
|
13
|
+
dependencies = ["requests>=2.28"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = ["pytest>=7", "responses>=0.23"]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://heroshots.ai"
|
|
20
|
+
Repository = "https://github.com/JasonPriore/HeroShotsSDK-Python"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["src"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""HeroShots SDK - high-level Python client for the StudioHero platform (JWT auth)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .client import HeroShotsClient
|
|
6
|
+
from .errors import (
|
|
7
|
+
ApiError,
|
|
8
|
+
AuthError,
|
|
9
|
+
ConflictError,
|
|
10
|
+
ForbiddenError,
|
|
11
|
+
HeroShotsError,
|
|
12
|
+
InternalError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
PayloadTooLargeError,
|
|
15
|
+
ProviderError,
|
|
16
|
+
QuotaExceededError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
ServiceUnavailableError,
|
|
19
|
+
TokenExpiredError,
|
|
20
|
+
ValidationError,
|
|
21
|
+
)
|
|
22
|
+
from ._polling import TimeoutError as PollTimeoutError
|
|
23
|
+
|
|
24
|
+
__version__ = "0.2.2"
|
|
25
|
+
|
|
26
|
+
# Friendly alias.
|
|
27
|
+
Client = HeroShotsClient
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Client",
|
|
31
|
+
"HeroShotsClient",
|
|
32
|
+
"HeroShotsError",
|
|
33
|
+
"ApiError",
|
|
34
|
+
"AuthError",
|
|
35
|
+
"TokenExpiredError",
|
|
36
|
+
"ValidationError",
|
|
37
|
+
"NotFoundError",
|
|
38
|
+
"ForbiddenError",
|
|
39
|
+
"ConflictError",
|
|
40
|
+
"QuotaExceededError",
|
|
41
|
+
"PayloadTooLargeError",
|
|
42
|
+
"ProviderError",
|
|
43
|
+
"ServiceUnavailableError",
|
|
44
|
+
"RateLimitError",
|
|
45
|
+
"InternalError",
|
|
46
|
+
"PollTimeoutError",
|
|
47
|
+
"__version__",
|
|
48
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Generic async-job polling helper.
|
|
2
|
+
|
|
3
|
+
The backend has three async surfaces with **inconsistent** terminal-state names:
|
|
4
|
+
|
|
5
|
+
* storyboard jobs and long-video jobs: success = ``done``, fail = ``failed``
|
|
6
|
+
* single video / image status: success = ``succeeded``, fail = ``failed``
|
|
7
|
+
|
|
8
|
+
Each resource passes the right state sets to :func:`wait`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any, Callable, Iterable, Optional
|
|
15
|
+
|
|
16
|
+
from .errors import ProviderError, HeroShotsError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TimeoutError(HeroShotsError):
|
|
20
|
+
"""Polling exceeded the allotted timeout."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Default terminal states, lower-cased for comparison.
|
|
24
|
+
STORYBOARD_OK = ("done", "succeeded", "completed")
|
|
25
|
+
STORYBOARD_FAIL = ("failed", "error", "cancelled", "canceled")
|
|
26
|
+
MEDIA_OK = ("succeeded", "done", "completed")
|
|
27
|
+
MEDIA_FAIL = ("failed", "error", "cancelled", "canceled")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def wait(
|
|
31
|
+
get_fn: Callable[[], dict[str, Any]],
|
|
32
|
+
*,
|
|
33
|
+
terminal_ok: Iterable[str] = STORYBOARD_OK,
|
|
34
|
+
terminal_fail: Iterable[str] = STORYBOARD_FAIL,
|
|
35
|
+
status_key: str = "status",
|
|
36
|
+
interval: float = 3.0,
|
|
37
|
+
timeout: float = 600.0,
|
|
38
|
+
on_progress: Optional[Callable[[dict[str, Any]], None]] = None,
|
|
39
|
+
) -> dict[str, Any]:
|
|
40
|
+
"""Poll ``get_fn`` until the job reaches a terminal state.
|
|
41
|
+
|
|
42
|
+
Returns the final status payload on success. Raises :class:`ProviderError`
|
|
43
|
+
on a failed job and :class:`TimeoutError` if the deadline passes.
|
|
44
|
+
"""
|
|
45
|
+
ok = {s.lower() for s in terminal_ok}
|
|
46
|
+
fail = {s.lower() for s in terminal_fail}
|
|
47
|
+
deadline = time.monotonic() + timeout
|
|
48
|
+
while True:
|
|
49
|
+
payload = get_fn()
|
|
50
|
+
if on_progress is not None:
|
|
51
|
+
on_progress(payload)
|
|
52
|
+
status = str(payload.get(status_key) or "").lower()
|
|
53
|
+
if status in ok:
|
|
54
|
+
return payload
|
|
55
|
+
if status in fail:
|
|
56
|
+
err = payload.get("error") or {}
|
|
57
|
+
if isinstance(err, dict):
|
|
58
|
+
raise ProviderError(
|
|
59
|
+
err.get("message") or f"job {status}",
|
|
60
|
+
code=err.get("code") or "provider_error",
|
|
61
|
+
details=err if err else None,
|
|
62
|
+
)
|
|
63
|
+
raise ProviderError(str(err) or f"job {status}", code="provider_error")
|
|
64
|
+
if time.monotonic() >= deadline:
|
|
65
|
+
raise TimeoutError(f"job did not finish within {timeout:.0f}s (last status={status!r})")
|
|
66
|
+
time.sleep(interval)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""HeroShots API client.
|
|
2
|
+
|
|
3
|
+
Authenticates with a **portal JWT** (``Authorization: Bearer <token>``). The token
|
|
4
|
+
is obtained from ``POST /api/auth/login`` (HS256, 7-day TTL, no refresh endpoint -
|
|
5
|
+
re-login to renew). The client transparently handles the two response envelope
|
|
6
|
+
styles used by the backend:
|
|
7
|
+
|
|
8
|
+
* ``/api/v1/*`` -> ``{"success", "data", "meta"}`` -> unwrapped to ``data``
|
|
9
|
+
* everything else -> bare JSON, returned as-is
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from typing import Any, BinaryIO, Optional
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
from .errors import error_from_response, TokenExpiredError
|
|
20
|
+
from .models import wrap
|
|
21
|
+
from .resources.avatars import Avatars
|
|
22
|
+
from .resources.images import Images
|
|
23
|
+
from .resources.longvideo import LongVideo
|
|
24
|
+
from .resources.stilllife import StillLife
|
|
25
|
+
from .resources.storyboard import Storyboard
|
|
26
|
+
from .resources.uploads import Uploads
|
|
27
|
+
from .resources.videos import Videos
|
|
28
|
+
|
|
29
|
+
DEFAULT_TIMEOUT = 120.0
|
|
30
|
+
DEFAULT_BASE_URL = "https://app.heroshots.ai"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HeroShotsClient:
|
|
34
|
+
"""Entry point for the HeroShots API.
|
|
35
|
+
|
|
36
|
+
Example::
|
|
37
|
+
|
|
38
|
+
client = HeroShotsClient.from_login("dev@partner.com", "...")
|
|
39
|
+
client.storyboard.brief_suggestions(product_image_url="https://.../p.jpg")
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
base_url: Optional[str] = None,
|
|
45
|
+
token: Optional[str] = None,
|
|
46
|
+
*,
|
|
47
|
+
email: Optional[str] = None,
|
|
48
|
+
password: Optional[str] = None,
|
|
49
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
50
|
+
session: Optional[requests.Session] = None,
|
|
51
|
+
auto_relogin: bool = True,
|
|
52
|
+
) -> None:
|
|
53
|
+
base_url = base_url or os.environ.get("HEROSHOTS_BASE_URL") or DEFAULT_BASE_URL
|
|
54
|
+
self.base_url = base_url.rstrip("/")
|
|
55
|
+
self.token = token
|
|
56
|
+
self._email = email
|
|
57
|
+
self._password = password
|
|
58
|
+
self._auto_relogin = auto_relogin
|
|
59
|
+
self.timeout = timeout
|
|
60
|
+
self.session = session or requests.Session()
|
|
61
|
+
|
|
62
|
+
# Resource namespaces.
|
|
63
|
+
self.avatars = Avatars(self)
|
|
64
|
+
self.images = Images(self)
|
|
65
|
+
self.longvideo = LongVideo(self)
|
|
66
|
+
self.stilllife = StillLife(self)
|
|
67
|
+
self.storyboard = Storyboard(self)
|
|
68
|
+
self.uploads = Uploads(self)
|
|
69
|
+
self.videos = Videos(self)
|
|
70
|
+
|
|
71
|
+
# -- construction helpers -------------------------------------------------
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_login(
|
|
75
|
+
cls,
|
|
76
|
+
email: str,
|
|
77
|
+
password: str,
|
|
78
|
+
*,
|
|
79
|
+
base_url: Optional[str] = None,
|
|
80
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
81
|
+
auto_relogin: bool = True,
|
|
82
|
+
) -> "HeroShotsClient":
|
|
83
|
+
"""Create a client and log in with email/password in one step.
|
|
84
|
+
|
|
85
|
+
``base_url`` defaults to https://app.heroshots.ai.
|
|
86
|
+
"""
|
|
87
|
+
client = cls(
|
|
88
|
+
base_url,
|
|
89
|
+
email=email,
|
|
90
|
+
password=password,
|
|
91
|
+
timeout=timeout,
|
|
92
|
+
auto_relogin=auto_relogin,
|
|
93
|
+
)
|
|
94
|
+
client.login(email, password)
|
|
95
|
+
return client
|
|
96
|
+
|
|
97
|
+
# -- auth -----------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def login(self, email: str, password: str) -> dict[str, Any]:
|
|
100
|
+
"""POST /api/auth/login -> stores and returns the JWT + profile."""
|
|
101
|
+
data = self.request(
|
|
102
|
+
"POST",
|
|
103
|
+
"/api/auth/login",
|
|
104
|
+
json={"email": email, "password": password},
|
|
105
|
+
auth=False,
|
|
106
|
+
)
|
|
107
|
+
self.token = data.get("token")
|
|
108
|
+
self._email, self._password = email, password
|
|
109
|
+
return data
|
|
110
|
+
|
|
111
|
+
def me(self) -> dict[str, Any]:
|
|
112
|
+
"""GET /api/auth/me -> current authenticated profile."""
|
|
113
|
+
return self.request("GET", "/api/auth/me")
|
|
114
|
+
|
|
115
|
+
def balance(self) -> dict[str, Any]:
|
|
116
|
+
"""GET /api/auth/stats -> account balance.
|
|
117
|
+
|
|
118
|
+
Includes ``hs_credits``, ``total_credits_used`` and ``plan.wallet_balance``.
|
|
119
|
+
"""
|
|
120
|
+
return self.request("GET", "/api/auth/stats")
|
|
121
|
+
|
|
122
|
+
# -- transport ------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def request(
|
|
125
|
+
self,
|
|
126
|
+
method: str,
|
|
127
|
+
path: str,
|
|
128
|
+
*,
|
|
129
|
+
json: Optional[dict[str, Any]] = None,
|
|
130
|
+
params: Optional[dict[str, Any]] = None,
|
|
131
|
+
files: Optional[dict[str, Any]] = None,
|
|
132
|
+
data: Optional[dict[str, Any]] = None,
|
|
133
|
+
auth: bool = True,
|
|
134
|
+
unwrap: Optional[bool] = None,
|
|
135
|
+
_retried: bool = False,
|
|
136
|
+
) -> Any:
|
|
137
|
+
"""Perform an HTTP request and return decoded JSON (or raise ``ApiError``).
|
|
138
|
+
|
|
139
|
+
``unwrap`` controls envelope handling; when ``None`` it is inferred from the
|
|
140
|
+
path (``/api/v1/*`` responses are wrapped in ``{"success","data","meta"}``).
|
|
141
|
+
"""
|
|
142
|
+
url = self.base_url + path
|
|
143
|
+
headers: dict[str, str] = {}
|
|
144
|
+
if auth and self.token:
|
|
145
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
146
|
+
|
|
147
|
+
resp = self.session.request(
|
|
148
|
+
method.upper(),
|
|
149
|
+
url,
|
|
150
|
+
headers=headers,
|
|
151
|
+
json=json,
|
|
152
|
+
params=params,
|
|
153
|
+
files=files,
|
|
154
|
+
data=data,
|
|
155
|
+
timeout=self.timeout,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
body: Any = None
|
|
159
|
+
if resp.content:
|
|
160
|
+
try:
|
|
161
|
+
body = resp.json()
|
|
162
|
+
except ValueError:
|
|
163
|
+
body = None
|
|
164
|
+
|
|
165
|
+
if not resp.ok:
|
|
166
|
+
err = error_from_response(resp.status_code, body)
|
|
167
|
+
# One automatic re-login attempt on an expired token.
|
|
168
|
+
if (
|
|
169
|
+
isinstance(err, TokenExpiredError)
|
|
170
|
+
and self._auto_relogin
|
|
171
|
+
and not _retried
|
|
172
|
+
and self._email
|
|
173
|
+
and self._password
|
|
174
|
+
):
|
|
175
|
+
self.login(self._email, self._password)
|
|
176
|
+
return self.request(
|
|
177
|
+
method,
|
|
178
|
+
path,
|
|
179
|
+
json=json,
|
|
180
|
+
params=params,
|
|
181
|
+
files=files,
|
|
182
|
+
data=data,
|
|
183
|
+
auth=auth,
|
|
184
|
+
unwrap=unwrap,
|
|
185
|
+
_retried=True,
|
|
186
|
+
)
|
|
187
|
+
raise err
|
|
188
|
+
|
|
189
|
+
if unwrap is None:
|
|
190
|
+
unwrap = path.startswith("/api/v1/")
|
|
191
|
+
if unwrap and isinstance(body, dict) and "data" in body:
|
|
192
|
+
data_part = body.get("data")
|
|
193
|
+
meta = body.get("meta")
|
|
194
|
+
if isinstance(data_part, dict) and meta is not None:
|
|
195
|
+
# Surface billing/credits metadata without losing the typed shape.
|
|
196
|
+
data_part.setdefault("_meta", meta)
|
|
197
|
+
return wrap(data_part)
|
|
198
|
+
return wrap(body)
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def _multipart_file(file: "str | BinaryIO", field: str = "file") -> dict[str, Any]:
|
|
202
|
+
"""Build a ``files=`` dict for ``requests`` from a path or open file object."""
|
|
203
|
+
if isinstance(file, str):
|
|
204
|
+
return {field: open(file, "rb")} # noqa: SIM115 - requests closes it
|
|
205
|
+
return {field: file}
|