creacortex 0.1.0__py3-none-any.whl

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.
creacortex/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """Creacortex — Python client for the video-creative attention-analysis API.
2
+
3
+ from creacortex import Client
4
+
5
+ with Client("ace_...") as cx:
6
+ run = cx.analyze("ad.mp4", with_audio=True) # upload + run + wait
7
+ cx.get_report(run.run_id, "diagnose") # PDF bytes
8
+ """
9
+ from .client import DEFAULT_BASE_URL, AsyncClient, Client
10
+ from .errors import (
11
+ ApiError,
12
+ AuthError,
13
+ CreacortexError,
14
+ InsufficientCreditsError,
15
+ NotFoundError,
16
+ RateLimitError,
17
+ RunFailedError,
18
+ WaitTimeout,
19
+ )
20
+ from .models import Account, Run, RunListItem
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ __all__ = [
25
+ "DEFAULT_BASE_URL",
26
+ "Client",
27
+ "AsyncClient",
28
+ "Run",
29
+ "RunListItem",
30
+ "Account",
31
+ "CreacortexError",
32
+ "ApiError",
33
+ "AuthError",
34
+ "InsufficientCreditsError",
35
+ "RateLimitError",
36
+ "NotFoundError",
37
+ "RunFailedError",
38
+ "WaitTimeout",
39
+ ]
creacortex/client.py ADDED
@@ -0,0 +1,213 @@
1
+ """Sync and async clients for the Creacortex API."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import os
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from .errors import RunFailedError, WaitTimeout, raise_for_status
13
+ from .models import Account, Run, RunListItem
14
+
15
+ DEFAULT_BASE_URL = "https://api.creacortex.ai"
16
+
17
+
18
+ def _build_files(file: Any, filename: str | None, content_type: str) -> dict[str, Any]:
19
+ if isinstance(file, (str, os.PathLike)):
20
+ p = Path(file)
21
+ return {"file": (filename or p.name, p.read_bytes(), content_type)}
22
+ if isinstance(file, (bytes, bytearray)):
23
+ return {"file": (filename or "video.mp4", bytes(file), content_type)}
24
+ name = filename or os.path.basename(getattr(file, "name", "") or "video.mp4")
25
+ return {"file": (name, file, content_type)}
26
+
27
+
28
+ class _Base:
29
+ def __init__(self, api_key: str, base_url: str) -> None:
30
+ if not api_key:
31
+ raise ValueError("api_key is required")
32
+ self._key = api_key
33
+ self._base = base_url.rstrip("/")
34
+
35
+ @property
36
+ def _headers(self) -> dict[str, str]:
37
+ return {"X-API-Key": self._key}
38
+
39
+ def _url(self, path: str) -> str:
40
+ return f"{self._base}{path}"
41
+
42
+ @staticmethod
43
+ def _run(resp: httpx.Response) -> Run:
44
+ raise_for_status(resp)
45
+ return Run.from_api(resp.json())
46
+
47
+ @staticmethod
48
+ def _runs(resp: httpx.Response) -> list[RunListItem]:
49
+ raise_for_status(resp)
50
+ return [RunListItem.from_api(x) for x in resp.json()]
51
+
52
+ @staticmethod
53
+ def _account(resp: httpx.Response) -> Account:
54
+ raise_for_status(resp)
55
+ return Account.from_api(resp.json())
56
+
57
+ @staticmethod
58
+ def _bytes(resp: httpx.Response) -> bytes:
59
+ raise_for_status(resp)
60
+ return resp.content
61
+
62
+ @staticmethod
63
+ def _json(resp: httpx.Response) -> Any:
64
+ raise_for_status(resp)
65
+ return resp.json()
66
+
67
+ @staticmethod
68
+ def _video_id(resp: httpx.Response) -> str:
69
+ raise_for_status(resp)
70
+ return str(resp.json()["video_id"])
71
+
72
+
73
+ class Client(_Base):
74
+ """Blocking client. Use as a context manager or call `close()` when done."""
75
+
76
+ def __init__(self, api_key: str, *, base_url: str = DEFAULT_BASE_URL,
77
+ timeout: float = 60.0, transport: httpx.BaseTransport | None = None) -> None:
78
+ super().__init__(api_key, base_url)
79
+ self._http = httpx.Client(timeout=timeout, transport=transport, headers=self._headers)
80
+
81
+ def __enter__(self) -> Client:
82
+ return self
83
+
84
+ def __exit__(self, *_exc: object) -> None:
85
+ self.close()
86
+
87
+ def close(self) -> None:
88
+ self._http.close()
89
+
90
+ def upload_video(self, file: Any, *, filename: str | None = None,
91
+ content_type: str = "video/mp4") -> str:
92
+ files = _build_files(file, filename, content_type)
93
+ return self._video_id(self._http.post(self._url("/v1/videos"), files=files))
94
+
95
+ def create_run(self, video_id: str, *, creative_id: str | None = None,
96
+ with_audio: bool = False, audience: str | None = None) -> Run:
97
+ body = {"video_id": video_id, "creative_id": creative_id,
98
+ "with_audio": with_audio, "audience": audience}
99
+ return self._run(self._http.post(self._url("/v1/runs"), json=body))
100
+
101
+ def get_run(self, run_id: str) -> Run:
102
+ return self._run(self._http.get(self._url(f"/v1/runs/{run_id}")))
103
+
104
+ def list_runs(self) -> list[RunListItem]:
105
+ return self._runs(self._http.get(self._url("/v1/runs")))
106
+
107
+ def get_account(self) -> Account:
108
+ return self._account(self._http.get(self._url("/v1/account")))
109
+
110
+ def get_csv(self, run_id: str) -> bytes:
111
+ """Per-second analysis CSV (raw bytes)."""
112
+ return self._bytes(self._http.get(self._url(f"/v1/runs/{run_id}/data.csv")))
113
+
114
+ def get_export(self, run_id: str) -> Any:
115
+ """Gen-AI payload (parsed JSON)."""
116
+ return self._json(self._http.get(self._url(f"/v1/runs/{run_id}/export.json")))
117
+
118
+ def get_report(self, run_id: str, kind: str = "diagnose") -> bytes:
119
+ """PDF report bytes. `kind` is 'diagnose' or 'analyze'."""
120
+ return self._bytes(self._http.get(
121
+ self._url(f"/v1/runs/{run_id}/report.pdf"), params={"kind": kind}))
122
+
123
+ def wait(self, run_id: str, *, interval: float = 5.0, timeout: float = 1800.0) -> Run:
124
+ """Poll until the run reaches a terminal state. Raises on failure/timeout."""
125
+ deadline = time.monotonic() + timeout
126
+ while True:
127
+ run = self.get_run(run_id)
128
+ if run.failed:
129
+ raise RunFailedError(run.run_id, run.error)
130
+ if run.done:
131
+ return run
132
+ if time.monotonic() >= deadline:
133
+ raise WaitTimeout(f"run {run_id} not done after {timeout:.0f}s")
134
+ time.sleep(interval)
135
+
136
+ def analyze(self, file: Any, *, creative_id: str | None = None, with_audio: bool = False,
137
+ audience: str | None = None, filename: str | None = None, wait: bool = True,
138
+ interval: float = 5.0, timeout: float = 1800.0) -> Run:
139
+ """Upload, start a run, and (by default) wait for it to finish. Returns the Run."""
140
+ video_id = self.upload_video(file, filename=filename)
141
+ run = self.create_run(video_id, creative_id=creative_id,
142
+ with_audio=with_audio, audience=audience)
143
+ return self.wait(run.run_id, interval=interval, timeout=timeout) if wait else run
144
+
145
+
146
+ class AsyncClient(_Base):
147
+ """Async client. Use `async with` or `await aclose()`."""
148
+
149
+ def __init__(self, api_key: str, *, base_url: str = DEFAULT_BASE_URL,
150
+ timeout: float = 60.0, transport: httpx.AsyncBaseTransport | None = None) -> None:
151
+ super().__init__(api_key, base_url)
152
+ self._http = httpx.AsyncClient(timeout=timeout, transport=transport, headers=self._headers)
153
+
154
+ async def __aenter__(self) -> AsyncClient:
155
+ return self
156
+
157
+ async def __aexit__(self, *_exc: object) -> None:
158
+ await self.aclose()
159
+
160
+ async def aclose(self) -> None:
161
+ await self._http.aclose()
162
+
163
+ async def upload_video(self, file: Any, *, filename: str | None = None,
164
+ content_type: str = "video/mp4") -> str:
165
+ files = _build_files(file, filename, content_type)
166
+ return self._video_id(await self._http.post(self._url("/v1/videos"), files=files))
167
+
168
+ async def create_run(self, video_id: str, *, creative_id: str | None = None,
169
+ with_audio: bool = False, audience: str | None = None) -> Run:
170
+ body = {"video_id": video_id, "creative_id": creative_id,
171
+ "with_audio": with_audio, "audience": audience}
172
+ return self._run(await self._http.post(self._url("/v1/runs"), json=body))
173
+
174
+ async def get_run(self, run_id: str) -> Run:
175
+ return self._run(await self._http.get(self._url(f"/v1/runs/{run_id}")))
176
+
177
+ async def list_runs(self) -> list[RunListItem]:
178
+ return self._runs(await self._http.get(self._url("/v1/runs")))
179
+
180
+ async def get_account(self) -> Account:
181
+ return self._account(await self._http.get(self._url("/v1/account")))
182
+
183
+ async def get_csv(self, run_id: str) -> bytes:
184
+ return self._bytes(await self._http.get(self._url(f"/v1/runs/{run_id}/data.csv")))
185
+
186
+ async def get_export(self, run_id: str) -> Any:
187
+ return self._json(await self._http.get(self._url(f"/v1/runs/{run_id}/export.json")))
188
+
189
+ async def get_report(self, run_id: str, kind: str = "diagnose") -> bytes:
190
+ return self._bytes(await self._http.get(
191
+ self._url(f"/v1/runs/{run_id}/report.pdf"), params={"kind": kind}))
192
+
193
+ async def wait(self, run_id: str, *, interval: float = 5.0, timeout: float = 1800.0) -> Run:
194
+ deadline = time.monotonic() + timeout
195
+ while True:
196
+ run = await self.get_run(run_id)
197
+ if run.failed:
198
+ raise RunFailedError(run.run_id, run.error)
199
+ if run.done:
200
+ return run
201
+ if time.monotonic() >= deadline:
202
+ raise WaitTimeout(f"run {run_id} not done after {timeout:.0f}s")
203
+ await asyncio.sleep(interval)
204
+
205
+ async def analyze(self, file: Any, *, creative_id: str | None = None, with_audio: bool = False,
206
+ audience: str | None = None, filename: str | None = None, wait: bool = True,
207
+ interval: float = 5.0, timeout: float = 1800.0) -> Run:
208
+ video_id = await self.upload_video(file, filename=filename)
209
+ run = await self.create_run(video_id, creative_id=creative_id,
210
+ with_audio=with_audio, audience=audience)
211
+ if wait:
212
+ return await self.wait(run.run_id, interval=interval, timeout=timeout)
213
+ return run
creacortex/errors.py ADDED
@@ -0,0 +1,78 @@
1
+ """Exceptions raised by the Creacortex client."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+
9
+ class CreacortexError(Exception):
10
+ """Base class for every error this library raises."""
11
+
12
+
13
+ class ApiError(CreacortexError):
14
+ """An HTTP error returned by the API (status >= 400)."""
15
+
16
+ def __init__(
17
+ self, status_code: int, message: str, *, response: httpx.Response | None = None,
18
+ ) -> None:
19
+ super().__init__(f"[{status_code}] {message}")
20
+ self.status_code = status_code
21
+ self.message = message
22
+ self.response = response
23
+
24
+
25
+ class AuthError(ApiError):
26
+ """Invalid or missing API key (401)."""
27
+
28
+
29
+ class InsufficientCreditsError(ApiError):
30
+ """The project does not have enough credits to run the analysis (402)."""
31
+
32
+
33
+ class RateLimitError(ApiError):
34
+ """Too many requests — slow down (429)."""
35
+
36
+
37
+ class NotFoundError(ApiError):
38
+ """The requested run (or resource) does not exist (404)."""
39
+
40
+
41
+ class RunFailedError(CreacortexError):
42
+ """The analysis run finished with status='failed'."""
43
+
44
+ def __init__(self, run_id: str, error: str | None) -> None:
45
+ super().__init__(f"run {run_id} failed: {error or 'unknown error'}")
46
+ self.run_id = run_id
47
+ self.error = error
48
+
49
+
50
+ class WaitTimeout(CreacortexError):
51
+ """A run did not reach a terminal state within the wait timeout."""
52
+
53
+
54
+ _STATUS_ERRORS: dict[int, type[ApiError]] = {
55
+ 401: AuthError,
56
+ 402: InsufficientCreditsError,
57
+ 404: NotFoundError,
58
+ 429: RateLimitError,
59
+ }
60
+
61
+
62
+ def raise_for_status(resp: httpx.Response) -> None:
63
+ """Map an error response to the appropriate typed exception."""
64
+ if resp.status_code < 400:
65
+ return
66
+ detail = _detail(resp)
67
+ cls = _STATUS_ERRORS.get(resp.status_code, ApiError)
68
+ raise cls(resp.status_code, detail, response=resp)
69
+
70
+
71
+ def _detail(resp: httpx.Response) -> str:
72
+ try:
73
+ body: Any = resp.json()
74
+ except Exception: # noqa: BLE001 — non-JSON error body
75
+ return resp.text or resp.reason_phrase
76
+ if isinstance(body, dict) and "detail" in body:
77
+ return str(body["detail"])
78
+ return str(body)
creacortex/models.py ADDED
@@ -0,0 +1,98 @@
1
+ """Typed response models. Plain dataclasses built from the API's JSON."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ # Run lifecycle states. `done` and `failed` are terminal.
9
+ QUEUED = "queued"
10
+ RUNNING = "running"
11
+ DONE = "done"
12
+ FAILED = "failed"
13
+ TERMINAL = frozenset({DONE, FAILED})
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Run:
18
+ """An analysis run and the URLs to its results once ready."""
19
+
20
+ run_id: str
21
+ status: str
22
+ creative_id: str | None = None
23
+ with_audio: bool = False
24
+ error: str | None = None
25
+ ready: bool = False
26
+ csv_url: str | None = None
27
+ export_url: str | None = None
28
+ diagnose_pdf_url: str | None = None
29
+ analyze_pdf_url: str | None = None
30
+
31
+ @property
32
+ def done(self) -> bool:
33
+ return self.status == DONE
34
+
35
+ @property
36
+ def failed(self) -> bool:
37
+ return self.status == FAILED
38
+
39
+ @property
40
+ def terminal(self) -> bool:
41
+ return self.status in TERMINAL
42
+
43
+ @classmethod
44
+ def from_api(cls, d: dict[str, Any]) -> Run:
45
+ return cls(
46
+ run_id=d["run_id"],
47
+ status=d["status"],
48
+ creative_id=d.get("creative_id"),
49
+ with_audio=bool(d.get("with_audio", False)),
50
+ error=d.get("error"),
51
+ ready=bool(d.get("ready", False)),
52
+ csv_url=d.get("csv_url"),
53
+ export_url=d.get("export_url"),
54
+ diagnose_pdf_url=d.get("diagnose_pdf_url"),
55
+ analyze_pdf_url=d.get("analyze_pdf_url"),
56
+ )
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class RunListItem:
61
+ """A run as returned by `list_runs` (lighter than `Run`)."""
62
+
63
+ run_id: str
64
+ status: str
65
+ creative_id: str | None
66
+ with_audio: bool
67
+ ready: bool
68
+ created_at: datetime
69
+
70
+ @classmethod
71
+ def from_api(cls, d: dict[str, Any]) -> RunListItem:
72
+ return cls(
73
+ run_id=d["run_id"],
74
+ status=d["status"],
75
+ creative_id=d.get("creative_id"),
76
+ with_audio=bool(d.get("with_audio", False)),
77
+ ready=bool(d.get("ready", False)),
78
+ created_at=datetime.fromisoformat(d["created_at"]),
79
+ )
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class Account:
84
+ """Credit balance for the API key's project."""
85
+
86
+ project: str | None
87
+ balance: float
88
+ quota: float
89
+ used: float
90
+
91
+ @classmethod
92
+ def from_api(cls, d: dict[str, Any]) -> Account:
93
+ return cls(
94
+ project=d.get("project"),
95
+ balance=float(d["balance"]),
96
+ quota=float(d["quota"]),
97
+ used=float(d.get("used", 0.0)),
98
+ )
creacortex/py.typed ADDED
File without changes
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: creacortex
3
+ Version: 0.1.0
4
+ Summary: Python client for the Creacortex video-creative attention-analysis API
5
+ Project-URL: Homepage, https://creacortex.ai
6
+ Author: Creacortex
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: advertising,analytics,api,attention,creacortex,video
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Requires-Python: >=3.9
15
+ Requires-Dist: httpx>=0.24
16
+ Provides-Extra: dev
17
+ Requires-Dist: mypy>=1.8; extra == 'dev'
18
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
19
+ Requires-Dist: pytest>=7; extra == 'dev'
20
+ Requires-Dist: ruff>=0.4; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # creacortex
24
+
25
+ Python client for the **Creacortex** video-creative attention-analysis API.
26
+
27
+ Upload a video creative, run it through the pipeline, and pull back per-second
28
+ attention data, a Gen-AI optimization payload, and PDF reports.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install creacortex
34
+ ```
35
+
36
+ ## Quick start
37
+
38
+ ```python
39
+ from creacortex import Client
40
+
41
+ with Client("ace_your_api_key") as cx:
42
+ # one call: upload + start + wait for completion
43
+ run = cx.analyze("ad.mp4", with_audio=True, creative_id="summer-promo")
44
+
45
+ csv = cx.get_csv(run.run_id) # per-second analysis (bytes)
46
+ payload = cx.get_export(run.run_id) # Gen-AI insights (dict)
47
+ pdf = cx.get_report(run.run_id, "diagnose") # PDF report (bytes)
48
+ ```
49
+
50
+ Get an API key from **Usage & billing → API keys** in the dashboard. It is sent
51
+ as the `X-API-Key` header.
52
+
53
+ ## Async
54
+
55
+ ```python
56
+ import asyncio
57
+ from creacortex import AsyncClient
58
+
59
+ async def main():
60
+ async with AsyncClient("ace_your_api_key") as cx:
61
+ run = await cx.analyze("ad.mp4")
62
+ print(await cx.get_export(run.run_id))
63
+
64
+ asyncio.run(main())
65
+ ```
66
+
67
+ ## Step by step
68
+
69
+ `analyze()` is a convenience over the raw endpoints, which you can also call directly:
70
+
71
+ ```python
72
+ video_id = cx.upload_video("ad.mp4")
73
+ run = cx.create_run(video_id, with_audio=True, audience="men 20-45, mobile gamers")
74
+ run = cx.wait(run.run_id) # poll until done (or RunFailedError / WaitTimeout)
75
+
76
+ for item in cx.list_runs():
77
+ print(item.run_id, item.status, item.ready)
78
+
79
+ print(cx.get_account().balance) # credits left
80
+ ```
81
+
82
+ `upload_video` / `analyze` accept a file path, raw `bytes`, or a binary file object.
83
+
84
+ ## Errors
85
+
86
+ | Exception | When |
87
+ |---|---|
88
+ | `AuthError` | invalid/missing API key (401) |
89
+ | `InsufficientCreditsError` | not enough credits (402) |
90
+ | `RateLimitError` | too many requests (429) |
91
+ | `NotFoundError` | unknown run (404) |
92
+ | `RunFailedError` | the run finished with `status="failed"` |
93
+ | `WaitTimeout` | `wait()` exceeded its timeout |
94
+ | `ApiError` | any other HTTP error (has `.status_code`) |
95
+
96
+ All inherit from `CreacortexError`.
97
+
98
+ ## Configuration
99
+
100
+ ```python
101
+ Client("ace_...", base_url="https://api.creacortex.ai", timeout=60.0)
102
+ ```
103
+
104
+ ## License
105
+
106
+ MIT
@@ -0,0 +1,9 @@
1
+ creacortex/__init__.py,sha256=MYqy4Y_EWDtllOiihSszSdv78J6AANTU_rs3aIydFNQ,892
2
+ creacortex/client.py,sha256=k1Iplv09wEg0Qvd8fPVztJJDij5oWZFTN9u0hA4QJFA,8801
3
+ creacortex/errors.py,sha256=OE8pp0JDmXsBz-ZAj9t6_x8mf5kBR_PSlfYu1vuDECE,2139
4
+ creacortex/models.py,sha256=Ju39mSXxWlQO3sQ_Fxw74mbOknN5lVcvyyBmbg5Z8RE,2590
5
+ creacortex/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ creacortex-0.1.0.dist-info/METADATA,sha256=1YoL3rxP49FCJDMVORljMA99oPREAI0l2gN1USEMCjo,2994
7
+ creacortex-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
8
+ creacortex-0.1.0.dist-info/licenses/LICENSE,sha256=Srvb_fua232oRVeo451wZ9Fp0EVZmdLcCKfPwt91zjc,1067
9
+ creacortex-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Creacortex
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.