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.
@@ -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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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}