"""Castings API client — Python. A single dependency-free module (standard library only) for the public REST API at /api/v1. Drop it next to your code and import it: from castings_client import CastingsClient client = CastingsClient(os.environ["CASTINGS_TOKEN"]) for casting in client.castings(status="open"): print(casting["role_name"]) Requires Python 3.9+. License: MIT """ from __future__ import annotations import json import mimetypes import os import uuid from typing import Any, Dict, Iterator, List, Mapping, Optional, Sequence, Union from urllib.error import HTTPError, URLError from urllib.parse import urlencode from urllib.request import Request, urlopen #: Library version, sent as the User-Agent and shown on /docs/api. __version__ = "1.0.0" #: Release date of this version (ISO-8601), shown on /docs/api. __released__ = "2026-08-01" __all__ = [ "__version__", "__released__", "CastingsClient", "Page", "CastingsError", "CastingsAuthError", "CastingsForbiddenError", "CastingsNotFoundError", "CastingsDuplicateError", "CastingsValidationError", "CastingsRateLimitError", "CastingsTransportError", ] FilePaths = Union[str, os.PathLike, Sequence[Union[str, os.PathLike]]] class Page(List[Dict[str, Any]]): """One page of a list endpoint. Behaves like a list of records and carries the pagination state: page = client.talents(gender="female") for talent in page: ... page.total # 477 page.has_more # True """ def __init__(self, items: Sequence[Dict[str, Any]], meta: Mapping[str, Any]): super().__init__(items) self.meta: Dict[str, Any] = dict(meta) @property def page(self) -> int: return int(self.meta.get("page", 1)) @property def per_page(self) -> int: return int(self.meta.get("per_page", len(self))) @property def total(self) -> int: return int(self.meta.get("total", len(self))) @property def last_page(self) -> int: return int(self.meta.get("last_page", 1)) @property def has_more(self) -> bool: return self.page < self.last_page def __repr__(self) -> str: # pragma: no cover - debugging aid return f"" # ── Errors ────────────────────────────────────────────────────────────────── class CastingsError(Exception): """Base class for every error this client raises.""" def __init__(self, message: str, status: int = 0, response: Optional[Dict[str, Any]] = None): super().__init__(message) self.message = message self.status = status self.response: Dict[str, Any] = response or {} class CastingsAuthError(CastingsError): """The token is missing, wrong or revoked (401).""" class CastingsForbiddenError(CastingsError): """Your plan does not include this feature (403).""" class CastingsNotFoundError(CastingsError): """Unknown endpoint, or the record does not belong to your agency (404).""" class CastingsDuplicateError(CastingsError): """This applicant already applied (409). Call ``apply(..., update_existing=True)`` to refresh the stored application. """ @property def application_id(self) -> Optional[int]: value = (self.response.get("data") or {}).get("id") return None if value is None else int(value) class CastingsValidationError(CastingsError): """The payload failed validation (422).""" @property def errors(self) -> Dict[str, List[str]]: """Field name → list of messages.""" return self.response.get("errors") or {} def first_error(self, field: str) -> Optional[str]: messages = self.errors.get(field) or [] return messages[0] if messages else None class CastingsRateLimitError(CastingsError): """Rate limit or the plan's monthly application quota was reached (429).""" def __init__(self, message: str, status: int = 429, response: Optional[Dict[str, Any]] = None, retry_after: Optional[int] = None): super().__init__(message, status, response) self.retry_after = retry_after class CastingsTransportError(CastingsError): """The request never got a usable reply (DNS, TLS, timeout, malformed JSON).""" _ERRORS = { 401: CastingsAuthError, 403: CastingsForbiddenError, 404: CastingsNotFoundError, 409: CastingsDuplicateError, 422: CastingsValidationError, } # ── Client ────────────────────────────────────────────────────────────────── class CastingsClient: """Client for the Castings REST API. :param token: agency API token (Agency → Settings → API). :param base_url: API root, without a trailing slash. :param timeout: per-request timeout in seconds; uploads may need more. :param language: force a response language (en, uk, ru, pl, fr, it, de, es). """ def __init__( self, token: str, base_url: str = "https://castings.com.ua/api/v1", timeout: int = 30, language: Optional[str] = None, ): if not token: raise ValueError("An API token is required.") self.token = token self.base_url = base_url.rstrip("/") self.timeout = timeout self.language = language # ── Agency ────────────────────────────────────────────────────────────── def agency(self) -> Dict[str, Any]: """The agency behind the token: branding, contacts, plan features, usage.""" return self._get("/agency") def ping(self) -> bool: """True when the token is valid and the API is reachable.""" try: self.agency() return True except CastingsError: return False # ── Castings ──────────────────────────────────────────────────────────── def castings(self, **filters: Any) -> Page: """One page of published castings. Filters: status, gender, age, search, page, per_page. """ return self._get_page("/castings", filters) def iter_castings(self, **filters: Any) -> Iterator[Dict[str, Any]]: """Every casting matching the filters, walking the pages for you.""" yield from self._walk("/castings", filters) def casting(self, casting_id: int) -> Dict[str, Any]: return self._get(f"/castings/{casting_id}") def casting_fields(self, casting_id: int) -> Dict[str, Any]: """The application form definition: fields to render, caps, submit URL.""" return self._get(f"/castings/{casting_id}/fields") # ── Applications ──────────────────────────────────────────────────────── def apply( self, casting_id: int, fields: Mapping[str, Any], files: Optional[Mapping[str, FilePaths]] = None, update_existing: bool = False, ) -> Dict[str, Any]: """Submit an application to a casting. :param fields: field keys from :meth:`casting_fields`. :param files: local paths, e.g. ``{"photos": ["a.jpg", "b.jpg"], "video_demo_file": "reel.mp4"}``. :param update_existing: refresh a previous application from the same email instead of raising :class:`CastingsDuplicateError`. :raises CastingsDuplicateError: this email already applied. :raises CastingsValidationError: the payload is incomplete or malformed. """ payload = dict(fields) if update_existing: payload["update_existing"] = 1 return self._post(f"/castings/{casting_id}/applications", payload, files or {}) def applications(self, **filters: Any) -> Page: """One page of received applications. Filters: casting_id, status, source, since, page, per_page. """ return self._get_page("/applications", filters) def iter_applications(self, **filters: Any) -> Iterator[Dict[str, Any]]: yield from self._walk("/applications", filters) def application(self, application_id: int) -> Dict[str, Any]: return self._get(f"/applications/{application_id}") # ── Talents ───────────────────────────────────────────────────────────── def talents(self, **filters: Any) -> Page: """One page of your agency's talent database. Filters: search, gender, age_min, age_max, height_min, height_max, include="contacts", page, per_page. """ return self._get_page("/talents", filters) def iter_talents(self, **filters: Any) -> Iterator[Dict[str, Any]]: yield from self._walk("/talents", filters) def talent(self, talent_id: int, with_contacts: bool = False) -> Dict[str, Any]: params = {"include": "contacts"} if with_contacts else {} return self._get(f"/talents/{talent_id}", params) # ── Plumbing ──────────────────────────────────────────────────────────── def _get(self, path: str, params: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]: return self._request("GET", path, params or {}).get("data", {}) def _get_page(self, path: str, params: Mapping[str, Any]) -> Page: body = self._request("GET", path, params) return Page(body.get("data") or [], body.get("meta") or {}) def _post(self, path: str, fields: Mapping[str, Any], files: Mapping[str, FilePaths]) -> Dict[str, Any]: return self._request("POST", path, {}, fields, files).get("data", {}) def _walk(self, path: str, filters: Mapping[str, Any]) -> Iterator[Dict[str, Any]]: params = dict(filters) page_no = int(params.get("page", 1)) while True: params["page"] = page_no page = self._get_page(path, params) yield from page if not page.has_more: return page_no += 1 def _request( self, method: str, path: str, params: Mapping[str, Any], fields: Optional[Mapping[str, Any]] = None, files: Optional[Mapping[str, FilePaths]] = None, ) -> Dict[str, Any]: query = {k: v for k, v in dict(params).items() if v is not None} if self.language: query["lang"] = self.language url = self.base_url + path if query: url += "?" + urlencode(query) headers = { "Authorization": f"Bearer {self.token}", "Accept": "application/json", "User-Agent": f"castings-python/{__version__}", } body: Optional[bytes] = None if method == "POST": body, content_type = _encode_multipart(fields or {}, files or {}) headers["Content-Type"] = content_type request = Request(url, data=body, headers=headers, method=method) try: with urlopen(request, timeout=self.timeout) as response: return self._decode(response.read(), response.status) except HTTPError as exc: # 4xx / 5xx still carry a JSON body raw = exc.read() payload: Dict[str, Any] try: payload = json.loads(raw.decode("utf-8")) except (ValueError, UnicodeDecodeError): raise CastingsTransportError( f"The API returned a non-JSON response (HTTP {exc.code}).", exc.code ) from exc raise self._error_for(exc.code, payload, exc.headers.get("Retry-After")) from None except URLError as exc: raise CastingsTransportError(f"Request to {url} failed: {exc.reason}") from exc @staticmethod def _decode(raw: bytes, status: int) -> Dict[str, Any]: try: decoded = json.loads(raw.decode("utf-8")) except (ValueError, UnicodeDecodeError) as exc: raise CastingsTransportError( f"The API returned a non-JSON response (HTTP {status}).", status ) from exc if not isinstance(decoded, dict): raise CastingsTransportError(f"Unexpected response shape (HTTP {status}).", status) return decoded @staticmethod def _error_for(status: int, payload: Dict[str, Any], retry_after: Optional[str]) -> CastingsError: message = payload.get("message") or f"The API answered HTTP {status}." if status == 429: seconds = int(retry_after) if retry_after and retry_after.isdigit() else None return CastingsRateLimitError(message, status, payload, seconds) return _ERRORS.get(status, CastingsError)(message, status, payload) # ── multipart/form-data encoding (stdlib only) ────────────────────────────── def _encode_multipart(fields: Mapping[str, Any], files: Mapping[str, FilePaths]) -> "tuple[bytes, str]": """Build a multipart body. A sequence value becomes indexed parts (``photos[0]``, ``photos[1]``…) so PHP receives a proper array.""" boundary = uuid.uuid4().hex parts: List[bytes] = [] def add_field(name: str, value: Any) -> None: parts.append( f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n'.encode() + str(value).encode("utf-8") + b"\r\n" ) for name, value in fields.items(): if value is None: continue if isinstance(value, bool): value = int(value) if isinstance(value, (list, tuple)): for index, item in enumerate(value): add_field(f"{name}[{index}]", item) continue add_field(name, value) for name, paths in files.items(): is_many = isinstance(paths, (list, tuple)) for index, path in enumerate(paths if is_many else [paths]): path = os.fspath(path) if not os.path.isfile(path): raise CastingsTransportError(f"File not readable: {path}") with open(path, "rb") as handle: content = handle.read() field_name = f"{name}[{index}]" if is_many else name mime = mimetypes.guess_type(path)[0] or "application/octet-stream" parts.append( f'--{boundary}\r\nContent-Disposition: form-data; ' f'name="{field_name}"; filename="{os.path.basename(path)}"\r\n' f"Content-Type: {mime}\r\n\r\n".encode() + content + b"\r\n" ) parts.append(f"--{boundary}--\r\n".encode()) return b"".join(parts), f"multipart/form-data; boundary={boundary}"