From 46148c51299a64786b21cbec0af804d11a4d2ec8 Mon Sep 17 00:00:00 2001 From: Erik Johansson Date: Wed, 23 Sep 2026 11:05:08 +0200 Subject: [PATCH 1/5] feat: add ftrack production tracking provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the full ProdtrackProviderBase surface over ftrack, alongside the existing ShotGrid and mock providers. Select with PRODTRACK_PROVIDER=ftrack. Three decisions shape the implementation: Entity ids. ftrack keys entities by UUID while DNA ids are int throughout (EntityBase, route params, Mongo bookkeeping, frontend types). The provider assigns a stable int per UUID and persists the pairing in ftrack_id_map.py, so a reverse lookup resolves on any instance after any restart. The map must outlive the process: draft notes, transcripts and stored segments all reference these ids. Playlists. ftrack has two candidates — AssetVersionList (Lists, the default) and ReviewSession (Client Reviews). Both are PlaylistAdapter subclasses, and playlist_adapter() resolves the type per call from FTRACK_PLAYLIST_ENTITY, so it can be swapped for a user setting later without touching anything built on top. Query shape. Projections are flat: scalar columns and foreign keys only, never a dotted path. Deep projections make the ftrack server build the joins, which is slower than fetching each layer separately. _hydrate_versions walks versions -> assets -> contexts -> object types and stitches the result, one batched query per layer, so a playlist costs a constant number of queries regardless of size. Two tests reject a dotted path in any projection constant or playlist-load query. Also adds transcript_entity_type() to the provider base so main.py no longer reads ShotGrid's env var directly, and a thumbnail proxy endpoint because ftrack's own thumbnail URL carries the API key in its query string. Known gaps vs ShotGrid (note subject in metadata, cc merged into recipients, updated_at unavailable, opt-in media paths) are documented in backend/README.md. Not yet run against a live ftrack server, and the repo test suite has not run against it either — see the handoff doc for what that leaves unverified. Co-Authored-By: Claude Opus 5 (1M context) --- DEPLOYMENT.md | 2 +- QUICKSTART.md | 11 +- backend/README.md | 16 + backend/example.docker-compose.local.yml | 7 + backend/requirements.txt | 1 + backend/src/dna/prodtrack_providers/ftrack.py | 2072 +++++++++++++++++ .../dna/prodtrack_providers/ftrack_id_map.py | 420 ++++ .../prodtrack_provider_base.py | 23 + .../src/dna/prodtrack_providers/shotgrid.py | 4 + backend/src/main.py | 35 +- backend/tests/providers/test_ftrack_id_map.py | 264 +++ .../tests/providers/test_ftrack_provider.py | 1798 ++++++++++++++ .../providers/test_prodtrack_provider_base.py | 5 + .../tests/test_publish_transcript_endpoint.py | 12 +- 14 files changed, 4661 insertions(+), 9 deletions(-) create mode 100644 backend/src/dna/prodtrack_providers/ftrack.py create mode 100644 backend/src/dna/prodtrack_providers/ftrack_id_map.py create mode 100644 backend/tests/providers/test_ftrack_id_map.py create mode 100644 backend/tests/providers/test_ftrack_provider.py diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 6dc30d2d..44215093 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -351,7 +351,7 @@ The following secrets must be configured in GitHub repository settings: |----------|-------| | `PYTHONUNBUFFERED` | 1 | | `STORAGE_PROVIDER` | mongodb | -| `PRODTRACK_PROVIDER` | shotgrid | +| `PRODTRACK_PROVIDER` | `shotgrid`, `ftrack` or `mock` | | `LLM_PROVIDER` | openai | | `AUTH_PROVIDER` | `none` (noop) for local dev; `google` for production | | `CORS_ALLOWED_ORIGINS` | Comma-separated list of allowed origins | diff --git a/QUICKSTART.md b/QUICKSTART.md index 044065a0..481a197a 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -278,7 +278,16 @@ The React app will be available at `http://localhost:5173`. | `SHOTGRID_URL` | Yes\* | - | ShotGrid site URL (required when using ShotGrid) | | `SHOTGRID_API_KEY` | Yes\* | - | ShotGrid API key (required when using ShotGrid) | | `SHOTGRID_SCRIPT_NAME` | Yes\* | - | ShotGrid script name (required when using ShotGrid) | -| `PRODTRACK_PROVIDER` | No | `shotgrid` | `shotgrid` or `mock`; set to `mock` to use the read-only mock DB without ShotGrid | +| `PRODTRACK_PROVIDER` | No | `shotgrid` | `shotgrid`, `ftrack` or `mock`; set to `mock` to use the read-only mock DB without ShotGrid | +| `FTRACK_SERVER` | Yes\* | - | ftrack server URL (required when `PRODTRACK_PROVIDER=ftrack`) | +| `FTRACK_API_KEY` | Yes\* | - | ftrack API key (required when `PRODTRACK_PROVIDER=ftrack`) | +| `FTRACK_API_USER` | Yes\* | - | ftrack API user (required when `PRODTRACK_PROVIDER=ftrack`) | +| `FTRACK_PLAYLIST_ENTITY` | No | `AssetVersionList` | Which ftrack entity acts as a playlist: `AssetVersionList` (Lists) or `ReviewSession` (Client Reviews) | +| `FTRACK_ID_MAP` | No | `mongodb` | Where the ftrack UUID-to-int id map is persisted: `mongodb`, `sqlite` or `memory` | +| `FTRACK_ID_MAP_PATH` | No | `/tmp/dna_ftrack_id_map.db` | SQLite file for the id map when `FTRACK_ID_MAP=sqlite` | +| `FTRACK_CACHE_SECONDS` | No | `300` | How long ftrack project and version-status lookups are reused; `0` disables | +| `FTRACK_MOVIE_COMPONENTS` | No | - | Component names to resolve `movie_path` from, most preferred first (e.g. `movie,main`); empty means no path | +| `FTRACK_FRAME_COMPONENTS` | No | - | Component names to resolve `frame_path` from, most preferred first | | `MONGODB_URL` | No | `mongodb://mongo:27017` | MongoDB connection string | | `STORAGE_PROVIDER` | No | `mongodb` | Storage provider type | | `VEXA_API_KEY` | Yes | - | API key for Vexa transcription service | diff --git a/backend/README.md b/backend/README.md index 7f4d44de..daf8dfc3 100644 --- a/backend/README.md +++ b/backend/README.md @@ -26,6 +26,8 @@ Production Tracking providers are the services that provide data to the backend **ShotGrid** is the primary production tracking integration. To run without a ShotGrid seat, set **`PRODTRACK_PROVIDER=mock`**; the mock provider is read-only and backed by a SQLite database under `src/dna/prodtrack_providers/mock_data/`. See [Mock production tracking](#mock-production-tracking) below. +**ftrack** is also supported: set `PRODTRACK_PROVIDER=ftrack` with `FTRACK_SERVER`, `FTRACK_API_KEY` and `FTRACK_API_USER`. See [ftrack production tracking](#ftrack-production-tracking) below. + ### LLM LLM providers are the services that provide the LLM functionality to the backend. @@ -80,6 +82,7 @@ To configure ShotGrid and other local settings, create a local docker-compose ov 2. Edit `docker-compose.local.yml` and set at least: - **ShotGrid:** To use ShotGrid, set `PRODTRACK_PROVIDER=shotgrid` (or leave unset) and set `SHOTGRID_URL`, `SHOTGRID_API_KEY`, and `SHOTGRID_SCRIPT_NAME`. To run without ShotGrid, set `PRODTRACK_PROVIDER=mock`; see [Mock production tracking](#mock-production-tracking). + - **ftrack:** To use ftrack instead, set `PRODTRACK_PROVIDER=ftrack` and `FTRACK_SERVER`, `FTRACK_API_KEY`, `FTRACK_API_USER`; see [ftrack production tracking](#ftrack-production-tracking). - **Auth (local dev):** Keep `AUTH_PROVIDER=none` so the noop provider is used and you can sign in with any email. Change to `AUTH_PROVIDER=google` only if you need to test Google OAuth locally. - **LLM:** Choose an LLM provider and matching credentials. Examples: @@ -119,6 +122,19 @@ To configure ShotGrid and other local settings, create a local docker-compose ov 3. The `docker-compose.local.yml` file is gitignored, so your credentials will not be committed to the repository. +### ftrack production tracking + +Set **`PRODTRACK_PROVIDER=ftrack`** together with `FTRACK_SERVER`, `FTRACK_API_KEY` and `FTRACK_API_USER`. + +- **Playlists:** ftrack has two entities a studio might treat as a playlist. `FTRACK_PLAYLIST_ENTITY` picks which one: `AssetVersionList` (ftrack Lists, the default) or `ReviewSession` (ftrack Client Reviews). Both the schema names and the UI names (`Lists`, `ClientReviews`) are accepted. The setting is re-read on every playlist operation, so it can change without a restart — this is the seam a per-user preference will plug into. +- **Entity ids:** ftrack keys entities by UUID while DNA ids are ints, so the provider assigns a stable int per UUID and persists the pairing. `FTRACK_ID_MAP` selects where: `mongodb` (default, shared by every backend instance), `sqlite` (single host, path from `FTRACK_ID_MAP_PATH`) or `memory` (tests). **The map must outlive the process** — draft notes, transcripts and stored segments all reference these ids, and losing the map orphans them. +- **Flat queries, fetched in layers:** projections never use a dotted path like `asset.parent.object_type.name`. A deep projection makes the ftrack server build the joins and is reliably slower than asking for each layer on its own, so `_hydrate_versions` walks them — versions, then their assets, then those assets' parents, then the parents' object types, plus statuses, users, tasks and projects alongside — and stitches the nested shape the converters expect. Each layer is one `id in (...)` query for the whole batch, so a playlist costs about ten queries whatever its size. Filters use flat foreign keys (`project_id`) for the same reason; the only join left is a version's `entity`, which has to reach through its asset. Two tests hold this line: one rejects a dotted path in any projection constant, another rejects one in any query a playlist load issues. +- **Batching elsewhere:** id pairings for a batch are warmed in one store read rather than one per reference, and cached in process (safe — a pairing never changes). Component paths go through `pick_locations`/`get_filesystem_paths`: one availability request plus one per location, not two per version. Note recipients resolve in a single query. Project and version-status lookups are cached for `FTRACK_CACHE_SECONDS` (default 300, `0` disables) because one publish round asks for them repeatedly; the TTL exists because the provider is a process-lifetime singleton and a schema edit should not need a redeploy. Adding a new read path is worth the same treatment: fetch ids, hydrate in layers, `_warm_ids(...)`, then convert. +- **Notes:** ftrack notes attach to exactly one parent and have no subject or cc field. DNA's subject is kept in the note's metadata, cc recipients are merged into the recipient list, and links beyond the primary one are dropped. +- **Thumbnails:** ftrack's own thumbnail URL carries the API key in its query string, so thumbnails are proxied through `GET /api/ftrack-thumbnails/{version_id}` instead of being handed to the browser. +- **Media paths:** `movie_path` and `frame_path` are empty unless you opt in, because neither kind of ftrack component yields a path DNA can use. The reviewable encodings (`ftrackreview-mp4`, `ftrackreview-webm`) live in the ftrack.server location, which has no filesystem path at all; `movie` and `main` live on studio disk locations that a containerised DNA almost certainly cannot read. Resolving also costs a location round trip per component, per version in a playlist. If DNA runs inside the studio network with the storage mounted, set `FTRACK_MOVIE_COMPONENTS` (e.g. `movie,main`) and `FTRACK_FRAME_COMPONENTS` — comma-separated, most preferred first, since ftrack often carries several encodings of the same version. +- **Transcripts:** ftrack has no equivalent of ShotGrid's spare custom-entity slots, so a published transcript is a note on the version, tagged through note metadata. + ### Mock production tracking When **`PRODTRACK_PROVIDER=mock`** is set, the backend uses a read-only mock provider backed by `src/dna/prodtrack_providers/mock_data/mock.db`. The mock must be explicitly selected; there is no automatic fallback when ShotGrid credentials are missing. This allows the full stack to run without ShotGrid access. diff --git a/backend/example.docker-compose.local.yml b/backend/example.docker-compose.local.yml index a966fffb..5ffe0b3e 100644 --- a/backend/example.docker-compose.local.yml +++ b/backend/example.docker-compose.local.yml @@ -2,6 +2,9 @@ # Auth: AUTH_PROVIDER=none uses the noop provider (sign in with any email; no token validation). # Prodtrack: Set PRODTRACK_PROVIDER=mock to use the read-only mock (no ShotGrid needed). # To use real ShotGrid, set PRODTRACK_PROVIDER=shotgrid and SHOTGRID_URL, SHOTGRID_SCRIPT_NAME, SHOTGRID_API_KEY. +# To use ftrack, set PRODTRACK_PROVIDER=ftrack and FTRACK_SERVER, FTRACK_API_KEY, FTRACK_API_USER. +# FTRACK_PLAYLIST_ENTITY picks what counts as a playlist: AssetVersionList (Lists, default) +# or ReviewSession (Client Reviews). services: api: environment: @@ -10,6 +13,10 @@ services: - SHOTGRID_API_KEY=************ - SHOTGRID_SCRIPT_NAME=DNA_local_testing - PRODTRACK_PROVIDER=mock + # - FTRACK_SERVER=https://yourstudio.ftrackapp.com + # - FTRACK_API_KEY=************ + # - FTRACK_API_USER=api-user@yourstudio.com + # - FTRACK_PLAYLIST_ENTITY=AssetVersionList - VEXA_API_KEY=********** - VEXA_API_URL=http://vexa:8056 - OPENAI_API_KEY=your-openai-api-key diff --git a/backend/requirements.txt b/backend/requirements.txt index 7eb3cb54..bdfabb13 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,6 +8,7 @@ pytest-cov==4.1.0 pytest-asyncio==0.21.1 httpx==0.25.2 shotgun_api3==3.9.2 +ftrack-python-api==3.0.6 pymongo==4.10.1 websockets==12.0 openai==2.36.0 diff --git a/backend/src/dna/prodtrack_providers/ftrack.py b/backend/src/dna/prodtrack_providers/ftrack.py new file mode 100644 index 00000000..0e495430 --- /dev/null +++ b/backend/src/dna/prodtrack_providers/ftrack.py @@ -0,0 +1,2072 @@ +"""ftrack production tracking provider implementation. + +Mirrors the surface the ShotGrid and mock providers expose, over ftrack's +schema. Two things differ from ShotGrid deeply enough to call out: + +* **Ids.** ftrack keys entities by UUID, DNA by int. Every id crossing this + boundary goes through :mod:`dna.prodtrack_providers.ftrack_id_map`, which + hands out a stable int per UUID and remembers the pairing. Nothing outside + this module sees a UUID. + +* **Playlists.** ftrack has two entities a studio might call a playlist: + ``AssetVersionList`` (Lists) and ``ReviewSession`` (Client Reviews). Which one + is used is a setting, resolved per call through :meth:`FtrackProvider.playlist_adapter` + so a future per-user preference can replace the env var without touching the + operations built on top of it. ``AssetVersionList`` is the default. +""" + +from __future__ import annotations + +import logging +import os +import time +from datetime import date, datetime +from typing import Any, Iterable, Optional, cast + +import ftrack_api + +from dna.models.entity import ( + Asset, + EntityBase, + Note, + Playlist, + Project, + Shot, + Task, + User, + Version, +) +from dna.prodtrack_providers.ftrack_id_map import FtrackIdMap, get_ftrack_id_map +from dna.prodtrack_providers.prodtrack_provider_base import ( + ProdtrackProviderBase, + UserNotFoundError, +) + +logger = logging.getLogger(__name__) + +# DNA entity type -> ftrack entity type. `playlist` is deliberately absent: it +# is resolved from settings, see PLAYLIST_ADAPTERS below. +DNA_TO_FTRACK_TYPE: dict[str, str] = { + "project": "Project", + "shot": "Shot", + # ftrack's `Asset` is the container a version hangs off, not a modelled + # thing. The DNA `asset` (character, prop, environment) is an AssetBuild. + "asset": "AssetBuild", + "task": "Task", + "version": "AssetVersion", + "note": "Note", + "user": "User", +} + +# Projections stay FLAT — scalar columns and `*_id` foreign keys only, never a +# dotted path like `asset.parent.object_type.name`. A deep projection makes the +# ftrack server build those joins, which is reliably slower than asking for each +# layer separately: versions, then their assets, then those assets' parents, +# then the parents' object types. Each follow-up is one `id in (...)` query for +# the whole batch, so the round trips stay constant in the number of versions +# while the server does far less work per query. `_hydrate_versions` walks the +# layers and stitches the nested shape the converters expect. +PROJECT_PROJECTION = "id, name, full_name" + +USER_PROJECTION = "id, username, email, first_name, last_name" + +STATUS_PROJECTION = "id, name" + +TYPE_PROJECTION = "id, name" + +OBJECT_TYPE_PROJECTION = "id, name" + +# ftrack's Asset is the container a version hangs off; `context_id` is the +# context (Shot, AssetBuild, ...) that DNA actually wants. Note the name: Asset +# uses `context_id` where Task and Note use `parent_id`. +ASSET_PROJECTION = "id, name, context_id" + +CONTEXT_PROJECTION = "id, name, description, object_type_id, project_id" + +TASK_PROJECTION = "id, name, type_id, status_id, project_id, parent_id" + +VERSION_PROJECTION = ( + "id, version, comment, date, is_published, thumbnail_id, " + "asset_id, task_id, user_id, project_id, status_id" +) + +COMPONENT_PROJECTION = "id, name, version_id" + +NOTE_PROJECTION = "id, content, date, parent_id, parent_type, metadata, author_id" + +# ftrack notes have no subject field; DNA's is round-tripped through metadata. +NOTE_SUBJECT_KEY = "dna_subject" + +TRANSCRIPT_METADATA_KEYS = { + "meeting_id": "dna_meeting_id", + "meeting_date": "dna_meeting_date", + "platform": "dna_platform", + "playlist_id": "dna_playlist_id", + "kind": "dna_kind", +} +TRANSCRIPT_KIND = "transcript" + +# Types with no `project` attribute to warm. Everything else DNA reads — +# contexts, lists, review sessions — has one. +_PROJECTLESS_TYPES = frozenset({"Project", "User", "Type", "Status"}) + +# ftrack rejects very long query expressions; ids go out in chunks this size. +_QUERY_CHUNK = 200 + + +class _Stitched(dict): + """A nested entity assembled from flat query results. + + Carries `entity_type` so it is indistinguishable from an ftrack entity to + the converters — and, unlike one, never lazily fetches anything. + """ + + __slots__ = ("entity_type",) + + def __init__(self, entity_type: str, data: dict): + super().__init__(data) + self.entity_type = entity_type + + +def _cache_ttl() -> float: + """Seconds to reuse project and status lookups. 0 disables caching. + + The provider is a process-lifetime singleton, so these cannot be cached + forever: a studio editing its project schema should not need a redeploy. + """ + return float(os.getenv("FTRACK_CACHE_SECONDS", "300")) + + +def _quote(value: Any) -> str: + """Quote a value for an ftrack query expression.""" + escaped = str(value).replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + +def _full_name(user_entity: Any) -> Optional[str]: + """Best display name for an ftrack user.""" + if user_entity is None: + return None + parts = [user_entity.get("first_name"), user_entity.get("last_name")] + name = " ".join(p for p in parts if p).strip() + return name or user_entity.get("username") + + +def _version_name(ftrack_version: Any) -> str: + """Compose the version label ftrack shows as `asset - v003`.""" + asset = ftrack_version.get("asset") + asset_name = asset.get("name") if asset else None + number = ftrack_version.get("version") + if asset_name and number is not None: + return f"{asset_name}_v{int(number):03d}" + if asset_name: + return str(asset_name) + return f"v{number:03d}" if number is not None else "" + + +class PlaylistAdapter: + """One way of treating an ftrack entity as a DNA playlist. + + Subclasses own everything that differs between the entity types a studio + may pick, so the provider's playlist methods read the same either way. + """ + + entity_type: str = "" + projection: str = "" + + def list_for_project(self, session: Any, project_uuid: str) -> list[Any]: + raise NotImplementedError() + + def create(self, session: Any, project: Any, name: str) -> Any: + raise NotImplementedError() + + def version_ids(self, session: Any, playlist: Any) -> list[str]: + """Member version ids only; the provider hydrates them in layers.""" + raise NotImplementedError() + + def add_version( + self, session: Any, playlist: Any, version: Any, display: Any = None + ) -> bool: + """Link *version* to *playlist*. + + *display* is the hydrated version, for adapters that store their own + copy of its name; the raw entity carries only foreign keys. + """ + raise NotImplementedError() + + def description(self, playlist: Any) -> Optional[str]: + return None + + def created_at(self, playlist: Any) -> Optional[datetime]: + return None + + def updated_at(self, playlist: Any) -> Optional[datetime]: + return None + + +class ListPlaylistAdapter(PlaylistAdapter): + """ftrack Lists (`AssetVersionList`) as playlists. The default.""" + + entity_type = "AssetVersionList" + projection = "id, name, date, is_open, category_id, project_id" + + def list_for_project(self, session: Any, project_uuid: str) -> list[Any]: + return list( + session.query( + f"select {self.projection} from {self.entity_type} " + f"where project_id is {_quote(project_uuid)}" + ) + ) + + def create(self, session: Any, project: Any, name: str) -> Any: + data: dict[str, Any] = {"name": name, "project": project} + + # `category` is required on lists. Studios name their categories + # freely, so take the configured one and otherwise the first available. + category = self._category(session) + if category is not None: + data["category"] = category + + owner = self._api_user(session) + if owner is not None: + data["owner"] = owner + + return session.create(self.entity_type, data) + + def _category(self, session: Any) -> Any: + configured = os.getenv("FTRACK_LIST_CATEGORY") + if configured: + category = session.query( + f"select id from ListCategory where name is {_quote(configured)}" + ).first() + if category is not None: + return category + logger.warning( + "FTRACK_LIST_CATEGORY=%s does not match any ListCategory; " + "falling back to the first category on the server.", + configured, + ) + return session.query("select id, name from ListCategory").first() + + def _api_user(self, session: Any) -> Any: + return session.query( + f"select id from User where username is {_quote(session.api_user)}" + ).first() + + def version_ids(self, session: Any, playlist: Any) -> list[str]: + return [item["id"] for item in playlist["items"]] + + def add_version( + self, session: Any, playlist: Any, version: Any, display: Any = None + ) -> bool: + if any(item["id"] == version["id"] for item in playlist["items"]): + return True + playlist["items"].append(version) + session.commit() + return True + + def created_at(self, playlist: Any) -> Optional[datetime]: + return playlist.get("date") + + +class ReviewSessionPlaylistAdapter(PlaylistAdapter): + """ftrack Client Reviews (`ReviewSession`) as playlists.""" + + entity_type = "ReviewSession" + projection = "id, name, description, created_at, end_date, project_id" + + def list_for_project(self, session: Any, project_uuid: str) -> list[Any]: + return list( + session.query( + f"select {self.projection} from {self.entity_type} " + f"where project_id is {_quote(project_uuid)}" + ) + ) + + def create(self, session: Any, project: Any, name: str) -> Any: + return session.create(self.entity_type, {"name": name, "project": project}) + + # The join's foreign key to the version is `version_id` — not + # `asset_version_id`, despite the entity linking AssetVersions. Note it sits + # beside a separate string field also called `version`, which is the display + # copy below. + def version_ids(self, session: Any, playlist: Any) -> list[str]: + objects = session.query( + "select version_id from ReviewSessionObject " + f"where review_session_id is {_quote(playlist['id'])}" + ) + return [obj["version_id"] for obj in objects if obj.get("version_id")] + + def add_version( + self, session: Any, playlist: Any, version: Any, display: Any = None + ) -> bool: + existing = session.query( + "select id from ReviewSessionObject where review_session_id is " + f"{_quote(playlist['id'])} and version_id is " + f"{_quote(version['id'])}" + ).first() + if existing is not None: + return True + + # ReviewSessionObject carries its own display copy of the version's + # identity, and ftrack shows that rather than the version's own. The + # raw entity holds only foreign keys, so the labels come from the + # hydrated copy. + labelled = display if display is not None else version + session.create( + "ReviewSessionObject", + { + # Linked by foreign key. The `review_session` / `asset_version` + # relationship attributes exist too and would work, but the + # columns are what the rest of this adapter queries on, so + # linking the same way keeps it consistent. + "review_session_id": playlist["id"], + "version_id": version["id"], + "name": _version_name(labelled), + "version": str(labelled.get("version") or ""), + "description": labelled.get("comment") or "", + }, + ) + session.commit() + return True + + def description(self, playlist: Any) -> Optional[str]: + return playlist.get("description") + + def created_at(self, playlist: Any) -> Optional[datetime]: + return playlist.get("created_at") + + +# Accepts both the ftrack schema names and the names the ftrack UI uses, since +# the setting is headed for a user-facing picker. +PLAYLIST_ADAPTERS: dict[str, type[PlaylistAdapter]] = { + "assetversionlist": ListPlaylistAdapter, + "list": ListPlaylistAdapter, + "lists": ListPlaylistAdapter, + "reviewsession": ReviewSessionPlaylistAdapter, + "clientreview": ReviewSessionPlaylistAdapter, + "clientreviews": ReviewSessionPlaylistAdapter, +} + +DEFAULT_PLAYLIST_ENTITY = "AssetVersionList" + + +def resolve_playlist_adapter(entity_type: Optional[str]) -> PlaylistAdapter: + """Build the adapter for *entity_type*, defaulting to Lists.""" + key = (entity_type or DEFAULT_PLAYLIST_ENTITY).strip().lower() + adapter_class = PLAYLIST_ADAPTERS.get(key) + if adapter_class is None: + raise ValueError( + f"Unknown ftrack playlist entity type: {entity_type}. " + f"Supported: {sorted({c.entity_type for c in PLAYLIST_ADAPTERS.values()})}" + ) + return adapter_class() + + +class FtrackProvider(ProdtrackProviderBase): + """ftrack provider for production tracking operations.""" + + def __init__( + self, + server_url: Optional[str] = None, + api_key: Optional[str] = None, + api_user: Optional[str] = None, + playlist_entity_type: Optional[str] = None, + id_map: Optional[FtrackIdMap] = None, + connect: bool = True, + ): + """Initialise the ftrack connection. + + Args: + server_url: ftrack server URL. Defaults to FTRACK_SERVER env var. + api_key: API key. Defaults to FTRACK_API_KEY env var. + api_user: API user. Defaults to FTRACK_API_USER env var. + playlist_entity_type: Pins the entity used as a playlist for this + instance. Left unset, the setting is re-read on every playlist + call so a change takes effect without a restart. + id_map: UUID <-> int store. Defaults to the configured backend. + connect: Whether to open the session immediately. + """ + super().__init__() + + self.server_url = server_url or os.getenv("FTRACK_SERVER") + self.api_key = api_key or os.getenv("FTRACK_API_KEY") + self.api_user = api_user or os.getenv("FTRACK_API_USER") + self._playlist_entity_type = playlist_entity_type + + if not all([self.server_url, self.api_key, self.api_user]): + raise ValueError( + "ftrack credentials not provided. Set FTRACK_SERVER, " + "FTRACK_API_KEY, and FTRACK_API_USER environment variables." + ) + + self.server_url = (self.server_url or "").rstrip("/") + self._id_map = id_map if id_map is not None else get_ftrack_id_map() + self._base_url = (os.getenv("API_BASE_URL", "http://localhost:8000")).rstrip( + "/" + ) + + # Status lookups repeat across a publish round; version->project never + # changes at all. See _project_statuses / _project_uuid_for_version. + self._status_cache: dict[str, tuple[float, dict[str, Any]]] = {} + self._project_cache: dict[str, tuple[float, _Stitched]] = {} + self._version_project: dict[str, str] = {} + + self.session: Any = None + if connect: + self.connect() + + def connect(self) -> None: + """Open the ftrack session.""" + self.session = ftrack_api.Session( + server_url=self.server_url, + api_key=self.api_key, + api_user=self.api_user, + auto_connect_event_hub=False, + ) + + @property + def _session(self) -> Any: + if self.session is None: + raise ValueError("Not connected to ftrack") + return self.session + + def _safe_rollback(self) -> None: + """Roll back if there is a session to roll back, never raising.""" + try: + if self.session is not None: + self.session.rollback() + except Exception: + logger.debug("Rollback failed", exc_info=True) + + # ------------------------------------------------------------------ + # Playlist entity type + # ------------------------------------------------------------------ + + def playlist_adapter(self) -> PlaylistAdapter: + """The adapter for the entity type currently configured as a playlist. + + Resolved per call rather than cached at init. Today the value comes + from FTRACK_PLAYLIST_ENTITY; when the choice becomes a user setting, + this is the only place that has to learn how to read it. + """ + if self._playlist_entity_type is not None: + return resolve_playlist_adapter(self._playlist_entity_type) + return resolve_playlist_adapter( + os.getenv("FTRACK_PLAYLIST_ENTITY", DEFAULT_PLAYLIST_ENTITY) + ) + + # ------------------------------------------------------------------ + # Id translation + # ------------------------------------------------------------------ + + def _to_id(self, ftrack_entity: Any, entity_type: Optional[str] = None) -> int: + """Surrogate int for an ftrack entity (or a raw uuid + type).""" + if isinstance(ftrack_entity, str): + if entity_type is None: + raise ValueError("entity_type is required when mapping a raw uuid") + return self._id_map.to_int(ftrack_entity, entity_type) + resolved_type = entity_type or ftrack_entity.entity_type + return self._id_map.to_int(ftrack_entity["id"], resolved_type) + + def _to_uuid(self, entity_type: str, entity_id: int) -> str: + """UUID behind a surrogate int, raising the way callers expect.""" + uuid = self._id_map.to_uuid(entity_id) + if uuid is None: + raise ValueError(f"Entity not found: {entity_type} {entity_id}") + return uuid + + def _warm_ids(self, entities: Iterable[Any]) -> None: + """Pre-map every uuid a batch of entities will need. + + Conversion asks for an id per entity and per nested reference — a + version alone touches its project, user, task, task type and parent + context. Resolved one at a time that is a store round trip each; warmed + here it is one for the batch, and every later `_to_id` is a cache hit. + + Walks each type's own references rather than probing for likely + attribute names. A blind probe is only safe while no type happens to + carry an unprojected attribute by that name — and reading one is the + auto-populate round trip the projections exist to avoid. + """ + pairs: list[tuple[str, str]] = [] + + def collect(entity: Any, entity_type: Optional[str] = None) -> None: + if not entity: + return + resolved = entity_type or getattr(entity, "entity_type", None) + # Plain dicts with no discoverable type fall back to resolving + # individually; real ftrack entities always carry entity_type. + if resolved and entity.get("id"): + pairs.append((entity["id"], resolved)) + + def collect_context(context: Any) -> None: + collect(context) + if context: + collect(context.get("project"), "Project") + + def collect_task(task: Any) -> None: + collect(task, "Task") + if task: + collect(task.get("type"), "Type") + collect(task.get("project"), "Project") + + for entity in entities: + collect(entity) + kind = getattr(entity, "entity_type", None) + + if kind == "AssetVersion": + collect(entity.get("project"), "Project") + collect(entity.get("user"), "User") + collect_task(entity.get("task")) + asset = entity.get("asset") + if asset: + collect_context(asset.get("parent")) + elif kind == "Task": + collect_task(entity) + collect_context(entity.get("parent")) + elif kind == "Note": + collect(entity.get("author"), "User") + elif kind not in _PROJECTLESS_TYPES: + # Contexts, lists and review sessions all carry a project link + # and nothing else the conversions reach for. + collect(entity.get("project"), "Project") + + if pairs: + self._id_map.to_ints(pairs) + + # ------------------------------------------------------------------ + # Hydration + # + # Each helper issues ONE flat query for a whole batch of ids. Callers chain + # them layer by layer instead of asking the server for a deep projection. + # ------------------------------------------------------------------ + + def _fetch_by_ids( + self, ftrack_type: str, uuids: Iterable[str], projection: str + ) -> dict[str, Any]: + """One flat `id in (...)` query, keyed by id. Empty input costs nothing.""" + unique = [u for u in dict.fromkeys(uuids) if u] + if not unique: + return {} + + fetched: dict[str, Any] = {} + # ftrack rejects over-long expressions; chunk rather than risk one. + for start in range(0, len(unique), _QUERY_CHUNK): + chunk = unique[start : start + _QUERY_CHUNK] + quoted = ", ".join(_quote(uuid) for uuid in chunk) + for entity in self._session.query( + f"select {projection} from {ftrack_type} where id in ({quoted})" + ): + fetched[entity["id"]] = entity + return fetched + + def _stitch_projects(self, rows: Iterable[Any]) -> dict[str, _Stitched]: + """{id: project} for every project_id in *rows*. + + Cached, because hydrating one playlist reaches for the project from + three directions — the versions, their contexts and their tasks — and + it is almost always the same one. + """ + wanted = [r.get("project_id") for r in rows] + + ttl = _cache_ttl() + now = time.monotonic() + found: dict[str, _Stitched] = {} + missing: list[str] = [] + for uuid in wanted: + if not uuid: + continue + entry = self._project_cache.get(uuid) + if entry is not None and ttl > 0 and (now - entry[0]) < ttl: + found[uuid] = entry[1] + else: + missing.append(uuid) + + if missing: + fetched = self._fetch_by_ids("Project", missing, PROJECT_PROJECTION) + for uuid, project in fetched.items(): + stitched = _Stitched( + "Project", + { + "id": uuid, + "name": project.get("name"), + "full_name": project.get("full_name"), + }, + ) + self._project_cache[uuid] = (now, stitched) + found[uuid] = stitched + return found + + def _stitch_contexts(self, context_uuids: Iterable[str]) -> dict[str, _Stitched]: + """Shots, asset builds and the like, with object type and project. + + Three flat queries — contexts, their object types, their projects — + instead of one `...parent.object_type.name` join. + """ + contexts = self._fetch_by_ids("TypedContext", context_uuids, CONTEXT_PROJECTION) + if not contexts: + return {} + + object_types = self._fetch_by_ids( + "ObjectType", + [c.get("object_type_id") for c in contexts.values()], + OBJECT_TYPE_PROJECTION, + ) + projects = self._stitch_projects(contexts.values()) + + stitched: dict[str, _Stitched] = {} + for uuid, context in contexts.items(): + object_type = object_types.get(context.get("object_type_id")) + stitched[uuid] = _Stitched( + # The concrete type ftrack returned, which is what decides + # between DNA's Shot and Asset. + getattr(context, "entity_type", "TypedContext"), + { + "id": uuid, + "name": context.get("name"), + "description": context.get("description"), + "object_type": ( + {"name": object_type.get("name")} if object_type else None + ), + "project": projects.get(context.get("project_id")), + }, + ) + return stitched + + def _stitch_tasks( + self, + task_uuids: Iterable[str], + contexts: Optional[dict[str, _Stitched]] = None, + ) -> dict[str, _Stitched]: + """Tasks with their type, status and project, in flat queries.""" + tasks = self._fetch_by_ids("Task", task_uuids, TASK_PROJECTION) + if not tasks: + return {} + + types = self._fetch_by_ids( + "Type", [t.get("type_id") for t in tasks.values()], TYPE_PROJECTION + ) + statuses = self._fetch_by_ids( + "Status", [t.get("status_id") for t in tasks.values()], STATUS_PROJECTION + ) + projects = self._stitch_projects(tasks.values()) + + stitched: dict[str, _Stitched] = {} + for uuid, task in tasks.items(): + task_type = types.get(task.get("type_id")) + status = statuses.get(task.get("status_id")) + stitched[uuid] = _Stitched( + "Task", + { + "id": uuid, + "name": task.get("name"), + "type": ( + {"id": task_type["id"], "name": task_type.get("name")} + if task_type + else None + ), + "status": {"name": status.get("name")} if status else None, + "project": projects.get(task.get("project_id")), + "parent": (contexts or {}).get(task.get("parent_id")), + }, + ) + return stitched + + def _hydrate_versions(self, version_uuids: list[str]) -> list[_Stitched]: + """Assemble whole versions from flat, batched queries. + + The layers: versions -> assets -> parent contexts -> object types, plus + statuses, users, tasks and projects alongside. Roughly ten queries for a + playlist of any size, none of them asking the server to join. + """ + versions = self._fetch_by_ids("AssetVersion", version_uuids, VERSION_PROJECTION) + if not versions: + return [] + rows = list(versions.values()) + + assets = self._fetch_by_ids( + "Asset", [v.get("asset_id") for v in rows], ASSET_PROJECTION + ) + contexts = self._stitch_contexts([a.get("context_id") for a in assets.values()]) + tasks = self._stitch_tasks([v.get("task_id") for v in rows]) + users = self._fetch_by_ids( + "User", [v.get("user_id") for v in rows], USER_PROJECTION + ) + statuses = self._fetch_by_ids( + "Status", [v.get("status_id") for v in rows], STATUS_PROJECTION + ) + projects = self._stitch_projects(rows) + components = self._hydrate_components(list(versions)) + + stitched = [] + for uuid in version_uuids: + version = versions.get(uuid) + if version is None: + continue + asset = assets.get(version.get("asset_id")) + status = statuses.get(version.get("status_id")) + stitched.append( + _Stitched( + "AssetVersion", + { + "id": uuid, + "version": version.get("version"), + "comment": version.get("comment"), + "date": version.get("date"), + "is_published": version.get("is_published"), + "thumbnail_id": version.get("thumbnail_id"), + "asset": ( + { + "id": asset["id"], + "name": asset.get("name"), + "parent": contexts.get(asset.get("context_id")), + } + if asset + else None + ), + "status": {"name": status.get("name")} if status else None, + "user": users.get(version.get("user_id")), + "task": tasks.get(version.get("task_id")), + "project": projects.get(version.get("project_id")), + "components": components.get(uuid, []), + }, + ) + ) + return stitched + + def _hydrate_components(self, version_uuids: list[str]) -> dict[str, list[Any]]: + """Components per version — skipped entirely unless paths are configured.""" + if not _movie_component_names() and not _frame_component_names(): + return {} + + grouped: dict[str, list[Any]] = {} + unique = [u for u in dict.fromkeys(version_uuids) if u] + for start in range(0, len(unique), _QUERY_CHUNK): + chunk = unique[start : start + _QUERY_CHUNK] + quoted = ", ".join(_quote(uuid) for uuid in chunk) + for component in self._session.query( + f"select {COMPONENT_PROJECTION} from Component " + f"where version_id in ({quoted})" + ): + grouped.setdefault(component["version_id"], []).append(component) + return grouped + + def _ftrack_type(self, entity_type: str) -> str: + """ftrack entity type for a DNA entity type.""" + if entity_type == "playlist": + return self.playlist_adapter().entity_type + ftrack_type = DNA_TO_FTRACK_TYPE.get(entity_type) + if ftrack_type is None: + raise ValueError(f"Unknown entity type: {entity_type}") + return ftrack_type + + # ------------------------------------------------------------------ + # Conversions + # ------------------------------------------------------------------ + + def _project_ref(self, project: Any) -> Optional[dict[str, Any]]: + if not project: + return None + return { + "type": "Project", + "id": self._to_id(project, "Project"), + "name": project.get("full_name") or project.get("name"), + } + + def _project_from(self, project: Any) -> Project: + return Project( + id=self._to_id(project, "Project"), + name=project.get("full_name") or project.get("name"), + ) + + def _user_from(self, user: Any) -> User: + return User( + id=self._to_id(user, "User"), + name=_full_name(user), + email=user.get("email"), + login=user.get("username"), + ) + + def _context_from( + self, context: Any, tasks: Optional[list[Task]] = None + ) -> EntityBase: + """Shot or Asset, picked from the ftrack object type.""" + object_type = context.get("object_type") + object_name = object_type.get("name") if object_type else None + model = Shot if (object_name or context.entity_type) == "Shot" else Asset + return model( + id=self._to_id(context), + name=context.get("name"), + description=context.get("description"), + project=self._project_ref(context.get("project")), + tasks=tasks or [], + ) + + def _task_from(self, task: Any, entity: Optional[EntityBase] = None) -> Task: + step = None + task_type = task.get("type") + if task_type: + # ftrack's Task `type` is the closest analogue to a ShotGrid step. + step = { + "id": self._to_id(task_type, "Type"), + "name": task_type.get("name"), + } + status = task.get("status") + return Task( + id=self._to_id(task, "Task"), + name=task.get("name"), + status=status.get("name") if status else None, + pipeline_step=step, + project=self._project_ref(task.get("project")), + entity=entity, + ) + + def _note_from(self, note: Any, author: Optional[User] = None) -> Note: + metadata = self._note_metadata(note) + author_entity = author + if author_entity is None and note.get("author"): + author_entity = self._user_from(note["author"]) + return Note( + id=self._to_id(note, "Note"), + subject=metadata.get(NOTE_SUBJECT_KEY), + content=note.get("content"), + # ftrack notes belong to their parent, not to a project of their own. + project=None, + note_links=[], + author=author_entity, + ) + + def _note_metadata(self, note: Any) -> dict[str, Any]: + """Note metadata, tolerating servers/schemas without the attribute.""" + try: + return dict(note["metadata"].items()) + except Exception: + return {} + + def _version_from( + self, + ftrack_version: Any, + entity: Optional[EntityBase] = None, + task: Optional[Task] = None, + notes: Optional[list[Note]] = None, + user: Optional[User] = None, + paths: Optional[tuple[Optional[str], Optional[str]]] = None, + ) -> Version: + if paths is None: + paths = self._component_paths([ftrack_version])[ftrack_version["id"]] + movie_path, frame_path = paths + project = ftrack_version.get("project") + if project: + self._version_project[ftrack_version["id"]] = project["id"] + status = ftrack_version.get("status") + version_id = self._to_id(ftrack_version, "AssetVersion") + + entity_detail_url = None + if entity is not None: + parent = ftrack_version["asset"]["parent"] + entity_detail_url = ( + f"{self.server_url}/#entityId={parent['id']}&entityType=task" + ) + + return Version( + id=version_id, + name=_version_name(ftrack_version), + description=ftrack_version.get("comment"), + status=status.get("name") if status else None, + user=user, + created_at=ftrack_version.get("date"), + # ftrack AssetVersion records creation only; there is no + # server-side modification stamp to map onto updated_at. + updated_at=None, + movie_path=movie_path, + frame_path=frame_path, + thumbnail=self._thumbnail_url(ftrack_version, version_id), + project=self._project_ref(ftrack_version.get("project")), + entity=entity, + task=task, + notes=notes or [], + prodtrack_detail_url=( + f"{self.server_url}/#slideEntityId={ftrack_version['id']}" + "&slideEntityType=assetversion" + ), + prodtrack_entity_detail_url=entity_detail_url, + ) + + def _thumbnail_url(self, ftrack_version: Any, version_id: int) -> Optional[str]: + """Proxy URL for the version thumbnail. + + ftrack's own thumbnail URL embeds the API key as a query parameter, so + it cannot be handed to a browser. The backend streams the image instead. + """ + if not ftrack_version.get("thumbnail_id"): + return None + return f"{self._base_url}/api/ftrack-thumbnails/{version_id}" + + def _component_paths( + self, ftrack_versions: list[Any] + ) -> dict[str, tuple[Optional[str], Optional[str]]]: + """Movie and frame paths for many versions, in as few round trips as possible. + + Off unless the component names are configured, because neither kind of + component yields a path DNA can use by default: + + * The reviewable encodings (`ftrackreview-mp4`, `ftrackreview-webm`) + live in the ftrack.server location, whose ServerAccessor has no + `get_filesystem_path` at all — it raises, always. + * `movie` and `main` live on studio disk locations, which do resolve, + but to a mount a containerised DNA almost certainly cannot read. A + path that looks authoritative and isn't is worse than none. + + Studios running DNA inside the network opt in through + FTRACK_MOVIE_COMPONENTS / FTRACK_FRAME_COMPONENTS. + + Picks per version by walking the configured names rather than the + version's components, so the preference decides: ftrack encodes both + mp4 and webm for one version, and iterating components would take + whichever the server happened to return first. + """ + movie_names = _movie_component_names() + frame_names = _frame_component_names() + + empty: tuple[Optional[str], Optional[str]] = (None, None) + if not movie_names and not frame_names: + return {version["id"]: empty for version in ftrack_versions} + + wanted: list[Any] = [] + picks: dict[str, dict[str, Any]] = {} + for version in ftrack_versions: + by_name: dict[str, Any] = {} + for component in version.get("components") or []: + by_name.setdefault(component["name"], component) + + chosen: dict[str, Any] = {} + for kind, names in (("movie", movie_names), ("frame", frame_names)): + for name in names: + component = by_name.get(name) + if component is not None: + chosen[kind] = component + wanted.append(component) + break + picks[version["id"]] = chosen + + resolved = self._filesystem_paths(wanted) + + def path_for(chosen: dict[str, Any], kind: str) -> Optional[str]: + component = chosen.get(kind) + return resolved.get(component["id"]) if component else None + + return { + version_id: (path_for(chosen, "movie"), path_for(chosen, "frame")) + for version_id, chosen in picks.items() + } + + def _filesystem_paths(self, components: list[Any]) -> dict[str, Optional[str]]: + """Resolve a batch of components to paths, keyed by component id. + + One `pick_locations` call covers the whole batch (it is a single + availability request), then one `get_filesystem_paths` per location + that came back — rather than two round trips per version. A location + that cannot produce paths at all, which is what ftrack.server does, + loses only its own group. + """ + if not components: + return {} + + unique: dict[str, Any] = {} + for component in components: + unique.setdefault(component["id"], component) + ordered = list(unique.values()) + + try: + locations = self._session.pick_locations(ordered) + except Exception: + logger.debug("Could not pick locations for components", exc_info=True) + return {} + + grouped: dict[str, tuple[Any, list[Any]]] = {} + for component, location in zip(ordered, locations): + if location is None: + continue + grouped.setdefault(location["id"], (location, []))[1].append(component) + + paths: dict[str, Optional[str]] = {} + for location, group in grouped.values(): + try: + for component, path in zip(group, location.get_filesystem_paths(group)): + paths[component["id"]] = path + except Exception: + logger.debug( + "Location %s cannot resolve filesystem paths", + location["id"], + exc_info=True, + ) + return paths + + def _playlist_from( + self, + playlist: Any, + adapter: PlaylistAdapter, + versions: Optional[list[Version]] = None, + ) -> Playlist: + return Playlist( + id=self._to_id(playlist, adapter.entity_type), + code=playlist.get("name"), + description=adapter.description(playlist), + project=self._project_ref(playlist.get("project")), + created_at=adapter.created_at(playlist), + updated_at=adapter.updated_at(playlist), + versions=versions or [], + ) + + # ------------------------------------------------------------------ + # Reads + # ------------------------------------------------------------------ + + def get_entity( + self, entity_type: str, entity_id: int, resolve_links: bool = True + ) -> EntityBase: + """Get an entity by its DNA id. + + Args: + entity_type: The type of entity to fetch + entity_id: The DNA id of the entity + resolve_links: If True, fetch linked entities too. If False, only + shallow links with id/name. + """ + # Validate the type before the id, so an unsupported type reads as one + # rather than as a missing entity. + self._ftrack_type(entity_type) + uuid = self._to_uuid(entity_type, entity_id) + + if entity_type == "project": + return self._project_from(self._require(entity_type, entity_id, uuid)) + + if entity_type == "user": + return self._user_from(self._require(entity_type, entity_id, uuid)) + + if entity_type in ("shot", "asset"): + context = self._stitch_contexts([uuid]).get(uuid) + if context is None: + raise ValueError(f"Entity not found: {entity_type} {entity_id}") + tasks: list[Task] = [] + if resolve_links: + task_ids = [ + task["id"] + for task in self._session.query( + f"select id from Task where parent_id is {_quote(uuid)}" + ) + ] + stitched = self._stitch_tasks(task_ids) + self._warm_ids(stitched.values()) + tasks = [self._task_from(task) for task in stitched.values()] + return self._context_from(context, tasks) + + if entity_type == "task": + task = self._stitch_tasks([uuid]).get(uuid) + if task is None: + raise ValueError(f"Entity not found: {entity_type} {entity_id}") + entity = None + if resolve_links: + raw = self._fetch_by_ids("Task", [uuid], TASK_PROJECTION).get(uuid) + parent_id = raw.get("parent_id") if raw else None + if parent_id: + context = self._stitch_contexts([parent_id]).get(parent_id) + if context is not None: + entity = self._context_from(context) + self._warm_ids([task]) + return self._task_from(task, entity) + + if entity_type == "version": + hydrated = self._hydrate_versions([uuid]) + if not hydrated: + raise ValueError(f"Entity not found: {entity_type} {entity_id}") + ftrack_version = hydrated[0] + self._warm_ids([ftrack_version]) + entity = None + task = None + user = None + notes: list[Note] = [] + if resolve_links: + parent = (ftrack_version.get("asset") or {}).get("parent") + if parent: + entity = self._context_from(parent) + if ftrack_version.get("task"): + task = self._task_from(ftrack_version["task"]) + if ftrack_version.get("user"): + user = self._user_from(ftrack_version["user"]) + notes = self._notes_for_parents([uuid]).get(uuid, []) + return self._version_from(ftrack_version, entity, task, notes, user) + + if entity_type == "playlist": + adapter = self.playlist_adapter() + playlist = self._attach_projects( + [self._require(entity_type, entity_id, uuid)], adapter.entity_type + )[0] + versions: list[Version] = [] + if resolve_links: + # Membership only gives ids; reuse the path that hydrates them + # in layers and attaches notes. + versions = self.get_versions_for_playlist(entity_id) + return self._playlist_from(playlist, adapter, versions) + + if entity_type == "note": + note = self._require(entity_type, entity_id, uuid) + author = None + if resolve_links and note.get("author"): + author = self._user_from(note["author"]) + return self._note_from(note, author) + + raise ValueError(f"Unknown entity type: {entity_type}") + + def _require(self, entity_type: str, entity_id: int, uuid: str) -> Any: + """Fetch one entity with its projection, or raise the DNA-shaped error.""" + ftrack_type = self._ftrack_type(entity_type) + projection = { + "project": PROJECT_PROJECTION, + "user": USER_PROJECTION, + "note": NOTE_PROJECTION, + }.get(entity_type) + if projection is None: + projection = self.playlist_adapter().projection + + entity = self._session.query( + f"select {projection} from {ftrack_type} where id is {_quote(uuid)}" + ).first() + if entity is None: + raise ValueError(f"Entity not found: {entity_type} {entity_id}") + return entity + + def _notes_for_parents(self, parent_uuids: list[str]) -> dict[str, list[Note]]: + """Notes attached to each of *parent_uuids*, keyed by parent uuid.""" + if not parent_uuids: + return {} + quoted = ", ".join(_quote(uuid) for uuid in parent_uuids) + notes = list( + self._session.query( + f"select {NOTE_PROJECTION} from Note where parent_id in ({quoted})" + ) + ) + if not notes: + return {} + + authors = self._fetch_by_ids( + "User", [n.get("author_id") for n in notes], USER_PROJECTION + ) + self._warm_ids(authors.values()) + + grouped: dict[str, list[Note]] = {} + for note in notes: + author = authors.get(note.get("author_id")) + grouped.setdefault(note["parent_id"], []).append( + self._note_from(note, self._user_from(author) if author else None) + ) + return grouped + + def add_entity(self, entity_type: str, entity: EntityBase) -> EntityBase: + """Add an entity to ftrack. + + Only notes are supported; ftrack contexts and versions are created by + the pipeline, not by review tooling. + """ + if entity_type != "note": + raise NotImplementedError( + f"FtrackProvider does not support creating '{entity_type}' entities." + ) + + note = cast(Note, entity) + if not note.note_links: + raise ValueError("A note needs a note_link to attach to in ftrack") + + # ftrack notes hang off exactly one parent; the rest of the DNA links + # have nowhere to go. + link = note.note_links[0] + link_type = link.__class__.__name__.lower() + parent_uuid = self._to_uuid(link_type, link.id) + parent = self._session.get(self._ftrack_type(link_type), parent_uuid) + if parent is None: + raise ValueError(f"Entity not found: {link_type} {link.id}") + + created = self._create_note( + parent=parent, + content=note.content or "", + subject=note.subject, + author=self._session.query( + f"select id from User where username is " + f"{_quote(self._session.api_user)}" + ).first(), + recipients=[], + ) + return self._note_from(created) + + def find( + self, entity_type: str, filters: list[dict[str, Any]], limit: int = 0 + ) -> list[EntityBase]: + """Find entities matching DNA-format filters. + + Args: + entity_type: The DNA entity type to search for + filters: Filter dicts with 'field', 'operator' and 'value' keys + limit: Maximum number of entities to return. 0 means no limit. + """ + ftrack_type = self._ftrack_type(entity_type) + if entity_type not in self.FILTER_FIELDS: + raise ValueError(f"Unsupported entity type: {entity_type}") + + # Ask only for ids, then hydrate in layers. Selecting the full shape + # here would put the deep joins back into the filtered query, which is + # the slowest place to have them. + expression = f"select id from {ftrack_type}" + clauses = [self._filter_clause(entity_type, f) for f in filters] + if clauses: + expression += " where " + " and ".join(clauses) + + results = self._session.query(expression) + matched = [row["id"] for row in (results[:limit] if limit > 0 else results)] + if not matched: + return [] + + entities = self._hydrate(entity_type, matched) + self._warm_ids(entities) + paths = self._component_paths(entities) if entity_type == "version" else {} + + return [ + self._convert(entity_type, entity, paths.get(entity["id"])) + for entity in entities + ] + + def _hydrate(self, entity_type: str, uuids: list[str]) -> list[Any]: + """Fetch a batch of one DNA entity type, stitched and in order.""" + if entity_type == "version": + return self._hydrate_versions(uuids) + if entity_type in ("shot", "asset"): + stitched = self._stitch_contexts(uuids) + return [stitched[u] for u in uuids if u in stitched] + if entity_type == "task": + stitched = self._stitch_tasks(uuids) + return [stitched[u] for u in uuids if u in stitched] + + projection = { + "project": PROJECT_PROJECTION, + "user": USER_PROJECTION, + "note": NOTE_PROJECTION, + "playlist": self.playlist_adapter().projection, + }[entity_type] + fetched = self._fetch_by_ids(self._ftrack_type(entity_type), uuids, projection) + rows = [fetched[u] for u in uuids if u in fetched] + + # Playlists carry a project link the converter reads. + if entity_type == "playlist": + rows = self._attach_projects(rows, self.playlist_adapter().entity_type) + return rows + + def _attach_projects(self, rows: list[Any], entity_type: str) -> list[_Stitched]: + """Stitch each row's project onto it, in one extra query for the batch.""" + projects = self._stitch_projects(rows) + return [ + _Stitched( + entity_type, + {**dict(row), "project": projects.get(row.get("project_id"))}, + ) + for row in rows + ] + + def _convert( + self, + entity_type: str, + entity: Any, + paths: Optional[tuple[Optional[str], Optional[str]]] = None, + ) -> EntityBase: + if entity_type == "project": + return self._project_from(entity) + if entity_type == "user": + return self._user_from(entity) + if entity_type in ("shot", "asset"): + return self._context_from(entity) + if entity_type == "task": + return self._task_from(entity) + if entity_type == "version": + return self._version_from(entity, paths=paths) + if entity_type == "note": + return self._note_from(entity) + if entity_type == "playlist": + return self._playlist_from(entity, self.playlist_adapter()) + raise ValueError(f"Unknown entity type: {entity_type}") + + # DNA field -> ftrack attribute path, per entity type. + FILTER_FIELDS: dict[str, dict[str, str]] = { + "project": {"id": "id", "name": "full_name"}, + "user": { + "id": "id", + "email": "email", + "login": "username", + "name": "username", + }, + "shot": { + "id": "id", + "name": "name", + "description": "description", + "project": "project_id", + }, + "asset": { + "id": "id", + "name": "name", + "description": "description", + "project": "project_id", + }, + "task": { + "id": "id", + "name": "name", + "status": "status.name", + "project": "project_id", + "entity": "parent_id", + }, + "version": { + "id": "id", + "name": "asset.name", + "description": "comment", + "status": "status.name", + "project": "project_id", + # The only filter that still needs the server to join: a version + # reaches its context through the asset in between. + "entity": "asset.context_id", + "task": "task_id", + }, + "playlist": { + "id": "id", + "code": "name", + "project": "project_id", + }, + "note": { + "id": "id", + "content": "content", + "author": "author_id", + }, + } + + def _filter_clause(self, entity_type: str, filter_spec: dict[str, Any]) -> str: + field = filter_spec.get("field") + operator = filter_spec.get("operator", "is") + value = filter_spec.get("value") + + attribute = self.FILTER_FIELDS.get(entity_type, {}).get(field or "") + if attribute is None: + raise ValueError(f"Unknown field '{field}' for entity type '{entity_type}'") + + is_id_field = ( + attribute == "id" or attribute.endswith("_id") or attribute.endswith(".id") + ) + + def resolve(raw: Any) -> Any: + if isinstance(raw, dict) and "id" in raw: + raw = raw["id"] + if is_id_field and isinstance(raw, int): + uuid = self._id_map.to_uuid(raw) + if uuid is None: + raise ValueError(f"Unknown id for field '{field}': {raw}") + return uuid + return raw + + if operator == "is": + return f"{attribute} is {_quote(resolve(value))}" + if operator == "in": + values = ", ".join(_quote(resolve(v)) for v in (value or [])) + # `in ()` is a syntax error in ftrack; make it match nothing. + return f"{attribute} in ({values})" if values else "id is none" + if operator == "contains": + return f"{attribute} like {_quote(f'%{value}%')}" + + raise ValueError(f"Unsupported filter operator: {operator}") + + def search( + self, + query: str, + entity_types: list[str], + project_id: int | None = None, + limit: int = 10, + ) -> list[dict[str, Any]]: + """Search entities by name across several types. + + Args: + query: Text to match against the name (empty prefetches up to limit) + entity_types: DNA entity types to search + project_id: Optional project to scope non-user entities to + limit: Maximum results per entity type + """ + term = (query or "").strip() + project_uuid = ( + self._id_map.to_uuid(project_id) if project_id is not None else None + ) + + results: list[dict[str, Any]] = [] + for entity_type in entity_types: + if entity_type == "user": + results.extend(self._search_users(term, limit)) + elif entity_type in ("shot", "asset"): + results.extend( + self._search_contexts(entity_type, term, project_uuid, limit) + ) + elif entity_type == "version": + results.extend(self._search_versions(term, project_uuid, limit)) + elif entity_type not in DNA_TO_FTRACK_TYPE and entity_type != "playlist": + raise ValueError(f"Unsupported entity type: {entity_type}") + return results + + def _search_users(self, term: str, limit: int) -> list[dict[str, Any]]: + expression = f"select {USER_PROJECTION} from User where is_active is true" + if term: + pattern = _quote(f"%{term}%") + expression += ( + f" and (first_name like {pattern} or last_name like {pattern}" + f" or username like {pattern} or email like {pattern})" + ) + users = list(self._session.query(expression)[:limit]) + self._warm_ids(users) + return [ + { + "type": "User", + "id": self._to_id(user, "User"), + "name": _full_name(user), + "email": user.get("email"), + } + for user in users + ] + + def _search_contexts( + self, + entity_type: str, + term: str, + project_uuid: Optional[str], + limit: int, + ) -> list[dict[str, Any]]: + ftrack_type = DNA_TO_FTRACK_TYPE[entity_type] + clauses = [] + if term: + clauses.append(f"name like {_quote(f'%{term}%')}") + if project_uuid: + clauses.append(f"project_id is {_quote(project_uuid)}") + expression = f"select id from {ftrack_type}" + if clauses: + expression += " where " + " and ".join(clauses) + + dna_type = "Shot" if entity_type == "shot" else "Asset" + matched = [row["id"] for row in self._session.query(expression)[:limit]] + stitched = self._stitch_contexts(matched) + contexts = [stitched[u] for u in matched if u in stitched] + self._warm_ids(contexts) + return [ + { + "type": dna_type, + "id": self._to_id(context), + "name": context.get("name"), + "description": context.get("description"), + "project": self._project_ref(context.get("project")), + } + for context in contexts + ] + + def _search_versions( + self, term: str, project_uuid: Optional[str], limit: int + ) -> list[dict[str, Any]]: + clauses = [] + if term: + clauses.append(f"asset.name like {_quote(f'%{term}%')}") + if project_uuid: + clauses.append(f"project_id is {_quote(project_uuid)}") + expression = "select id from AssetVersion" + if clauses: + expression += " where " + " and ".join(clauses) + + matched = [row["id"] for row in self._session.query(expression)[:limit]] + versions = self._hydrate_versions(matched) + self._warm_ids(versions) + return [ + { + "type": "Version", + "id": self._to_id(version, "AssetVersion"), + "name": _version_name(version), + "description": version.get("comment"), + "project": self._project_ref(version.get("project")), + } + for version in versions + ] + + def get_user_by_email(self, user_email: str) -> User: + """Get a user by email address. + + Raises: + ValueError: If the user is not found + """ + user = self._session.query( + f"select {USER_PROJECTION} from User where email is {_quote(user_email)}" + ).first() + if user is None: + raise ValueError(f"User not found: {user_email}") + return self._user_from(user) + + def get_projects_for_user(self, user_email: str) -> list[Project]: + """Get the projects a user has access to. + + ftrack grants access through security roles rather than a per-project + membership list, so this returns the active projects the API user can + see, after confirming the user exists. + """ + user = self._session.query( + f"select id from User where email is {_quote(user_email)}" + ).first() + if user is None: + raise ValueError(f"User not found: {user_email}") + + projects = list( + self._session.query( + f"select {PROJECT_PROJECTION} from Project where status is active" + ) + ) + self._warm_ids(projects) + return [self._project_from(project) for project in projects] + + # ------------------------------------------------------------------ + # Playlists + # ------------------------------------------------------------------ + + def get_playlists_for_project(self, project_id: int) -> list[Playlist]: + """Get playlists for a project, in the configured playlist entity type.""" + adapter = self.playlist_adapter() + project_uuid = self._to_uuid("project", project_id) + playlists = self._attach_projects( + adapter.list_for_project(self._session, project_uuid), adapter.entity_type + ) + self._warm_ids(playlists) + return [self._playlist_from(playlist, adapter) for playlist in playlists] + + def create_playlist(self, project_id: int, name: str) -> Playlist: + """Create a playlist in the configured playlist entity type.""" + adapter = self.playlist_adapter() + project_uuid = self._to_uuid("project", project_id) + project = self._session.get("Project", project_uuid) + if project is None: + raise ValueError(f"Entity not found: project {project_id}") + + playlist = adapter.create(self._session, project, name) + self._session.commit() + return self._playlist_from( + self._attach_projects([playlist], adapter.entity_type)[0], adapter + ) + + def get_versions_for_playlist(self, playlist_id: int) -> list[Version]: + """Get the versions in a playlist, with their tasks, users and notes.""" + adapter = self.playlist_adapter() + playlist_uuid = self._to_uuid("playlist", playlist_id) + playlist = self._session.query( + f"select {adapter.projection} from {adapter.entity_type} " + f"where id is {_quote(playlist_uuid)}" + ).first() + if playlist is None: + return [] + + member_ids = adapter.version_ids(self._session, playlist) + if not member_ids: + return [] + + # Membership gives ids; the layered fetch turns them into whole + # versions without asking the server for a single join. + ftrack_versions = self._hydrate_versions(member_ids) + + notes_by_parent = self._notes_for_parents([v["id"] for v in ftrack_versions]) + + # One store round trip for every id the conversion below will ask for, + # and one location request for every component path. + self._warm_ids(ftrack_versions) + paths_by_version = self._component_paths(ftrack_versions) + + versions: list[Version] = [] + for ftrack_version in ftrack_versions: + parent = (ftrack_version.get("asset") or {}).get("parent") + entity = self._context_from(parent) if parent else None + task = ( + self._task_from(ftrack_version["task"]) + if ftrack_version.get("task") + else None + ) + user = ( + self._user_from(ftrack_version["user"]) + if ftrack_version.get("user") + else None + ) + versions.append( + self._version_from( + ftrack_version, + entity, + task, + notes_by_parent.get(ftrack_version["id"], []), + user, + paths=paths_by_version.get(ftrack_version["id"]), + ) + ) + return versions + + def add_version_to_playlist(self, playlist_id: int, version_id: int) -> bool: + """Add an existing version to a playlist. + + Returns: + True on success, including when the version was already present + """ + adapter = self.playlist_adapter() + playlist_uuid = self._to_uuid("playlist", playlist_id) + version_uuid = self._to_uuid("version", version_id) + + playlist = self._session.get(adapter.entity_type, playlist_uuid) + if playlist is None: + raise ValueError(f"Playlist {playlist_id} not found") + + version = self._session.get("AssetVersion", version_uuid) + if version is None: + raise ValueError(f"Version {version_id} not found") + + hydrated = self._hydrate_versions([version_uuid]) + return adapter.add_version( + self._session, playlist, version, hydrated[0] if hydrated else None + ) + + # ------------------------------------------------------------------ + # Statuses + # ------------------------------------------------------------------ + + def get_version_statuses( + self, project_id: int | None = None + ) -> list[dict[str, str]]: + """Get valid status values for versions. + + ftrack statuses have no stable short code, so the name doubles as the + code; update_version_status resolves it back by name. + """ + statuses = self._version_statuses(project_id) + return [{"code": status["name"], "name": status["name"]} for status in statuses] + + def _version_statuses(self, project_id: int | None) -> list[Any]: + if project_id is None: + return list(self._session.query("select id, name from Status")) + + project_uuid = self._to_uuid("project", project_id) + return list(self._project_statuses(project_uuid).values()) + + def _project_statuses(self, project_uuid: str) -> dict[str, Any]: + """{status name: Status} for AssetVersion, cached per project. + + Publishing a round of notes calls update_version_status once per + version, and each call would otherwise re-read the project schema and + its statuses. Cached with a TTL rather than forever, because the + provider is a process-lifetime singleton and a studio editing its + schema should not have to wait for a redeploy. + """ + ttl = _cache_ttl() + now = time.monotonic() + cached = self._status_cache.get(project_uuid) + if cached is not None and ttl > 0 and (now - cached[0]) < ttl: + return cached[1] + + project = self._session.query( + "select id, project_schema_id from Project " + f"where id is {_quote(project_uuid)}" + ).first() + if project is None: + raise ValueError(f"Entity not found: project {project_uuid}") + + schema = self._session.get("ProjectSchema", project.get("project_schema_id")) + if schema is None: + raise ValueError(f"Project {project_uuid} has no project schema") + + statuses = { + status["name"]: status for status in schema.get_statuses("AssetVersion") + } + self._status_cache[project_uuid] = (now, statuses) + return statuses + + def _project_uuid_for_version(self, version_uuid: str) -> Optional[str]: + """Project a version belongs to, remembered from any earlier read. + + A version never moves between projects, so this needs no expiry. Every + playlist read fills it in, which is what makes a publish round skip the + per-version lookup entirely. + """ + cached = self._version_project.get(version_uuid) + if cached is not None: + return cached + + version = self._session.query( + "select id, project_id from AssetVersion " + f"where id is {_quote(version_uuid)}" + ).first() + if version is None: + return None + project_uuid = version.get("project_id") + if project_uuid is None: + return None + self._version_project[version_uuid] = project_uuid + return project_uuid + + def update_version_status(self, version_id: int, status: str) -> bool: + """Set a version's status. Returns False rather than raising on failure.""" + try: + version_uuid = self._to_uuid("version", version_id) + project_uuid = self._project_uuid_for_version(version_uuid) + if project_uuid is None: + return False + + match = self._project_statuses(project_uuid).get(status) + if match is None: + logger.warning( + "Status %s is not valid for AssetVersion in this project schema", + status, + ) + return False + + version = self._session.get("AssetVersion", version_uuid) + if version is None: + return False + + version["status"] = match + self._session.commit() + return True + except Exception: + logger.exception("Failed to update status on version %s", version_id) + self._safe_rollback() + return False + + # ------------------------------------------------------------------ + # Notes + # ------------------------------------------------------------------ + + def _resolve_author(self, author_email: Optional[str]) -> Any: + """The ftrack user a note is written as; falls back to the API user.""" + if author_email: + author = self._session.query( + f"select id from User where email is {_quote(author_email)}" + ).first() + if author is None: + raise UserNotFoundError(f"Author not found in ftrack: {author_email}") + return author + return self._session.query( + f"select id from User where username is {_quote(self._session.api_user)}" + ).first() + + def _resolve_recipients(self, user_ids: list[int]) -> list[Any]: + if not user_ids: + return [] + + uuids = self._id_map.to_uuids(list(user_ids)) + for user_id in user_ids: + if user_id not in uuids: + logger.warning("Skipping unknown recipient id %s", user_id) + if not uuids: + return [] + + quoted = ", ".join(_quote(uuid) for uuid in uuids.values()) + return list(self._session.query(f"select id from User where id in ({quoted})")) + + def _create_note( + self, + parent: Any, + content: str, + subject: Optional[str], + author: Any, + recipients: list[Any], + metadata: Optional[dict[str, str]] = None, + ) -> Any: + note = parent.create_note(content, author, recipients=recipients or None) + + extra = dict(metadata or {}) + if subject: + extra[NOTE_SUBJECT_KEY] = subject + if extra: + self._set_note_metadata(note, extra) + + self._session.commit() + return note + + def _set_note_metadata(self, note: Any, values: dict[str, str]) -> None: + """Write note metadata, tolerating schemas that do not expose it.""" + try: + for key, value in values.items(): + note["metadata"][key] = value + except Exception: + logger.warning( + "Could not write note metadata %s; the note itself was kept.", + sorted(values), + exc_info=True, + ) + + def _existing_note( + self, parent_uuid: str, content: str, subject: Optional[str] + ) -> Optional[Any]: + """Find an identical note already on the parent, for idempotency.""" + notes = self._session.query( + f"select {NOTE_PROJECTION} from Note " + f"where parent_id is {_quote(parent_uuid)}" + ) + for note in notes: + if note["content"] != content: + continue + if subject and self._note_metadata(note).get(NOTE_SUBJECT_KEY) != subject: + continue + return note + return None + + def publish_note( + self, + version_id: int, + content: str, + subject: str, + to_users: list[int], + cc_users: list[int], + links: list[EntityBase], + author_email: Optional[str] = None, + version_status: Optional[str] = None, + ) -> int: + """Publish a note against a version. + + ftrack notes attach to exactly one parent and have no cc list, so + `links` beyond the version are dropped and `cc_users` join `to_users` + as recipients. `subject` is kept in the note's metadata. + + Returns: + The DNA id of the created (or matching existing) note + """ + version_uuid = self._to_uuid("version", version_id) + version = self._session.get("AssetVersion", version_uuid) + if version is None: + raise ValueError(f"Version {version_id} not found") + + existing = self._existing_note(version_uuid, content, subject) + if existing is not None: + if version_status: + self.update_version_status(version_id, version_status) + return self._to_id(existing, "Note") + + author = self._resolve_author(author_email) + recipients = self._resolve_recipients(list(to_users) + list(cc_users)) + note = self._create_note(version, content, subject, author, recipients) + + if version_status: + self.update_version_status(version_id, version_status) + + return self._to_id(note, "Note") + + def publish_playlist_note( + self, + playlist_id: int, + content: str, + subject: str, + to_users: list[int], + cc_users: list[int], + links: list[EntityBase], + author_email: Optional[str] = None, + ) -> int: + """Publish a note against a playlist. + + Lists and Client Reviews do not accept notes on every ftrack server. On + those, the note lands on the project instead with the playlist named in + the first line, so the content is never lost. + + Returns: + The DNA id of the created (or matching existing) note + """ + adapter = self.playlist_adapter() + playlist_uuid = self._to_uuid("playlist", playlist_id) + playlist = self._session.query( + f"select {adapter.projection} from {adapter.entity_type} " + f"where id is {_quote(playlist_uuid)}" + ).first() + if playlist is None: + raise ValueError(f"Playlist {playlist_id} not found") + + author = self._resolve_author(author_email) + recipients = self._resolve_recipients(list(to_users) + list(cc_users)) + + if self._accepts_notes(adapter.entity_type): + parent: Any = playlist + body = content + else: + project = playlist.get("project") + if project is None: + raise ValueError(f"Playlist {playlist_id} has no project assigned") + parent = self._session.get("Project", project["id"]) + body = f"[{playlist.get('name')}]\n{content}" + + existing = self._existing_note(parent["id"], body, subject) + if existing is not None: + return self._to_id(existing, "Note") + + note = self._create_note(parent, body, subject, author, recipients) + return self._to_id(note, "Note") + + def _accepts_notes(self, ftrack_type: str) -> bool: + """Whether *ftrack_type* has a notes relation on this server's schema.""" + try: + return "notes" in self._session.types[ftrack_type].attributes.keys() + except Exception: + return False + + def attach_file_to_note( + self, note_id: int, file_path: str, display_name: str + ) -> bool: + """Upload a local file as an attachment on an existing note.""" + try: + note_uuid = self._to_uuid("note", note_id) + note = self._session.get("Note", note_uuid) + if note is None: + return False + + component = self._session.create_component( + file_path, data={"name": display_name}, location="auto" + ) + self._session.create( + "NoteComponent", {"component_id": component["id"], "note_id": note_uuid} + ) + self._session.commit() + return True + except Exception: + logger.exception("Failed to attach %s to note %s", file_path, note_id) + self._safe_rollback() + return False + + # ------------------------------------------------------------------ + # Transcripts + # ------------------------------------------------------------------ + + def transcript_entity_type(self) -> str: + """Entity type publish_transcript writes into. + + ftrack has no equivalent of ShotGrid's spare custom-entity slots, so a + transcript is a note on the version it was captured against. + """ + return "Note" + + def publish_transcript( + self, + *, + project_id: int, + playlist_id: int, + version_id: int, + meeting_id: str, + meeting_date: date, + platform: str, + body: str, + ) -> int: + """Create the transcript note. Returns its DNA id.""" + version_uuid = self._to_uuid("version", version_id) + version = self._session.get("AssetVersion", version_uuid) + if version is None: + raise ValueError(f"Version {version_id} not found") + + subject = f"Transcript {meeting_date.isoformat()}" + note = self._create_note( + parent=version, + content=body, + subject=subject, + author=self._resolve_author(None), + recipients=[], + metadata={ + TRANSCRIPT_METADATA_KEYS["kind"]: TRANSCRIPT_KIND, + TRANSCRIPT_METADATA_KEYS["meeting_id"]: meeting_id, + TRANSCRIPT_METADATA_KEYS["meeting_date"]: meeting_date.isoformat(), + TRANSCRIPT_METADATA_KEYS["platform"]: platform, + TRANSCRIPT_METADATA_KEYS["playlist_id"]: str(playlist_id), + }, + ) + return self._to_id(note, "Note") + + def update_transcript( + self, + *, + entity_type: str, + entity_id: int, + body: str, + meeting_date: date, + ) -> bool: + """Patch body and meeting date on an existing transcript note. + + `entity_type` comes from the caller's bookkeeping rather than from the + current setting, so a row still updates after a deployment changes how + transcripts are stored. + """ + try: + uuid = self._id_map.to_uuid(entity_id) + if uuid is None: + return False + entity = self._session.get(entity_type, uuid) + if entity is None: + return False + + entity["content"] = body + self._set_note_metadata( + entity, + { + TRANSCRIPT_METADATA_KEYS["meeting_date"]: meeting_date.isoformat(), + }, + ) + self._session.commit() + return True + except Exception: + logger.exception("Failed to update transcript %s", entity_id) + self._safe_rollback() + return False + + # ------------------------------------------------------------------ + # Thumbnails + # ------------------------------------------------------------------ + + def get_thumbnail(self, version_id: int) -> Optional[tuple[bytes, str]]: + """Fetch a version's thumbnail as (bytes, content type). + + Used by the thumbnail proxy endpoint: ftrack's own thumbnail URL carries + the API key in the query string and must not reach a browser. + """ + import requests + + version_uuid = self._to_uuid("version", version_id) + version = self._session.query( + "select thumbnail_id from AssetVersion " + f"where id is {_quote(version_uuid)}" + ).first() + if version is None or not version.get("thumbnail_id"): + return None + + try: + location = self._session.query( + "select id from Location where name is " f"{_quote('ftrack.server')}" + ).first() + if location is None: + return None + + url = location.accessor.get_thumbnail_url(version["thumbnail_id"], size=300) + response = requests.get(url, timeout=30) + if response.status_code != 200: + return None + return response.content, response.headers.get("Content-Type", "image/jpeg") + except Exception: + logger.exception("Failed to fetch the thumbnail for version %s", version_id) + return None + + +def _component_names(variable: str) -> list[str]: + """Component names to resolve paths from, most preferred first. + + Empty by default: see :meth:`FtrackProvider._component_path` for why no + name is a safe default. A studio with the storage mounted can set, say, + `FTRACK_MOVIE_COMPONENTS=movie,main`, or name the reviewable encodings + (`ftrackreview-mp4,ftrackreview-webm`) if it has a location plugin that + gives those a filesystem path. + """ + configured = os.getenv(variable, "") + return [name.strip() for name in configured.split(",") if name.strip()] + + +def _movie_component_names() -> list[str]: + """Component names treated as the reviewable movie, most preferred first.""" + return _component_names("FTRACK_MOVIE_COMPONENTS") + + +def _frame_component_names() -> list[str]: + """Component names treated as the frame sequence, most preferred first.""" + return _component_names("FTRACK_FRAME_COMPONENTS") diff --git a/backend/src/dna/prodtrack_providers/ftrack_id_map.py b/backend/src/dna/prodtrack_providers/ftrack_id_map.py new file mode 100644 index 00000000..c904247a --- /dev/null +++ b/backend/src/dna/prodtrack_providers/ftrack_id_map.py @@ -0,0 +1,420 @@ +"""Stable int ids for ftrack's UUID-keyed entities. + +DNA entity ids are ints end to end (`EntityBase.id`, the FastAPI path params, +the Mongo bookkeeping rows, the frontend interfaces). ftrack keys everything by +UUID string, so the provider hands out a surrogate int per UUID and keeps the +pairing here. + +The int is a truncated blake2b of the UUID, so any process derives the same one +without coordinating. The map is still persisted because the *reverse* +direction is what a request needs: `GET /version/814233907` has to resolve back +to a UUID on whichever instance happens to serve it, possibly long after the +instance that first saw that version is gone. Collisions are rare at 53 bits but +handled: the digest is re-salted until a free slot is found, which is exactly +why the forward direction reads the store too rather than trusting the hash. + +Ids stay inside the 2**53 range JavaScript can represent exactly; anything +larger would be silently rounded by the frontend. + +**Lookups come in batches.** Converting one playlist touches an id per version +plus its project, user, task, task type and shot — a round trip each would +dominate the request. Subclasses implement bulk hooks, the base class serves +repeats from an in-process cache, and callers with a known working set warm it +up front with :meth:`FtrackIdMap.to_ints`. Caching is safe because a pairing, +once written, never changes. +""" + +from __future__ import annotations + +import hashlib +import os +import sqlite3 +import threading +from pathlib import Path +from typing import Any, Iterable, Optional + +# 2**53 - 1: the largest integer JavaScript round-trips without loss. +_MAX_SAFE_INT = 9007199254740991 + +# Bounded so a pathological collision run fails loudly instead of spinning. +_MAX_PROBES = 64 + +# Above this many cached pairings the cache is dropped wholesale. Entries are +# equally valuable and never stale, so evicting precisely is not worth the +# bookkeeping. +_DEFAULT_CACHE_SIZE = 100_000 + +# Mongo `$in` and SQLite parameter lists both degrade on huge batches. +_CHUNK = 500 + + +def surrogate_id(uuid: str, salt: int = 0) -> int: + """Derive the candidate int id for *uuid*, re-salted on collision.""" + payload = uuid.encode("utf-8") if salt == 0 else f"{uuid}:{salt}".encode("utf-8") + digest = hashlib.blake2b(payload, digest_size=8).digest() + return int.from_bytes(digest, "big") % _MAX_SAFE_INT + 1 + + +def _chunked(values: list, size: int = _CHUNK) -> Iterable[list]: + for start in range(0, len(values), size): + yield values[start : start + size] + + +class FtrackIdMap: + """Bidirectional UUID <-> surrogate int store. + + Subclasses implement the three storage hooks (`_fetch_ints`, `_fetch_rows`, + `_insert`) and may override `_insert_many` when the backend can do better + than one write at a time. Everything else — caching, batching, collision + probing — lives here so all backends behave identically. + """ + + def __init__(self, cache_size: int = _DEFAULT_CACHE_SIZE) -> None: + self._forward: dict[str, int] = {} + self._reverse: dict[int, tuple[str, str]] = {} + self._cache_size = cache_size + self._lock = threading.Lock() + + # -- storage hooks ------------------------------------------------ + + def _fetch_ints(self, uuids: list[str]) -> dict[str, int]: + """Return the ints already assigned to *uuids*.""" + raise NotImplementedError() + + def _fetch_rows(self, entity_ids: list[int]) -> dict[int, tuple[str, str]]: + """Return {id: (uuid, entity_type)} for the ids that are mapped.""" + raise NotImplementedError() + + def _insert(self, entity_id: int, uuid: str, entity_type: str) -> bool: + """Claim *entity_id* for *uuid*. False if the slot or uuid is taken.""" + raise NotImplementedError() + + def _insert_many(self, rows: list[tuple[int, str, str]]) -> None: + """Best-effort bulk claim; conflicts are left for the probing path.""" + for entity_id, uuid, entity_type in rows: + self._insert(entity_id, uuid, entity_type) + + # -- cache -------------------------------------------------------- + + def _remember(self, entity_id: int, uuid: str, entity_type: str) -> None: + if len(self._forward) >= self._cache_size: + self._forward.clear() + self._reverse.clear() + self._forward[uuid] = entity_id + self._reverse[entity_id] = (uuid, entity_type) + + # -- public api --------------------------------------------------- + + def to_int(self, uuid: str, entity_type: str) -> int: + """Return the stable int for *uuid*, assigning one on first sight.""" + cached = self._forward.get(uuid) + if cached is not None: + return cached + return self.to_ints([(uuid, entity_type)])[uuid] + + def to_ints(self, items: list[tuple[str, str]]) -> dict[str, int]: + """Map many (uuid, entity_type) pairs at once. + + Warming this before a conversion turns what would be one round trip per + entity into one per batch. Repeats are free. + """ + wanted: dict[str, str] = {} + resolved: dict[str, int] = {} + for uuid, entity_type in items: + if uuid is None: + continue + cached = self._forward.get(uuid) + if cached is not None: + resolved[uuid] = cached + else: + wanted.setdefault(uuid, entity_type) + + if not wanted: + return resolved + + with self._lock: + # Another thread may have filled these in while we waited. + pending = {u: t for u, t in wanted.items() if u not in self._forward} + resolved.update({u: self._forward[u] for u in wanted if u in self._forward}) + + if pending: + for chunk in _chunked(list(pending)): + for uuid, entity_id in self._fetch_ints(chunk).items(): + self._remember(entity_id, uuid, pending[uuid]) + resolved[uuid] = entity_id + + missing = {u: t for u, t in pending.items() if u not in resolved} + if missing: + resolved.update(self._assign(missing)) + + return resolved + + def _assign(self, missing: dict[str, str]) -> dict[str, int]: + """Claim ids for uuids that have none, bulk first then probing.""" + candidates = [ + (surrogate_id(uuid), uuid, entity_type) + for uuid, entity_type in missing.items() + ] + for chunk in _chunked(candidates): + self._insert_many(chunk) + + assigned: dict[str, int] = {} + for chunk in _chunked(list(missing)): + for uuid, entity_id in self._fetch_ints(chunk).items(): + self._remember(entity_id, uuid, missing[uuid]) + assigned[uuid] = entity_id + + # Whatever the bulk write could not place lost a race or hit a + # colliding int; re-salt those one at a time. + for uuid, entity_type in missing.items(): + if uuid in assigned: + continue + assigned[uuid] = self._assign_one(uuid, entity_type) + return assigned + + def _assign_one(self, uuid: str, entity_type: str) -> int: + for salt in range(_MAX_PROBES): + candidate = surrogate_id(uuid, salt) + if self._insert(candidate, uuid, entity_type): + self._remember(candidate, uuid, entity_type) + return candidate + # Either another writer claimed this uuid, or the int belongs to a + # different one. Re-read before probing further. + existing = self._fetch_ints([uuid]).get(uuid) + if existing is not None: + self._remember(existing, uuid, entity_type) + return existing + raise RuntimeError(f"Could not allocate a surrogate id for {uuid}") + + def to_uuid(self, entity_id: int) -> Optional[str]: + """Return the UUID for *entity_id*, or None when it was never mapped.""" + row = self._row(entity_id) + return row[0] if row else None + + def entity_type_for(self, entity_id: int) -> Optional[str]: + """Return the ftrack entity type recorded alongside *entity_id*.""" + row = self._row(entity_id) + return row[1] if row else None + + def to_uuids(self, entity_ids: list[int]) -> dict[int, str]: + """Resolve many ints back to UUIDs in one round trip.""" + resolved: dict[int, str] = {} + pending: list[int] = [] + for entity_id in entity_ids: + row = self._reverse.get(entity_id) + if row is not None: + resolved[entity_id] = row[0] + else: + pending.append(entity_id) + + for chunk in _chunked(pending): + for entity_id, (uuid, entity_type) in self._fetch_rows(chunk).items(): + self._remember(entity_id, uuid, entity_type) + resolved[entity_id] = uuid + return resolved + + def _row(self, entity_id: int) -> Optional[tuple[str, str]]: + cached = self._reverse.get(entity_id) + if cached is not None: + return cached + row = self._fetch_rows([entity_id]).get(entity_id) + if row is not None: + self._remember(entity_id, row[0], row[1]) + return row + + +class InMemoryIdMap(FtrackIdMap): + """Process-local map. For tests and single-shot scripts only.""" + + def __init__(self, cache_size: int = _DEFAULT_CACHE_SIZE) -> None: + super().__init__(cache_size) + self._store: dict[int, tuple[str, str]] = {} + self._by_uuid: dict[str, int] = {} + + def _fetch_ints(self, uuids: list[str]) -> dict[str, int]: + return {u: self._by_uuid[u] for u in uuids if u in self._by_uuid} + + def _fetch_rows(self, entity_ids: list[int]) -> dict[int, tuple[str, str]]: + return {i: self._store[i] for i in entity_ids if i in self._store} + + def _insert(self, entity_id: int, uuid: str, entity_type: str) -> bool: + if entity_id in self._store or uuid in self._by_uuid: + return False + self._store[entity_id] = (uuid, entity_type) + self._by_uuid[uuid] = entity_id + return True + + +class SqliteIdMap(FtrackIdMap): + """File-backed map. Survives restarts on a single host.""" + + def __init__( + self, db_path: Optional[Path] = None, cache_size: int = _DEFAULT_CACHE_SIZE + ) -> None: + super().__init__(cache_size) + if db_path is None: + db_path = Path(os.getenv("FTRACK_ID_MAP_PATH", "/tmp/dna_ftrack_id_map.db")) + self._db_path = Path(db_path) + self._db_path.parent.mkdir(parents=True, exist_ok=True) + self._ensure_schema() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self._db_path, timeout=10) + conn.row_factory = sqlite3.Row + return conn + + def _ensure_schema(self) -> None: + conn = self._connect() + try: + conn.execute( + "CREATE TABLE IF NOT EXISTS ftrack_id_map (" + " id INTEGER PRIMARY KEY," + " uuid TEXT NOT NULL UNIQUE," + " entity_type TEXT NOT NULL" + ")" + ) + conn.commit() + finally: + conn.close() + + def _fetch_ints(self, uuids: list[str]) -> dict[str, int]: + placeholders = ",".join("?" * len(uuids)) + conn = self._connect() + try: + rows = conn.execute( + f"SELECT id, uuid FROM ftrack_id_map WHERE uuid IN ({placeholders})", + uuids, + ).fetchall() + finally: + conn.close() + return {row["uuid"]: int(row["id"]) for row in rows} + + def _fetch_rows(self, entity_ids: list[int]) -> dict[int, tuple[str, str]]: + placeholders = ",".join("?" * len(entity_ids)) + conn = self._connect() + try: + rows = conn.execute( + "SELECT id, uuid, entity_type FROM ftrack_id_map " + f"WHERE id IN ({placeholders})", + entity_ids, + ).fetchall() + finally: + conn.close() + return {int(r["id"]): (r["uuid"], r["entity_type"]) for r in rows} + + def _insert(self, entity_id: int, uuid: str, entity_type: str) -> bool: + conn = self._connect() + try: + conn.execute( + "INSERT INTO ftrack_id_map (id, uuid, entity_type) VALUES (?, ?, ?)", + (entity_id, uuid, entity_type), + ) + conn.commit() + return True + except sqlite3.IntegrityError: + return False + finally: + conn.close() + + def _insert_many(self, rows: list[tuple[int, str, str]]) -> None: + conn = self._connect() + try: + # OR IGNORE skips whichever rows conflict on id or uuid; the + # re-read in _assign spots them and falls back to probing. + conn.executemany( + "INSERT OR IGNORE INTO ftrack_id_map (id, uuid, entity_type) " + "VALUES (?, ?, ?)", + rows, + ) + conn.commit() + finally: + conn.close() + + +class MongoIdMap(FtrackIdMap): + """Mongo-backed map, shared by every instance pointed at the same database. + + Uses the synchronous client on purpose: the whole prodtrack provider + surface is synchronous, and an id lookup sits in the middle of it. + """ + + COLLECTION = "ftrack_id_map" + + def __init__( + self, + mongo_url: Optional[str] = None, + database: str = "dna", + cache_size: int = _DEFAULT_CACHE_SIZE, + ) -> None: + super().__init__(cache_size) + self._mongo_url = mongo_url or os.getenv( + "MONGODB_URL", "mongodb://localhost:27017" + ) + self._database = database + self._collection: Any = None + + @property + def collection(self) -> Any: + if self._collection is None: + from pymongo import MongoClient + + client: Any = MongoClient(self._mongo_url) + self._collection = client[self._database][self.COLLECTION] + self._collection.create_index("uuid", unique=True, name="ftrack_uuid") + return self._collection + + def _fetch_ints(self, uuids: list[str]) -> dict[str, int]: + rows = self.collection.find({"uuid": {"$in": uuids}}, {"_id": 1, "uuid": 1}) + return {row["uuid"]: int(row["_id"]) for row in rows} + + def _fetch_rows(self, entity_ids: list[int]) -> dict[int, tuple[str, str]]: + rows = self.collection.find({"_id": {"$in": entity_ids}}) + return { + int(row["_id"]): (row["uuid"], row.get("entity_type", "")) for row in rows + } + + def _insert(self, entity_id: int, uuid: str, entity_type: str) -> bool: + from pymongo.errors import DuplicateKeyError + + try: + self.collection.insert_one( + {"_id": entity_id, "uuid": uuid, "entity_type": entity_type} + ) + return True + except DuplicateKeyError: + return False + + def _insert_many(self, rows: list[tuple[int, str, str]]) -> None: + from pymongo.errors import BulkWriteError + + try: + # Unordered so one conflict does not abandon the rest of the batch; + # the re-read in _assign catches whatever did not land. + self.collection.insert_many( + [ + {"_id": entity_id, "uuid": uuid, "entity_type": entity_type} + for entity_id, uuid, entity_type in rows + ], + ordered=False, + ) + except BulkWriteError: + pass + + +def get_ftrack_id_map() -> FtrackIdMap: + """Build the id map named by FTRACK_ID_MAP. + + Defaults to mongodb, matching STORAGE_PROVIDER: the deployed backend runs + several instances against one database, and sqlite on a container's local + disk would hand each instance a different, short-lived map. + """ + backend = os.getenv("FTRACK_ID_MAP", "mongodb").lower() + + if backend == "mongodb": + return MongoIdMap() + if backend == "sqlite": + return SqliteIdMap() + if backend == "memory": + return InMemoryIdMap() + + raise ValueError(f"Unknown ftrack id map backend: {backend}") diff --git a/backend/src/dna/prodtrack_providers/prodtrack_provider_base.py b/backend/src/dna/prodtrack_providers/prodtrack_provider_base.py index b980b2f6..3dffa099 100644 --- a/backend/src/dna/prodtrack_providers/prodtrack_provider_base.py +++ b/backend/src/dna/prodtrack_providers/prodtrack_provider_base.py @@ -264,6 +264,15 @@ def attach_file_to_note( """ raise NotImplementedError("Subclasses must implement this method.") + def transcript_entity_type(self) -> str: + """Entity type that publish_transcript writes its rows into. + + Callers record this alongside the returned id so a later + update_transcript targets the original row even if the deployment's + configured slot changes in between. + """ + raise NotImplementedError("Subclasses must implement this method.") + def publish_transcript( self, *, @@ -311,6 +320,20 @@ def get_prodtrack_provider() -> ProdtrackProviderBase: return MockProdtrackProvider() + if provider_type == "ftrack": + ft_url = os.getenv("FTRACK_SERVER") + ft_key = os.getenv("FTRACK_API_KEY") + ft_user = os.getenv("FTRACK_API_USER") + if not all([ft_url, ft_key, ft_user]): + raise ValueError( + "ftrack credentials not provided. Set FTRACK_SERVER, " + "FTRACK_API_KEY, and FTRACK_API_USER, or use " + "PRODTRACK_PROVIDER=mock for the mock provider." + ) + from dna.prodtrack_providers.ftrack import FtrackProvider + + return FtrackProvider() + if provider_type == "shotgrid": sg_url = os.getenv("SHOTGRID_URL") sg_script = os.getenv("SHOTGRID_SCRIPT_NAME") diff --git a/backend/src/dna/prodtrack_providers/shotgrid.py b/backend/src/dna/prodtrack_providers/shotgrid.py index 477ddf20..02900f08 100644 --- a/backend/src/dna/prodtrack_providers/shotgrid.py +++ b/backend/src/dna/prodtrack_providers/shotgrid.py @@ -1161,6 +1161,10 @@ def attach_file_to_note( except Exception: return False + def transcript_entity_type(self) -> str: + """Site-specific custom-entity slot transcripts are written into.""" + return _transcript_entity_type() + def publish_transcript( self, *, diff --git a/backend/src/main.py b/backend/src/main.py index 5a2c5d75..190f0245 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -20,7 +20,7 @@ WebSocketDisconnect, ) from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, Response from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from dna.auth.email import emails_match @@ -476,6 +476,35 @@ async def delete_attachment(attachment_id: str, _: CurrentUserDep) -> dict: return {"deleted": attachment_id} +@app.get( + "/api/ftrack-thumbnails/{version_id}", + tags=["Versions"], + summary="Serve an ftrack version thumbnail", + description=( + "Streams a version's thumbnail from ftrack (when using the ftrack " + "prodtrack provider). ftrack's own thumbnail URL carries the API key " + "in its query string, so it is proxied here instead of handed to the " + "browser." + ), + response_class=Response, +) +async def get_ftrack_thumbnail(version_id: int, provider: ProdtrackProviderDep): + """Proxy a version thumbnail from ftrack.""" + getter = getattr(provider, "get_thumbnail", None) + if getter is None: + raise HTTPException(status_code=404, detail="Thumbnail not found") + + try: + thumbnail = getter(version_id) + except ValueError: + raise HTTPException(status_code=404, detail="Thumbnail not found") + if thumbnail is None: + raise HTTPException(status_code=404, detail="Thumbnail not found") + + content, media_type = thumbnail + return Response(content=content, media_type=media_type) + + @app.get( "/api/mock-thumbnails/{version_id}", tags=["Versions"], @@ -1318,7 +1347,9 @@ async def publish_transcript( except NotImplementedError as e: raise HTTPException(status_code=501, detail=str(e)) - entity_type = os.getenv("SHOTGRID_TRANSCRIPT_ENTITY", "CustomEntity01") + # Ask the provider where it just wrote, rather than reading ShotGrid's env + # var: ftrack stores transcripts somewhere else entirely. + entity_type = prodtrack.transcript_entity_type() try: await storage.upsert_published_transcript( PublishedTranscriptUpdate( diff --git a/backend/tests/providers/test_ftrack_id_map.py b/backend/tests/providers/test_ftrack_id_map.py new file mode 100644 index 00000000..117361cd --- /dev/null +++ b/backend/tests/providers/test_ftrack_id_map.py @@ -0,0 +1,264 @@ +"""Tests for the ftrack UUID <-> int surrogate id map.""" + +import pathlib + +import pytest + +from dna.prodtrack_providers.ftrack_id_map import ( + InMemoryIdMap, + SqliteIdMap, + get_ftrack_id_map, + surrogate_id, +) + +UUID_A = "3a7c1e02-1111-4111-8111-000000000001" +UUID_B = "3a7c1e02-2222-4222-8222-000000000002" + + +class TestSurrogateId: + """The derived int has to be usable by the JS frontend.""" + + def test_is_deterministic(self): + assert surrogate_id(UUID_A) == surrogate_id(UUID_A) + + def test_differs_per_uuid(self): + assert surrogate_id(UUID_A) != surrogate_id(UUID_B) + + def test_salt_changes_the_result(self): + assert surrogate_id(UUID_A, salt=1) != surrogate_id(UUID_A) + + def test_stays_inside_the_js_safe_range(self): + for uuid in (UUID_A, UUID_B, "x", "0" * 36): + value = surrogate_id(uuid) + assert 0 < value <= 9007199254740991 + + +class _IdMapContract: + """Shared behaviour every backend must honour.""" + + def make_map(self, tmp_path): + raise NotImplementedError() + + def test_round_trips_a_uuid(self, tmp_path): + id_map = self.make_map(tmp_path) + entity_id = id_map.to_int(UUID_A, "AssetVersion") + assert id_map.to_uuid(entity_id) == UUID_A + assert id_map.entity_type_for(entity_id) == "AssetVersion" + + def test_is_stable_across_calls(self, tmp_path): + id_map = self.make_map(tmp_path) + first = id_map.to_int(UUID_A, "AssetVersion") + second = id_map.to_int(UUID_A, "AssetVersion") + assert first == second + + def test_matches_the_bare_hash_when_uncontended(self, tmp_path): + id_map = self.make_map(tmp_path) + assert id_map.to_int(UUID_A, "AssetVersion") == surrogate_id(UUID_A) + + def test_unknown_id_resolves_to_none(self, tmp_path): + id_map = self.make_map(tmp_path) + assert id_map.to_uuid(123456789) is None + assert id_map.entity_type_for(123456789) is None + + def test_collision_falls_through_to_a_free_slot(self, tmp_path, monkeypatch): + id_map = self.make_map(tmp_path) + taken = id_map.to_int(UUID_A, "AssetVersion") + + # Force the first probe for B onto A's slot. + import dna.prodtrack_providers.ftrack_id_map as module + + real = module.surrogate_id + + def colliding(uuid, salt=0): + if uuid == UUID_B and salt == 0: + return taken + return real(uuid, salt) + + monkeypatch.setattr(module, "surrogate_id", colliding) + + other = id_map.to_int(UUID_B, "AssetVersion") + assert other != taken + assert id_map.to_uuid(other) == UUID_B + assert id_map.to_uuid(taken) == UUID_A + + +class TestInMemoryIdMap(_IdMapContract): + def make_map(self, tmp_path): + return InMemoryIdMap() + + +class TestSqliteIdMap(_IdMapContract): + def make_map(self, tmp_path): + return SqliteIdMap(tmp_path / "ids.db") + + def test_survives_a_new_instance(self, tmp_path): + path = tmp_path / "ids.db" + entity_id = SqliteIdMap(path).to_int(UUID_A, "AssetVersion") + assert SqliteIdMap(path).to_uuid(entity_id) == UUID_A + + +class TestBatching: + """Conversion asks for hundreds of ids per playlist; they go in batches.""" + + def make_map(self): + return InMemoryIdMap() + + def make_map_sharing(self, other): + """A second map over the same storage, with an empty cache.""" + fresh = InMemoryIdMap() + fresh._store = other._store + fresh._by_uuid = other._by_uuid + return fresh + + def _counting(self, id_map): + """Wrap the storage hooks so round trips can be counted.""" + calls = {"fetch_ints": 0, "fetch_rows": 0} + real_ints = id_map._fetch_ints + real_rows = id_map._fetch_rows + + def fetch_ints(uuids): + calls["fetch_ints"] += 1 + calls.setdefault("fetched_uuids", []).append(list(uuids)) + return real_ints(uuids) + + def fetch_rows(ids): + calls["fetch_rows"] += 1 + return real_rows(ids) + + id_map._fetch_ints = fetch_ints + id_map._fetch_rows = fetch_rows + return calls + + def test_to_ints_maps_a_whole_batch(self): + id_map = self.make_map() + items = [(f"uuid-{i}", "AssetVersion") for i in range(50)] + + result = id_map.to_ints(items) + + assert len(result) == 50 + assert len(set(result.values())) == 50 + for uuid, _ in items: + assert id_map.to_uuid(result[uuid]) == uuid + + def test_a_warmed_batch_costs_no_further_reads(self): + id_map = self.make_map() + items = [(f"uuid-{i}", "AssetVersion") for i in range(20)] + id_map.to_ints(items) + + calls = self._counting(id_map) + for uuid, entity_type in items: + id_map.to_int(uuid, entity_type) + + assert calls["fetch_ints"] == 0 + + def test_repeats_within_one_batch_are_collapsed(self): + """Ten mentions of one uuid must not become ten lookups.""" + id_map = self.make_map() + calls = self._counting(id_map) + + result = id_map.to_ints([("uuid-a", "Project")] * 10) + + assert len(result) == 1 + for fetched in calls["fetched_uuids"]: + assert fetched == ["uuid-a"] + + def test_an_unmapped_batch_reads_twice_at_most(self): + """Once to find what exists, once to confirm what the bulk write placed.""" + id_map = self.make_map() + calls = self._counting(id_map) + + id_map.to_ints([(f"uuid-{i}", "AssetVersion") for i in range(30)]) + + assert calls["fetch_ints"] <= 2 + + def test_an_already_mapped_batch_reads_once(self): + id_map = self.make_map() + items = [(f"uuid-{i}", "AssetVersion") for i in range(30)] + id_map.to_ints(items) + + fresh = self.make_map_sharing(id_map) + calls = self._counting(fresh) + fresh.to_ints(items) + + assert calls["fetch_ints"] == 1 + + def test_to_uuids_resolves_many_ids_at_once(self): + id_map = self.make_map() + ids = {u: id_map.to_int(u, "User") for u in ("uuid-a", "uuid-b", "uuid-c")} + + resolved = id_map.to_uuids(list(ids.values())) + + assert resolved == {v: k for k, v in ids.items()} + + def test_to_uuids_skips_ids_that_were_never_mapped(self): + id_map = self.make_map() + known = id_map.to_int(UUID_A, "AssetVersion") + + resolved = id_map.to_uuids([known, 12345]) + + assert resolved == {known: UUID_A} + + def test_reverse_lookups_are_cached(self): + id_map = self.make_map() + entity_id = id_map.to_int(UUID_A, "AssetVersion") + calls = self._counting(id_map) + + for _ in range(5): + assert id_map.to_uuid(entity_id) == UUID_A + + assert calls["fetch_rows"] == 0 + + def test_a_full_cache_is_dropped_rather_than_growing(self): + id_map = InMemoryIdMap(cache_size=4) + ids = {u: id_map.to_int(u, "User") for u in (f"uuid-{i}" for i in range(10))} + + # Evicted entries must still resolve, just from the store. + for uuid, entity_id in ids.items(): + assert id_map.to_uuid(entity_id) == uuid + + def test_batching_survives_a_collision(self, monkeypatch): + """Two uuids hashing alike must still both come back from one batch.""" + import dna.prodtrack_providers.ftrack_id_map as module + + real = module.surrogate_id + + def colliding(uuid, salt=0): + if salt == 0 and uuid in (UUID_A, UUID_B): + return 4242 + return real(uuid, salt) + + monkeypatch.setattr(module, "surrogate_id", colliding) + id_map = self.make_map() + + result = id_map.to_ints([(UUID_A, "AssetVersion"), (UUID_B, "AssetVersion")]) + + assert len(set(result.values())) == 2 + assert id_map.to_uuid(result[UUID_A]) == UUID_A + assert id_map.to_uuid(result[UUID_B]) == UUID_B + + +class TestSqliteBatching(TestBatching): + """The persistent backend must batch identically.""" + + def make_map(self, tmp_path=None): + import tempfile + + return SqliteIdMap(pathlib.Path(tempfile.mkdtemp()) / "ids.db") + + def make_map_sharing(self, other): + return SqliteIdMap(other._db_path) + + +class TestFactory: + def test_selects_backend_from_env(self, monkeypatch, tmp_path): + monkeypatch.setenv("FTRACK_ID_MAP", "memory") + assert isinstance(get_ftrack_id_map(), InMemoryIdMap) + + monkeypatch.setenv("FTRACK_ID_MAP", "sqlite") + monkeypatch.setenv("FTRACK_ID_MAP_PATH", str(tmp_path / "ids.db")) + assert isinstance(get_ftrack_id_map(), SqliteIdMap) + + def test_rejects_unknown_backend(self, monkeypatch): + monkeypatch.setenv("FTRACK_ID_MAP", "postgres") + with pytest.raises(ValueError, match="Unknown ftrack id map backend"): + get_ftrack_id_map() diff --git a/backend/tests/providers/test_ftrack_provider.py b/backend/tests/providers/test_ftrack_provider.py new file mode 100644 index 00000000..6c436872 --- /dev/null +++ b/backend/tests/providers/test_ftrack_provider.py @@ -0,0 +1,1798 @@ +"""Tests for FtrackProvider.""" + +import os +import re +from datetime import date, datetime +from unittest import mock + +import pytest + +from dna.models.entity import Asset, Note, Playlist, Shot, Version +from dna.prodtrack_providers.ftrack import ( + FtrackProvider, + ListPlaylistAdapter, + ReviewSessionPlaylistAdapter, + _movie_component_names, + resolve_playlist_adapter, +) +from dna.prodtrack_providers.ftrack_id_map import InMemoryIdMap +from dna.prodtrack_providers.prodtrack_provider_base import ( + UserNotFoundError, + get_prodtrack_provider, +) + +SERVER_URL = "https://ftrack.example.com" +BASE_URL = "http://api.example.com" + + +# --------------------------------------------------------------------------- +# Fakes +# +# ftrack entities behave like dicts with an `entity_type`; queries return a +# result that is iterable, sliceable and has `.first()`. That is the whole +# surface the provider touches, so the fakes stay small. +# --------------------------------------------------------------------------- + + +class FakeEntity(dict): + """A row. Reading a field it was not given records an auto-populate. + + Set the class attribute `lazy_reads` to a list to collect them; ftrack + would serve each one as its own request. + """ + + lazy_reads = None + + def __init__(self, entity_type, data, session=None): + super().__init__(data) + self.entity_type = entity_type + self.session = session + + def _record(self, key): + if FakeEntity.lazy_reads is not None: + FakeEntity.lazy_reads.append(f"{self.entity_type}.{key}") + + def get(self, key, default=None): + if key not in self: + self._record(key) + return default + return self[key] + + def create_note(self, content, author, recipients=None): + note = FakeEntity( + "Note", + { + "id": f"note-{len(self.session.created_notes) + 1}", + "content": content, + "author": author, + "parent_id": self["id"], + "parent_type": self.entity_type, + "metadata": {}, + }, + session=self.session, + ) + note.recipients = list(recipients or []) + self.session.created_notes.append(note) + return note + + +class FakeQueryResult(list): + def first(self): + return self[0] if self else None + + +class FakeSchema(FakeEntity): + """A ProjectSchema row; get_statuses is ftrack's own helper.""" + + def __init__(self, uuid, statuses): + super().__init__("ProjectSchema", {"id": uuid}) + self._statuses = statuses + + def get_statuses(self, schema, type_id=None): + return list(self._statuses) + + +# ftrack subtypes DNA queries through their common base. +_BASE_TYPES = { + "Shot": "TypedContext", + "AssetBuild": "TypedContext", + "Sequence": "TypedContext", + "Task": "TypedContext", + "FileComponent": "Component", + "SequenceComponent": "Component", +} + +_CLAUSE = re.compile(r"^(?P[\w.]+)\s+(?Pis not|is|in|like)\s+(?P.+)$") + + +def _unquote(value): + value = value.strip() + if value.startswith('"') and value.endswith('"'): + return value[1:-1].replace('\\"', '"').replace("\\\\", "\\") + return value + + +def _split_values(value): + return [_unquote(v) for v in value.strip().strip("()").split(",") if v.strip()] + + +def _walk(entity, field): + """Resolve a dotted attribute path, as ftrack would.""" + current = entity + for part in field.split("."): + if current is None: + return None + if part.endswith("_id") and part not in current: + # ftrack exposes both `project` and `project_id`; the fakes may + # only carry one of them. + linked = current.get(part[:-3]) + current = linked.get("id") if linked else None + continue + if part == "id" and hasattr(current, "get") and "id" not in current: + return None + current = current.get(part) if hasattr(current, "get") else None + if hasattr(current, "get") and "id" in current and not isinstance(current, str): + return current + return current + + +def _matches(entity, clause): + clause = clause.strip() + if clause.startswith("(") and " or " in clause: + return any( + _matches(part, "") or _matches(entity, part) + for part in clause.strip("()").split(" or ") + ) + + parsed = _CLAUSE.match(clause) + if not parsed: + return True + + field, op, raw = parsed.group("field"), parsed.group("op"), parsed.group("value") + actual = _walk(entity, field) + if hasattr(actual, "get") and not isinstance(actual, str): + actual = actual.get("id") + + if op == "is": + expected = _unquote(raw) + if expected in ("active", "true", "false", "none"): + return expected != "none" or actual is None + return actual == expected + if op == "is not": + return actual != _unquote(raw) + if op == "in": + return actual in _split_values(raw) + if op == "like": + pattern = _unquote(raw).strip("%").lower() + return pattern in str(actual or "").lower() + return False + + +class FakeSession: + """A small ftrack stand-in. + + Registered entities go into per-type tables and `query` parses the + expression against them, so a provider that fetches in layers is exercised + the way a real server would answer it. `respond` still pins an exact + canned answer where a test wants one; it takes precedence. + """ + + def __init__(self, api_user="api@example.com"): + self.api_user = api_user + self.rules = [] + self.entities = {} + self.created = [] + self.created_notes = [] + self.commits = 0 + self.rollbacks = 0 + self.queries = [] + self.types = {} + self.location = None + self.location_lookups = [] + + def respond(self, fragment, result): + """Register a canned answer for queries containing *fragment*.""" + self.rules.append((fragment, result)) + + def query(self, expression, page_size=500): + self.queries.append(expression) + for fragment, result in self.rules: + if fragment in expression: + return FakeQueryResult(result) + return FakeQueryResult(self._run(expression)) + + def _run(self, expression): + match = re.search(r"\bfrom\s+(\w+)(?:\s+where\s+(.*))?$", expression.strip()) + if not match: + return [] + entity_type, where = match.group(1), match.group(2) + + rows = [ + entity + for (kind, _), entity in self.entities.items() + if kind == entity_type or _BASE_TYPES.get(kind) == entity_type + ] + if not where: + return rows + for clause in where.split(" and "): + rows = [row for row in rows if _matches(row, clause)] + return rows + + def get(self, entity_type, entity_key): + return self.entities.get((entity_type, entity_key)) + + def register(self, entity): + self.entities[(entity.entity_type, entity["id"])] = entity + entity.session = self + return entity + + def queries_against(self, entity_type): + """Every query issued against *entity_type*, for round-trip counting.""" + return [q for q in self.queries if f"from {entity_type} " in q + " "] + + def create(self, entity_type, data=None): + entity = FakeEntity( + entity_type, {"id": f"{entity_type.lower()}-new", **(data or {})}, self + ) + self.created.append(entity) + return entity + + def commit(self): + self.commits += 1 + + def rollback(self): + self.rollbacks += 1 + + def pick_locations(self, components): + self.location_lookups.append(list(components)) + return [self.location] * len(components) + + +class FakeLocation(dict): + """A mounted location: every component resolves to a path.""" + + def __init__(self, location_id="location-1"): + super().__init__({"id": location_id}) + self.path_calls = [] + + def get_filesystem_paths(self, components): + self.path_calls.append(list(components)) + return [f"/mnt/ftrack/{c['name']}" for c in components] + + +@pytest.fixture +def session(): + return FakeSession() + + +@pytest.fixture +def provider(session, monkeypatch): + monkeypatch.setenv("API_BASE_URL", BASE_URL) + instance = FtrackProvider( + server_url=SERVER_URL, + api_key="key", + api_user="api@example.com", + id_map=InMemoryIdMap(), + connect=False, + ) + instance.session = session + return instance + + +def make_project(session, uuid="project-1", name="Skyfall", statuses=()): + schema = session.register( + FakeSchema(f"schema-{uuid}", [{"name": n} for n in statuses]) + ) + return session.register( + FakeEntity( + "Project", + { + "id": uuid, + "name": "sky", + "full_name": name, + "project_schema_id": schema["id"], + }, + ) + ) + + +def make_user(session, uuid="user-1", email="artist@example.com"): + return session.register( + FakeEntity( + "User", + { + "id": uuid, + "username": "artist", + "email": email, + "first_name": "Ada", + "last_name": "Lovelace", + "is_active": True, + }, + ) + ) + + +def make_status(session, name, uuid=None): + uuid = uuid or f"status-{name.lower().replace(' ', '-')}" + return session.register(FakeEntity("Status", {"id": uuid, "name": name})) + + +def make_object_type(session, name="Shot"): + return session.register( + FakeEntity("ObjectType", {"id": f"objecttype-{name.lower()}", "name": name}) + ) + + +def make_context(session, project, uuid, name, kind="Shot", description="A shot"): + object_type = make_object_type(session, kind) + return session.register( + FakeEntity( + kind.replace(" ", ""), + { + "id": uuid, + "name": name, + "description": description, + "object_type_id": object_type["id"], + "project_id": project["id"], + }, + ) + ) + + +def make_version(session, project, uuid="version-1", shot_name="sh010", number=3): + """Seed a version and every row the layered fetch will follow to. + + Normalised on purpose: the provider reaches these through separate flat + queries, exactly as it does against a real server. + """ + shot = make_context(session, project, f"shot-for-{uuid}", shot_name) + asset = session.register( + FakeEntity( + "Asset", + { + "id": f"asset-for-{uuid}", + "name": f"{shot_name}_comp", + # Asset links to its context through context_id, not parent_id + # the way Task and Note do. + "context_id": shot["id"], + }, + ) + ) + task_type = session.register( + FakeEntity("Type", {"id": "type-1", "name": "Compositing"}) + ) + task = session.register( + FakeEntity( + "Task", + { + "id": f"task-for-{uuid}", + "name": "comp", + "type_id": task_type["id"], + "status_id": make_status(session, "In Progress")["id"], + "project_id": project["id"], + "parent_id": shot["id"], + }, + ) + ) + user = make_user(session) + return session.register( + FakeEntity( + "AssetVersion", + { + "id": uuid, + "version": number, + "comment": "first pass", + "date": datetime(2026, 4, 15, 9, 0, 0), + "is_published": True, + "thumbnail_id": "thumb-1", + "asset_id": asset["id"], + "task_id": task["id"], + "user_id": user["id"], + "project_id": project["id"], + "status_id": make_status(session, "Pending Review")["id"], + }, + ) + ) + + +def make_components(session, version, names): + """Attach components to a version, as separate rows keyed by version_id.""" + return [ + session.register( + FakeEntity( + "FileComponent", + { + "id": f"{version['id']}-{name}", + "name": name, + "version_id": version["id"], + }, + ) + ) + for name in names + ] + + +def make_review_session( + session, + project, + uuid="review-1", + name="round 2", + description=None, + versions=(), +): + review = session.register( + FakeEntity( + "ReviewSession", + { + "id": uuid, + "name": name, + "description": description, + "created_at": datetime(2026, 4, 15), + "end_date": None, + "project_id": project["id"], + }, + ) + ) + for index, version in enumerate(versions): + session.register( + FakeEntity( + "ReviewSessionObject", + { + "id": f"{uuid}-object-{index}", + "review_session_id": uuid, + "version_id": version["id"], + }, + ) + ) + return review + + +def make_playlist(session, project, uuid="list-1", name="dailies", versions=()): + return session.register( + FakeEntity( + "AssetVersionList", + { + "id": uuid, + "name": name, + "date": datetime(2026, 4, 15), + "project_id": project["id"], + "items": list(versions), + }, + ) + ) + + +# --------------------------------------------------------------------------- +# Playlist entity type setting +# --------------------------------------------------------------------------- + + +class TestPlaylistEntityTypeSetting: + """Which ftrack entity acts as a playlist is configuration, not a constant.""" + + def test_defaults_to_lists(self, provider, monkeypatch): + monkeypatch.delenv("FTRACK_PLAYLIST_ENTITY", raising=False) + assert isinstance(provider.playlist_adapter(), ListPlaylistAdapter) + assert provider.playlist_adapter().entity_type == "AssetVersionList" + + def test_env_var_selects_review_sessions(self, provider, monkeypatch): + monkeypatch.setenv("FTRACK_PLAYLIST_ENTITY", "ReviewSession") + assert isinstance(provider.playlist_adapter(), ReviewSessionPlaylistAdapter) + + def test_accepts_the_ui_facing_names(self, provider, monkeypatch): + monkeypatch.setenv("FTRACK_PLAYLIST_ENTITY", "ClientReviews") + assert provider.playlist_adapter().entity_type == "ReviewSession" + + monkeypatch.setenv("FTRACK_PLAYLIST_ENTITY", "Lists") + assert provider.playlist_adapter().entity_type == "AssetVersionList" + + def test_is_reread_per_call(self, provider, monkeypatch): + """A setting change must land without restarting the process.""" + monkeypatch.setenv("FTRACK_PLAYLIST_ENTITY", "AssetVersionList") + assert provider.playlist_adapter().entity_type == "AssetVersionList" + monkeypatch.setenv("FTRACK_PLAYLIST_ENTITY", "ClientReview") + assert provider.playlist_adapter().entity_type == "ReviewSession" + + def test_constructor_pins_the_type(self, monkeypatch): + monkeypatch.setenv("FTRACK_PLAYLIST_ENTITY", "ClientReview") + pinned = FtrackProvider( + server_url=SERVER_URL, + api_key="key", + api_user="api@example.com", + playlist_entity_type="AssetVersionList", + id_map=InMemoryIdMap(), + connect=False, + ) + assert pinned.playlist_adapter().entity_type == "AssetVersionList" + + def test_rejects_an_unknown_type(self): + with pytest.raises(ValueError, match="Unknown ftrack playlist entity type"): + resolve_playlist_adapter("Sequence") + + +# --------------------------------------------------------------------------- +# Ids +# --------------------------------------------------------------------------- + + +class TestIdTranslation: + """UUIDs never leave the provider.""" + + def test_entities_come_back_with_int_ids(self, provider, session): + project = make_project(session) + project_id = provider._to_id(project, "Project") + session.respond("from Project where", [project]) + + entity = provider.get_entity("project", project_id) + + assert isinstance(entity.id, int) + assert entity.name == "Skyfall" + + def test_the_same_uuid_always_maps_to_the_same_int(self, provider, session): + project = make_project(session) + assert provider._to_id(project, "Project") == provider._to_id( + project, "Project" + ) + + def test_an_unmapped_id_reads_as_not_found(self, provider): + with pytest.raises(ValueError, match="Entity not found: version 999"): + provider.get_entity("version", 999) + + def test_a_mapped_id_with_no_row_on_the_server_is_not_found(self, provider): + """A deleted entity keeps its mapping but has nothing behind it.""" + version_id = provider._to_id("version-deleted", "AssetVersion") + with pytest.raises(ValueError, match="Entity not found"): + provider.get_entity("version", version_id) + + +# --------------------------------------------------------------------------- +# Versions +# --------------------------------------------------------------------------- + + +class TestGetVersionsForPlaylist: + @pytest.fixture(autouse=True) + def use_lists(self, monkeypatch): + monkeypatch.setenv("FTRACK_PLAYLIST_ENTITY", "AssetVersionList") + + def _setup(self, provider, session): + project = make_project(session) + version = make_version(session, project) + playlist = session.register( + FakeEntity( + "AssetVersionList", + { + "id": "list-1", + "name": "dailies_2026_04_15", + "date": datetime(2026, 4, 15), + "project": project, + "items": [version], + }, + ) + ) + session.respond("from AssetVersionList where", [playlist]) + session.respond("from AssetVersion where id in", [version]) + session.respond( + "from Note where parent_id in", + [ + FakeEntity( + "Note", + { + "id": "note-1", + "content": "Push the grade warmer", + "parent_id": version["id"], + "parent_type": "AssetVersion", + "author": make_user(session), + "metadata": {"dna_subject": "Grade"}, + }, + ) + ], + ) + return provider._to_id(playlist, "AssetVersionList"), version + + def test_returns_fully_populated_versions(self, provider, session): + playlist_id, _ = self._setup(provider, session) + + versions = provider.get_versions_for_playlist(playlist_id) + + assert len(versions) == 1 + version = versions[0] + assert isinstance(version, Version) + assert version.name == "sh010_comp_v003" + assert version.description == "first pass" + assert version.status == "Pending Review" + assert isinstance(version.entity, Shot) + assert version.entity.name == "sh010" + assert version.task.name == "comp" + assert version.task.pipeline_step["name"] == "Compositing" + assert version.user.email == "artist@example.com" + assert version.project["name"] == "Skyfall" + + def test_attaches_notes_from_the_version(self, provider, session): + playlist_id, _ = self._setup(provider, session) + + notes = provider.get_versions_for_playlist(playlist_id)[0].notes + + assert [n.content for n in notes] == ["Push the grade warmer"] + # ftrack has no note subject; DNA's round-trips through metadata. + assert notes[0].subject == "Grade" + + def test_links_back_to_the_ftrack_web_ui(self, provider, session): + playlist_id, _ = self._setup(provider, session) + + version = provider.get_versions_for_playlist(playlist_id)[0] + + assert version.prodtrack_detail_url == ( + f"{SERVER_URL}/#slideEntityId=version-1&slideEntityType=assetversion" + ) + assert version.prodtrack_entity_detail_url == ( + f"{SERVER_URL}/#entityId=shot-for-version-1&entityType=task" + ) + + def test_thumbnails_are_proxied_not_signed_with_the_api_key( + self, provider, session + ): + playlist_id, version = self._setup(provider, session) + + result = provider.get_versions_for_playlist(playlist_id)[0] + + assert result.thumbnail == f"{BASE_URL}/api/ftrack-thumbnails/{result.id}" + assert "apiKey" not in (result.thumbnail or "") + + def test_no_thumbnail_component_means_no_url(self, provider, session): + playlist_id, version = self._setup(provider, session) + version["thumbnail_id"] = None + + assert provider.get_versions_for_playlist(playlist_id)[0].thumbnail is None + + def test_empty_playlist_returns_nothing(self, provider, session): + project = make_project(session) + playlist = session.register( + FakeEntity( + "AssetVersionList", + {"id": "list-2", "name": "empty", "project": project, "items": []}, + ) + ) + session.respond("from AssetVersionList where", [playlist]) + + playlist_id = provider._to_id(playlist, "AssetVersionList") + assert provider.get_versions_for_playlist(playlist_id) == [] + + +# --------------------------------------------------------------------------- +# Playlists +# --------------------------------------------------------------------------- + + +class TestComponentPaths: + """Path resolution is opt-in; the defaults must not invent unusable paths.""" + + def _version_with(self, provider, session, component_names): + project = make_project(session) + version = make_version(session, project) + make_components(session, version, component_names) + session.location = FakeLocation() + return version + + def _read(self, provider, session, version): + return provider.get_entity( + "version", provider._to_id(version, "AssetVersion"), resolve_links=False + ) + + @pytest.fixture(autouse=True) + def no_configured_components(self, monkeypatch): + monkeypatch.delenv("FTRACK_MOVIE_COMPONENTS", raising=False) + monkeypatch.delenv("FTRACK_FRAME_COMPONENTS", raising=False) + + def test_no_paths_by_default(self, provider, session): + """ftrackreview-* has no filesystem path; movie/main is an unreachable mount.""" + version = self._version_with( + provider, session, ["ftrackreview-mp4", "ftrackreview-webm", "movie"] + ) + + result = self._read(provider, session, version) + + assert result.movie_path is None + assert result.frame_path is None + + def test_default_costs_no_location_lookups(self, provider, session): + """Location lookups are round trips; the default must make none.""" + version = self._version_with(provider, session, ["ftrackreview-mp4", "movie"]) + + self._read(provider, session, version) + + assert session.location_lookups == [] + + def test_a_playlist_resolves_every_path_in_one_batch( + self, provider, session, monkeypatch + ): + """The whole point: 2 round trips for a playlist, not 2 per version.""" + monkeypatch.setenv("FTRACK_MOVIE_COMPONENTS", "movie") + monkeypatch.setenv("FTRACK_FRAME_COMPONENTS", "frames") + project = make_project(session) + versions = [] + for i in range(4): + version = make_version(session, project, uuid=f"version-{i}") + make_components(session, version, ["movie", "frames"]) + versions.append(version) + location = FakeLocation() + session.location = location + playlist = make_playlist(session, project, versions=versions) + + result = provider.get_versions_for_playlist( + provider._to_id(playlist, "AssetVersionList") + ) + + assert [v.movie_path for v in result] == ["/mnt/ftrack/movie"] * 4 + assert [v.frame_path for v in result] == ["/mnt/ftrack/frames"] * 4 + # One availability request and one path request, whatever the count. + assert len(session.location_lookups) == 1 + assert len(session.location_lookups[0]) == 8 + assert len(location.path_calls) == 1 + assert len(location.path_calls[0]) == 8 + # And the components themselves arrived in one query. + assert len(session.queries_against("Component")) == 1 + + def test_one_dead_location_does_not_sink_the_others( + self, provider, session, monkeypatch + ): + monkeypatch.setenv("FTRACK_MOVIE_COMPONENTS", "movie") + version = self._version_with(provider, session, ["movie"]) + dead = FakeLocation("ftrack.server") + dead.get_filesystem_paths = mock.Mock( + side_effect=NotImplementedError("get_filesystem_path") + ) + session.location = dead + + result = self._read(provider, session, version) + + assert result.movie_path is None + assert result.name == "sh010_comp_v003" + + def test_configured_names_resolve(self, provider, session, monkeypatch): + monkeypatch.setenv("FTRACK_MOVIE_COMPONENTS", "movie,main") + version = self._version_with(provider, session, ["movie"]) + + assert self._read(provider, session, version).movie_path == "/mnt/ftrack/movie" + + def test_webm_is_recognised_when_configured(self, provider, session, monkeypatch): + monkeypatch.setenv( + "FTRACK_MOVIE_COMPONENTS", "ftrackreview-mp4,ftrackreview-webm" + ) + version = self._version_with(provider, session, ["ftrackreview-webm"]) + + result = self._read(provider, session, version) + + assert result.movie_path == "/mnt/ftrack/ftrackreview-webm" + + def test_configured_order_wins_over_server_order( + self, provider, session, monkeypatch + ): + """ftrack encodes both for one version; the setting decides, not the server.""" + monkeypatch.setenv( + "FTRACK_MOVIE_COMPONENTS", "ftrackreview-mp4,ftrackreview-webm" + ) + version = self._version_with( + provider, session, ["ftrackreview-webm", "ftrackreview-mp4"] + ) + + assert ( + self._read(provider, session, version).movie_path + == "/mnt/ftrack/ftrackreview-mp4" + ) + + monkeypatch.setenv( + "FTRACK_MOVIE_COMPONENTS", "ftrackreview-webm,ftrackreview-mp4" + ) + assert ( + self._read(provider, session, version).movie_path + == "/mnt/ftrack/ftrackreview-webm" + ) + + def test_frames_resolve_separately_from_the_movie( + self, provider, session, monkeypatch + ): + monkeypatch.setenv("FTRACK_MOVIE_COMPONENTS", "movie") + monkeypatch.setenv("FTRACK_FRAME_COMPONENTS", "frames") + version = self._version_with(provider, session, ["movie", "frames"]) + + result = self._read(provider, session, version) + + assert result.movie_path == "/mnt/ftrack/movie" + assert result.frame_path == "/mnt/ftrack/frames" + + def test_unmatched_component_means_no_path(self, provider, session, monkeypatch): + monkeypatch.setenv("FTRACK_MOVIE_COMPONENTS", "movie") + version = self._version_with(provider, session, ["source-exr"]) + + assert self._read(provider, session, version).movie_path is None + + def test_an_unmounted_location_is_not_fatal(self, provider, session, monkeypatch): + monkeypatch.setenv("FTRACK_MOVIE_COMPONENTS", "movie") + version = self._version_with(provider, session, ["movie"]) + session.location = None + + result = self._read(provider, session, version) + + assert result.movie_path is None + assert result.name == "sh010_comp_v003" + + def test_defaults_are_empty(self, monkeypatch): + monkeypatch.delenv("FTRACK_MOVIE_COMPONENTS", raising=False) + assert _movie_component_names() == [] + + +class TestProjections: + """Queries stay flat, and conversion never reaches for an unfetched field. + + A dotted projection makes the ftrack server build the join; a field read + but not fetched makes it serve a request per entity. Both are invisible in + the code and both show up as a slow playlist. + """ + + @pytest.fixture(autouse=True) + def use_lists(self, monkeypatch): + monkeypatch.setenv("FTRACK_PLAYLIST_ENTITY", "AssetVersionList") + + def _load_playlist(self, provider, session, versions=2): + project = make_project(session) + rows = [ + make_version(session, project, uuid=f"version-{i}", shot_name=f"sh{i:03d}") + for i in range(versions) + ] + for row in rows: + session.register( + FakeEntity( + "Note", + { + "id": f"note-{row['id']}", + "content": "Warmer grade", + "parent_id": row["id"], + "parent_type": "AssetVersion", + "metadata": {"dna_subject": "Grade"}, + "author_id": "user-1", + }, + ) + ) + playlist = make_playlist(session, project, versions=rows) + return provider.get_versions_for_playlist( + provider._to_id(playlist, "AssetVersionList") + ) + + def test_no_query_selects_a_dotted_path(self, provider, session): + """The whole point: no `asset.parent.object_type.name` anywhere.""" + self._load_playlist(provider, session) + + for query in session.queries: + selected = query.split(" from ", 1)[0].removeprefix("select ") + dotted = [f.strip() for f in selected.split(",") if "." in f] + assert dotted == [], f"deep projection in: {query}" + + def test_the_layers_are_fetched_separately(self, provider, session): + """Versions, then assets, then contexts, then object types.""" + self._load_playlist(provider, session) + + for entity_type in ( + "AssetVersion", + "Asset", + "TypedContext", + "ObjectType", + "Task", + "Status", + "User", + "Project", + ): + assert session.queries_against(entity_type), f"never queried {entity_type}" + + def test_each_layer_is_one_query_for_the_whole_batch(self, provider, session): + versions = self._load_playlist(provider, session, versions=6) + + assert len(versions) == 6 + for entity_type in ("Asset", "TypedContext", "ObjectType", "Task", "Project"): + assert len(session.queries_against(entity_type)) == 1, entity_type + + def test_conversion_reads_nothing_it_did_not_fetch(self, provider, session): + """Any read outside a row's flat projection would be an auto-populate.""" + lazy_reads = [] + FakeEntity.lazy_reads = lazy_reads + try: + versions = self._load_playlist(provider, session) + finally: + FakeEntity.lazy_reads = None + + assert versions[0].entity.description == "A shot" + assert versions[0].task.project["name"] == "Skyfall" + assert versions[0].notes[0].subject == "Grade" + assert lazy_reads == [] + + def test_stitched_versions_carry_the_whole_shape(self, provider, session): + versions = self._load_playlist(provider, session) + + version = versions[0] + assert version.name == "sh000_comp_v003" + assert version.status == "Pending Review" + assert version.entity.name == "sh000" + assert version.task.pipeline_step["name"] == "Compositing" + assert version.user.email == "artist@example.com" + assert version.project["name"] == "Skyfall" + + def test_note_projection_includes_metadata(self): + """The note subject lives in metadata; unfetched it costs a request each.""" + from dna.prodtrack_providers.ftrack import NOTE_PROJECTION + + assert "metadata" in [f.strip() for f in NOTE_PROJECTION.split(",")] + + def test_no_projection_constant_contains_a_dotted_path(self): + from dna.prodtrack_providers import ftrack as module + + for name in dir(module): + if not name.endswith("_PROJECTION"): + continue + for field in getattr(module, name).split(","): + assert "." not in field.strip(), f"{name} has a deep path: {field}" + + +class TestBatchedReads: + """A playlist load must not scale its round trips with the version count.""" + + @pytest.fixture(autouse=True) + def use_lists(self, monkeypatch): + monkeypatch.setenv("FTRACK_PLAYLIST_ENTITY", "AssetVersionList") + + def _playlist_of(self, provider, session, count): + project = make_project(session) + versions = [ + make_version(session, project, uuid=f"version-{i}", shot_name=f"sh{i:03d}") + for i in range(count) + ] + playlist = session.register( + FakeEntity( + "AssetVersionList", + { + "id": "list-1", + "name": "dailies", + "project": project, + "items": versions, + }, + ) + ) + session.respond("from AssetVersionList where", [playlist]) + session.respond("from AssetVersion where id in", versions) + return provider._to_id(playlist, "AssetVersionList") + + def _count_id_reads(self, provider): + reads = [] + real = provider._id_map._fetch_ints + + def counting(uuids): + reads.append(list(uuids)) + return real(uuids) + + provider._id_map._fetch_ints = counting + return reads + + def test_id_lookups_do_not_grow_with_the_playlist(self, provider, session): + """Each version touches ~6 ids; unbatched that would be reads per version.""" + small = self._playlist_of(provider, session, 2) + provider.get_versions_for_playlist(small) + + session.rules.clear() + provider._id_map = InMemoryIdMap() + large = self._playlist_of(provider, session, 20) + reads = self._count_id_reads(provider) + versions = provider.get_versions_for_playlist(large) + + assert len(versions) == 20 + # A handful of batches, not one read per entity reference. + assert len(reads) <= 4 + # And the batch really did carry every version's uuid. + assert any(len(batch) > 20 for batch in reads) + + def test_warming_covers_nested_references(self, provider, session): + """Project, user, task, task type and parent context all arrive warmed.""" + playlist_id = self._playlist_of(provider, session, 3) + provider.get_versions_for_playlist(playlist_id) + + reads = self._count_id_reads(provider) + # Re-converting the same entities must now hit the cache only. + provider.get_versions_for_playlist(playlist_id) + + assert reads == [] + + def test_versions_are_still_correct_when_batched(self, provider, session): + playlist_id = self._playlist_of(provider, session, 3) + + versions = provider.get_versions_for_playlist(playlist_id) + + assert [v.name for v in versions] == [ + "sh000_comp_v003", + "sh001_comp_v003", + "sh002_comp_v003", + ] + assert len({v.id for v in versions}) == 3 + assert all(v.entity is not None for v in versions) + assert all(v.task.pipeline_step["name"] == "Compositing" for v in versions) + + +class TestGetEntity: + @pytest.fixture(autouse=True) + def use_lists(self, monkeypatch): + monkeypatch.setenv("FTRACK_PLAYLIST_ENTITY", "AssetVersionList") + + def test_reads_a_shot_with_its_tasks(self, provider, session): + project = make_project(session) + shot = session.register( + FakeEntity( + "Shot", + { + "id": "shot-1", + "name": "sh010", + "description": "A shot", + "project": project, + "object_type": {"name": "Shot"}, + }, + ) + ) + session.respond("from Shot where", [shot]) + session.respond( + "from Task where", + [ + FakeEntity( + "Task", + { + "id": "task-1", + "name": "comp", + "type": {"id": "type-1", "name": "Compositing"}, + "status": {"name": "In Progress"}, + "project": project, + "parent": shot, + }, + ) + ], + ) + + result = provider.get_entity("shot", provider._to_id(shot, "Shot")) + + assert isinstance(result, Shot) + assert [t.name for t in result.tasks] == ["comp"] + + def test_asset_builds_map_to_dna_assets(self, provider, session): + project = make_project(session) + build = session.register( + FakeEntity( + "AssetBuild", + { + "id": "build-1", + "name": "hero_car", + "description": "The car", + "project": project, + "object_type": {"name": "Asset Build"}, + }, + ) + ) + session.respond("from AssetBuild where", [build]) + + result = provider.get_entity( + "asset", provider._to_id(build, "AssetBuild"), resolve_links=False + ) + + assert isinstance(result, Asset) + assert result.name == "hero_car" + + def test_reads_a_playlist_with_its_versions(self, provider, session): + project = make_project(session) + version = make_version(session, project) + playlist = session.register( + FakeEntity( + "AssetVersionList", + { + "id": "list-1", + "name": "dailies", + "project": project, + "items": [version], + }, + ) + ) + session.respond("from AssetVersionList where", [playlist]) + session.respond("from AssetVersion where id in", [version]) + + result = provider.get_entity( + "playlist", provider._to_id(playlist, "AssetVersionList") + ) + + assert isinstance(result, Playlist) + assert result.code == "dailies" + # Versions come back enriched, not as bare membership stubs. + assert [v.name for v in result.versions] == ["sh010_comp_v003"] + assert result.versions[0].entity.name == "sh010" + + def test_shallow_read_skips_the_versions(self, provider, session): + project = make_project(session) + playlist = session.register( + FakeEntity( + "AssetVersionList", + {"id": "list-1", "name": "dailies", "project": project, "items": []}, + ) + ) + session.respond("from AssetVersionList where", [playlist]) + + result = provider.get_entity( + "playlist", + provider._to_id(playlist, "AssetVersionList"), + resolve_links=False, + ) + + assert result.versions == [] + + def test_rejects_an_unknown_entity_type(self, provider): + with pytest.raises(ValueError, match="Unknown entity type"): + provider.get_entity("sequence", 1) + + +class TestPlaylistsAsLists: + @pytest.fixture(autouse=True) + def use_lists(self, monkeypatch): + monkeypatch.setenv("FTRACK_PLAYLIST_ENTITY", "AssetVersionList") + + def test_lists_playlists_for_a_project(self, provider, session): + project = make_project(session) + make_playlist(session, project) + + result = provider.get_playlists_for_project(provider._to_id(project, "Project")) + + assert len(result) == 1 + assert isinstance(result[0], Playlist) + assert result[0].code == "dailies" + assert result[0].created_at == datetime(2026, 4, 15) + + def test_creates_a_list_with_a_category_and_owner(self, provider, session): + project = make_project(session) + session.respond( + "from ListCategory", + [FakeEntity("ListCategory", {"id": "cat-1", "name": "Dailies"})], + ) + session.respond("from User where username", [make_user(session)]) + + result = provider.create_playlist( + provider._to_id(project, "Project"), "dailies_2026_04_15" + ) + + created = session.created[-1] + assert created.entity_type == "AssetVersionList" + assert created["name"] == "dailies_2026_04_15" + assert created["category"]["name"] == "Dailies" + assert created["owner"] is not None + assert session.commits == 1 + assert result.code == "dailies_2026_04_15" + + def test_adds_a_version_to_a_list(self, provider, session): + project = make_project(session) + version = make_version(session, project) + playlist = session.register( + FakeEntity( + "AssetVersionList", + {"id": "list-1", "name": "dailies", "project": project, "items": []}, + ) + ) + + added = provider.add_version_to_playlist( + provider._to_id(playlist, "AssetVersionList"), + provider._to_id(version, "AssetVersion"), + ) + + assert added is True + assert [item["id"] for item in playlist["items"]] == ["version-1"] + assert session.commits == 1 + + def test_adding_a_version_twice_is_a_no_op(self, provider, session): + project = make_project(session) + version = make_version(session, project) + playlist = session.register( + FakeEntity( + "AssetVersionList", + { + "id": "list-1", + "name": "dailies", + "project": project, + "items": [version], + }, + ) + ) + + added = provider.add_version_to_playlist( + provider._to_id(playlist, "AssetVersionList"), + provider._to_id(version, "AssetVersion"), + ) + + assert added is True + assert len(playlist["items"]) == 1 + assert session.commits == 0 + + def test_unknown_playlist_raises(self, provider, session): + project = make_project(session) + version = make_version(session, project) + missing = provider._to_id("list-gone", "AssetVersionList") + + with pytest.raises(ValueError, match="Playlist .* not found"): + provider.add_version_to_playlist( + missing, provider._to_id(version, "AssetVersion") + ) + + +class TestPlaylistsAsClientReviews: + @pytest.fixture(autouse=True) + def use_review_sessions(self, monkeypatch): + monkeypatch.setenv("FTRACK_PLAYLIST_ENTITY", "ClientReview") + + def test_lists_review_sessions_for_a_project(self, provider, session): + project = make_project(session) + make_review_session( + session, project, name="client round 2", description="for the client" + ) + + result = provider.get_playlists_for_project(provider._to_id(project, "Project")) + + assert [p.code for p in result] == ["client round 2"] + assert result[0].description == "for the client" + + def test_creates_a_review_session(self, provider, session): + project = make_project(session) + + provider.create_playlist(provider._to_id(project, "Project"), "client round 2") + + created = session.created[-1] + assert created.entity_type == "ReviewSession" + assert created["name"] == "client round 2" + assert session.commits == 1 + + def test_adds_a_version_through_a_review_session_object(self, provider, session): + project = make_project(session) + version = make_version(session, project) + review = make_review_session(session, project) + + added = provider.add_version_to_playlist( + provider._to_id(review, "ReviewSession"), + provider._to_id(version, "AssetVersion"), + ) + + assert added is True + created = session.created[-1] + assert created.entity_type == "ReviewSessionObject" + assert created["version_id"] == version["id"] + assert created["review_session_id"] == review["id"] + assert created["name"] == "sh010_comp_v003" + + def test_reads_versions_through_review_session_objects(self, provider, session): + project = make_project(session) + version = make_version(session, project) + review = make_review_session(session, project, versions=[version]) + + versions = provider.get_versions_for_playlist( + provider._to_id(review, "ReviewSession") + ) + + assert [v.name for v in versions] == ["sh010_comp_v003"] + + +# --------------------------------------------------------------------------- +# Users and search +# --------------------------------------------------------------------------- + + +class TestUsers: + def test_get_user_by_email(self, provider, session): + session.respond("from User where email", [make_user(session)]) + + user = provider.get_user_by_email("artist@example.com") + + assert user.name == "Ada Lovelace" + assert user.login == "artist" + + def test_missing_user_raises(self, provider, session): + with pytest.raises(ValueError, match="User not found"): + provider.get_user_by_email("nobody@example.com") + + def test_projects_for_user_requires_the_user_to_exist(self, provider, session): + with pytest.raises(ValueError, match="User not found"): + provider.get_projects_for_user("nobody@example.com") + + def test_projects_for_user_returns_active_projects(self, provider, session): + session.respond("from User where email", [make_user(session)]) + session.respond("from Project where status", [make_project(session)]) + + projects = provider.get_projects_for_user("artist@example.com") + + assert [p.name for p in projects] == ["Skyfall"] + + +class TestSearch: + def test_searches_users_by_name_and_email(self, provider, session): + session.respond("from User where", [make_user(session)]) + + results = provider.search("ada", ["user"]) + + assert results == [ + { + "type": "User", + "id": mock.ANY, + "name": "Ada Lovelace", + "email": "artist@example.com", + } + ] + assert "first_name like" in session.queries[-1] + + def test_scopes_shots_to_a_project(self, provider, session): + project = make_project(session) + make_context(session, project, "shot-1", "sh010") + + results = provider.search( + "sh0", ["shot"], project_id=provider._to_id(project, "Project") + ) + + assert results[0]["type"] == "Shot" + assert results[0]["name"] == "sh010" + assert results[0]["project"]["name"] == "Skyfall" + # Scoped on the flat foreign key, not a `project.id` join. + shot_queries = session.queries_against("Shot") + assert any('project_id is "project-1"' in q for q in shot_queries) + assert not any("project.id" in q for q in shot_queries) + + def test_rejects_an_unsupported_type(self, provider): + with pytest.raises(ValueError, match="Unsupported entity type"): + provider.search("x", ["sequence"]) + + +# --------------------------------------------------------------------------- +# Find +# --------------------------------------------------------------------------- + + +class TestFind: + def test_translates_dna_filters_into_ftrack_expressions(self, provider, session): + project = make_project(session) + + provider.find( + "shot", + [ + { + "field": "project", + "operator": "is", + "value": { + "type": "Project", + "id": provider._to_id(project, "Project"), + }, + }, + {"field": "name", "operator": "contains", "value": "sh0"}, + ], + ) + + expression = session.queries_against("Shot")[0] + assert 'project_id is "project-1"' in expression + assert 'name like "%sh0%"' in expression + + def test_rejects_an_unknown_field(self, provider): + with pytest.raises(ValueError, match="Unknown field 'colour'"): + provider.find("shot", [{"field": "colour", "operator": "is", "value": "x"}]) + + def test_rejects_an_unsupported_operator(self, provider): + with pytest.raises(ValueError, match="Unsupported filter operator"): + provider.find( + "shot", [{"field": "name", "operator": "starts_with", "value": "x"}] + ) + + def test_applies_the_limit(self, provider, session): + project = make_project(session) + for i in range(5): + make_context(session, project, f"shot-{i}", f"sh{i:03d}") + + assert len(provider.find("shot", [], limit=2)) == 2 + + def test_filtered_queries_ask_only_for_ids(self, provider, session): + """The filtered query is the worst place for a join; keep it minimal.""" + project = make_project(session) + make_context(session, project, "shot-1", "sh010") + + results = provider.find( + "shot", + [ + { + "field": "project", + "operator": "is", + "value": provider._to_id(project, "Project"), + } + ], + ) + + assert [r.name for r in results] == ["sh010"] + assert session.queries_against("Shot") == [ + 'select id from Shot where project_id is "project-1"' + ] + + +# --------------------------------------------------------------------------- +# Notes +# --------------------------------------------------------------------------- + + +class TestPublishNote: + def test_creates_a_note_on_the_version(self, provider, session): + project = make_project(session) + version = make_version(session, project) + author = make_user(session, uuid="user-2", email="lead@example.com") + recipient = make_user(session) + session.respond("from User where email", [author]) + session.respond("from User where id in", [recipient]) + session.respond("from Note where parent_id is", []) + + note_id = provider.publish_note( + version_id=provider._to_id(version, "AssetVersion"), + content="Warmer grade please", + subject="Grade", + to_users=[provider._to_id(recipient, "User")], + cc_users=[], + links=[], + author_email="lead@example.com", + ) + + created = session.created_notes[-1] + assert created["content"] == "Warmer grade please" + assert created["author"] is author + assert created["parent_id"] == "version-1" + # ftrack has no subject field; DNA's goes to metadata. + assert created["metadata"]["dna_subject"] == "Grade" + assert len(created.recipients) == 1 + assert isinstance(note_id, int) + + def test_recipients_resolve_in_one_query(self, provider, session): + """to_users and cc_users together, however many, cost one lookup.""" + project = make_project(session) + version = make_version(session, project) + recipients = [ + make_user(session, uuid=f"user-{i}", email=f"r{i}@example.com") + for i in range(5) + ] + session.respond("from User where username", [recipients[0]]) + session.respond("from User where id in", recipients) + session.respond("from Note where parent_id is", []) + + provider.publish_note( + version_id=provider._to_id(version, "AssetVersion"), + content="Notes for everyone", + subject="Round 2", + to_users=[provider._to_id(u, "User") for u in recipients[:3]], + cc_users=[provider._to_id(u, "User") for u in recipients[3:]], + links=[], + ) + + assert len(session.created_notes[-1].recipients) == 5 + user_queries = [q for q in session.queries if "from User where id in" in q] + assert len(user_queries) == 1 + + def test_is_idempotent_on_identical_content(self, provider, session): + project = make_project(session) + version = make_version(session, project) + existing = FakeEntity( + "Note", + { + "id": "note-existing", + "content": "Warmer grade please", + "parent_id": "version-1", + "author": make_user(session), + "metadata": {"dna_subject": "Grade"}, + }, + ) + session.respond("from User where username", [make_user(session)]) + session.respond("from Note where parent_id is", [existing]) + + note_id = provider.publish_note( + version_id=provider._to_id(version, "AssetVersion"), + content="Warmer grade please", + subject="Grade", + to_users=[], + cc_users=[], + links=[], + ) + + assert note_id == provider._to_id(existing, "Note") + assert session.created_notes == [] + + def test_unknown_author_raises_user_not_found(self, provider, session): + project = make_project(session) + version = make_version(session, project) + session.respond("from Note where parent_id is", []) + + with pytest.raises(UserNotFoundError, match="Author not found in ftrack"): + provider.publish_note( + version_id=provider._to_id(version, "AssetVersion"), + content="hi", + subject="s", + to_users=[], + cc_users=[], + links=[], + author_email="ghost@example.com", + ) + + def test_sets_the_version_status(self, provider, session): + project = make_project(session, statuses=["Approved"]) + version = make_version(session, project) + session.respond("from Note where parent_id is", []) + + provider.publish_note( + version_id=provider._to_id(version, "AssetVersion"), + content="Looks good", + subject="Approval", + to_users=[], + cc_users=[], + links=[], + version_status="Approved", + ) + + assert version["status"]["name"] == "Approved" + + +class TestPublishPlaylistNote: + @pytest.fixture(autouse=True) + def use_lists(self, monkeypatch): + monkeypatch.setenv("FTRACK_PLAYLIST_ENTITY", "AssetVersionList") + + def test_falls_back_to_the_project_when_the_type_takes_no_notes( + self, provider, session + ): + project = make_project(session) + playlist = session.register( + FakeEntity( + "AssetVersionList", + {"id": "list-1", "name": "dailies", "project": project, "items": []}, + ) + ) + session.respond("from AssetVersionList where", [playlist]) + session.respond("from User where username", [make_user(session)]) + session.respond("from Note where parent_id is", []) + # session.types has no AssetVersionList -> the schema has no notes relation. + + provider.publish_playlist_note( + playlist_id=provider._to_id(playlist, "AssetVersionList"), + content="General notes", + subject="Session", + to_users=[], + cc_users=[], + links=[], + ) + + created = session.created_notes[-1] + assert created["parent_type"] == "Project" + assert created["content"] == "[dailies]\nGeneral notes" + + def test_attaches_to_the_playlist_when_the_schema_allows_it( + self, provider, session + ): + project = make_project(session) + playlist = session.register( + FakeEntity( + "AssetVersionList", + {"id": "list-1", "name": "dailies", "project": project, "items": []}, + ) + ) + session.respond("from AssetVersionList where", [playlist]) + session.respond("from User where username", [make_user(session)]) + session.respond("from Note where parent_id is", []) + session.types["AssetVersionList"] = mock.Mock(attributes={"notes": object()}) + + provider.publish_playlist_note( + playlist_id=provider._to_id(playlist, "AssetVersionList"), + content="General notes", + subject="Session", + to_users=[], + cc_users=[], + links=[], + ) + + created = session.created_notes[-1] + assert created["parent_type"] == "AssetVersionList" + assert created["content"] == "General notes" + + +class TestAddEntity: + def test_creates_a_note_against_its_first_link(self, provider, session): + project = make_project(session) + version = make_version(session, project) + session.respond("from User where username", [make_user(session)]) + + result = provider.add_entity( + "note", + Note( + id=0, + subject="Grade", + content="Warmer", + note_links=[Version(id=provider._to_id(version, "AssetVersion"))], + ), + ) + + assert isinstance(result, Note) + assert session.created_notes[-1]["parent_id"] == "version-1" + + def test_requires_a_link(self, provider): + with pytest.raises(ValueError, match="needs a note_link"): + provider.add_entity("note", Note(id=0, content="orphan")) + + def test_other_entity_types_are_not_supported(self, provider): + with pytest.raises(NotImplementedError): + provider.add_entity("shot", Shot(id=1, name="sh010")) + + +class TestAttachFileToNote: + def test_uploads_and_links_a_component(self, provider, session, tmp_path): + note = session.register( + FakeEntity("Note", {"id": "note-1", "content": "x", "metadata": {}}) + ) + path = tmp_path / "frame.jpg" + path.write_bytes(b"jpeg") + session.create_component = mock.Mock( + return_value=FakeEntity("FileComponent", {"id": "component-1"}) + ) + + assert provider.attach_file_to_note( + provider._to_id(note, "Note"), str(path), "frame.jpg" + ) + + session.create_component.assert_called_once() + assert session.created[-1].entity_type == "NoteComponent" + + def test_reports_failure_instead_of_raising(self, provider, session, tmp_path): + note = session.register( + FakeEntity("Note", {"id": "note-1", "content": "x", "metadata": {}}) + ) + session.create_component = mock.Mock(side_effect=RuntimeError("no location")) + + assert not provider.attach_file_to_note( + provider._to_id(note, "Note"), str(tmp_path / "missing.jpg"), "x.jpg" + ) + assert session.rollbacks == 1 + + +# --------------------------------------------------------------------------- +# Statuses +# --------------------------------------------------------------------------- + + +class TestVersionStatuses: + def test_reads_statuses_from_the_project_schema(self, provider, session): + project = make_project(session, statuses=["Pending Review", "Approved"]) + + statuses = provider.get_version_statuses(provider._to_id(project, "Project")) + + # ftrack statuses have no short code; the name doubles as one. + assert statuses == [ + {"code": "Pending Review", "name": "Pending Review"}, + {"code": "Approved", "name": "Approved"}, + ] + + def test_update_sets_a_matching_status(self, provider, session): + project = make_project(session, statuses=["Approved"]) + version = make_version(session, project) + + assert provider.update_version_status( + provider._to_id(version, "AssetVersion"), "Approved" + ) + assert version["status"]["name"] == "Approved" + + def test_update_refuses_a_status_outside_the_schema(self, provider, session): + project = make_project(session, statuses=["Approved"]) + version = make_version(session, project) + + assert not provider.update_version_status( + provider._to_id(version, "AssetVersion"), "Omitted" + ) + + def test_the_schema_is_read_once_per_publish_round(self, provider, session): + """A publish updates every version; the schema must not be re-read.""" + project = make_project(session, statuses=["Approved"]) + versions = [ + make_version(session, project, uuid=f"version-{i}") for i in range(5) + ] + + for version in versions: + assert provider.update_version_status( + provider._to_id(version, "AssetVersion"), "Approved" + ) + + assert len(session.queries_against("Project")) == 1 + + +# --------------------------------------------------------------------------- +# Transcripts +# --------------------------------------------------------------------------- + + +class TestTranscripts: + def test_transcripts_are_notes(self, provider): + assert provider.transcript_entity_type() == "Note" + + def test_publish_records_the_meeting_on_the_note(self, provider, session): + project = make_project(session) + version = make_version(session, project) + session.respond("from User where username", [make_user(session)]) + + entity_id = provider.publish_transcript( + project_id=provider._to_id(project, "Project"), + playlist_id=42, + version_id=provider._to_id(version, "AssetVersion"), + meeting_id="meet-1", + meeting_date=date(2026, 4, 15), + platform="google_meet", + body="Speaker: hello", + ) + + created = session.created_notes[-1] + assert created["content"] == "Speaker: hello" + assert created["metadata"]["dna_kind"] == "transcript" + assert created["metadata"]["dna_meeting_id"] == "meet-1" + assert created["metadata"]["dna_meeting_date"] == "2026-04-15" + assert created["metadata"]["dna_platform"] == "google_meet" + assert isinstance(entity_id, int) + + def test_update_rewrites_body_and_date(self, provider, session): + note = session.register( + FakeEntity("Note", {"id": "note-1", "content": "old", "metadata": {}}) + ) + + assert provider.update_transcript( + entity_type="Note", + entity_id=provider._to_id(note, "Note"), + body="Speaker: updated", + meeting_date=date(2026, 4, 16), + ) + assert note["content"] == "Speaker: updated" + assert note["metadata"]["dna_meeting_date"] == "2026-04-16" + + def test_update_of_an_unknown_row_reports_failure(self, provider): + assert not provider.update_transcript( + entity_type="Note", + entity_id=999, + body="x", + meeting_date=date(2026, 4, 16), + ) + + +# --------------------------------------------------------------------------- +# Wiring +# --------------------------------------------------------------------------- + + +class TestProviderFactory: + def test_builds_an_ftrack_provider(self): + with mock.patch.dict( + os.environ, + { + "PRODTRACK_PROVIDER": "ftrack", + "FTRACK_SERVER": SERVER_URL, + "FTRACK_API_KEY": "key", + "FTRACK_API_USER": "api@example.com", + "FTRACK_ID_MAP": "memory", + }, + ), mock.patch("dna.prodtrack_providers.ftrack.ftrack_api.Session") as session: + provider = get_prodtrack_provider() + + assert isinstance(provider, FtrackProvider) + session.assert_called_once_with( + server_url=SERVER_URL, + api_key="key", + api_user="api@example.com", + auto_connect_event_hub=False, + ) + + def test_missing_credentials_are_reported(self): + with mock.patch.dict(os.environ, {"PRODTRACK_PROVIDER": "ftrack"}, clear=True): + with pytest.raises(ValueError, match="ftrack credentials not provided"): + get_prodtrack_provider() + + def test_operations_before_connecting_are_refused(self): + provider = FtrackProvider( + server_url=SERVER_URL, + api_key="key", + api_user="api@example.com", + id_map=InMemoryIdMap(), + connect=False, + ) + with pytest.raises(ValueError, match="Not connected to ftrack"): + provider.get_user_by_email("artist@example.com") diff --git a/backend/tests/providers/test_prodtrack_provider_base.py b/backend/tests/providers/test_prodtrack_provider_base.py index 57f32e17..afd382d9 100644 --- a/backend/tests/providers/test_prodtrack_provider_base.py +++ b/backend/tests/providers/test_prodtrack_provider_base.py @@ -23,6 +23,11 @@ def test_publish_transcript_raises_not_implemented(self): body="Speaker: hi", ) + def test_transcript_entity_type_raises_not_implemented(self): + provider = ProdtrackProviderBase() + with pytest.raises(NotImplementedError): + provider.transcript_entity_type() + def test_update_transcript_raises_not_implemented(self): provider = ProdtrackProviderBase() with pytest.raises(NotImplementedError): diff --git a/backend/tests/test_publish_transcript_endpoint.py b/backend/tests/test_publish_transcript_endpoint.py index 4dc1cf99..b96a5381 100644 --- a/backend/tests/test_publish_transcript_endpoint.py +++ b/backend/tests/test_publish_transcript_endpoint.py @@ -81,6 +81,9 @@ def mock_prodtrack(self): # version.project.id. version.project = {"type": "Project", "id": 1} p.get_entity.return_value = version + # The endpoint asks the provider where it writes transcripts rather + # than reading ShotGrid's env var, so the mock has to answer. + p.transcript_entity_type.return_value = "CustomEntity01" return p @pytest.fixture @@ -343,11 +346,10 @@ def test_update_path_uses_stored_entity_type_not_current_env( ) mock_prodtrack.update_transcript.return_value = True - # Flip the env to CustomEntity05; id 9001 still belongs to CustomEntity01. - with mock.patch.dict( - os.environ, - {**ENABLE_FLAG, "SHOTGRID_TRANSCRIPT_ENTITY": "CustomEntity05"}, - ): + # The provider now reports CustomEntity05; id 9001 still belongs to + # CustomEntity01. + mock_prodtrack.transcript_entity_type.return_value = "CustomEntity05" + with mock.patch.dict(os.environ, ENABLE_FLAG): response = client.post( "/playlists/42/publish-transcript", json={"version_id": 101}, From 97baa7db80ff2099ca7b231b6cfcd6da893f72cd Mon Sep 17 00:00:00 2001 From: Erik Johansson Date: Wed, 23 Sep 2026 11:05:20 +0200 Subject: [PATCH 2/5] build: add nix flake for host development environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provides the toolchain that was missing on the host and blocked running the backend tests outside Docker: python311 (matching backend/Dockerfile), uv, nodejs_22, mongodb-ce, black, isort and docker-compose. Also packages the two prodtrack SDKs that are absent from nixpkgs, which pulls in two more: shotgun_api3 3.9.2 github shotgunsoftware/python-api ftrack-python-api 3.0.6 github ftrackhq/ftrack-python-api clique 1.6.1 PyPI (not tagged on GitHub past 1.5.0) arrow 0.17.0 github arrow-py/arrow arrow is pinned below 1.0 deliberately. ftrack calls arrow.now().replace(tzinfo="utc") in entity/user.py, and arrow 1.0 removed tzinfo from .replace(); nixpkgs ships 1.4, which would raise on a path get_versions_for_playlist reaches. pyparsing and websocket-client are relaxed instead, since they are only used by the event hub and the provider connects with auto_connect_event_hub=False. ftrack needs build patching: upstream uses poetry-dynamic-versioning, which reads the version from a git tag, so a source tarball would build as 0.1.0. The derivation pins the version, swaps to plain poetry-core, and drops sphinx-notfound-page (declared as a runtime dep but docs-only). The shell does not install backend/requirements.txt automatically — it prints the uv commands instead, so entering it is fast and works offline. Tests still run in Docker. Adds backend/docs/ftrack-provider-handoff.md covering the provider's design, its verified and unverified assumptions, and this flake. Co-Authored-By: Claude Opus 5 (1M context) --- backend/docs/ftrack-provider-handoff.md | 356 ++++++++++++++++++++++++ flake.lock | 61 ++++ flake.nix | 224 +++++++++++++++ 3 files changed, 641 insertions(+) create mode 100644 backend/docs/ftrack-provider-handoff.md create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/backend/docs/ftrack-provider-handoff.md b/backend/docs/ftrack-provider-handoff.md new file mode 100644 index 00000000..a4d6c597 --- /dev/null +++ b/backend/docs/ftrack-provider-handoff.md @@ -0,0 +1,356 @@ +# ftrack provider — status and handoff + +Working notes for picking this up in a fresh session. Written 2026-09-23. + +**Bottom line:** the provider is written and self-consistent, but it has never +run against a real ftrack server, and the repo's own test suite has never run +against it either. Treat everything below marked "unverified" as a claim, not a +fact. + +--- + +## 1. What exists + +### New files + +| File | Lines | What it is | +|---|---|---| +| `src/dna/prodtrack_providers/ftrack.py` | ~2060 | The provider, playlist adapters, layered hydration | +| `src/dna/prodtrack_providers/ftrack_id_map.py` | ~420 | UUID ↔ int surrogate id store (memory / sqlite / mongodb) | +| `tests/providers/test_ftrack_provider.py` | ~1795 | Provider tests + a fake ftrack session with a query engine | +| `tests/providers/test_ftrack_id_map.py` | ~264 | Id map tests, incl. batching and collisions | + +### Modified files + +| File | Change | +|---|---| +| `prodtrack_provider_base.py` | Registers `PRODTRACK_PROVIDER=ftrack`; adds `transcript_entity_type()` to the base contract | +| `shotgrid.py` | Implements `transcript_entity_type()` (returns its existing env-driven slot) | +| `src/main.py` | `publish_transcript` asks the provider for the entity type instead of reading `SHOTGRID_TRANSCRIPT_ENTITY`; adds `GET /api/ftrack-thumbnails/{version_id}` | +| `tests/test_publish_transcript_endpoint.py` | Mock provider answers `transcript_entity_type()` | +| `tests/providers/test_prodtrack_provider_base.py` | Base-contract test for the new method | +| `requirements.txt` | `ftrack-python-api==3.0.6` | +| `README.md`, `QUICKSTART.md`, `DEPLOYMENT.md`, `example.docker-compose.local.yml` | ftrack section + env tables | + +### Feature coverage + +Full `ProdtrackProviderBase` surface: `get_entity`, `add_entity` (notes only), +`find`, `search`, `get_user_by_email`, `get_projects_for_user`, +`get_playlists_for_project`, `create_playlist`, `get_versions_for_playlist`, +`add_version_to_playlist`, `get_version_statuses`, `update_version_status`, +`publish_note`, `publish_playlist_note`, `attach_file_to_note`, +`publish_transcript`, `update_transcript`, `transcript_entity_type`. + +--- + +## 2. The three design decisions worth knowing + +### 2.1 Surrogate int ids (user-chosen) + +ftrack keys entities by UUID; DNA ids are `int` throughout `EntityBase`, the +FastAPI path params, the Mongo bookkeeping rows and the frontend TS interfaces. +The user chose **surrogate ints with a persistent map** over widening DNA's +types. + +`ftrack_id_map.py` derives a stable int (truncated blake2b, inside the 2^53 +range JS can represent) and persists the pairing so the *reverse* lookup works +on any instance after any restart. Collisions re-salt and probe. + +> **Operational constraint:** the map must outlive the process. Draft notes, +> transcripts and stored segments all reference these ids; losing the map +> orphans them. `FTRACK_ID_MAP=mongodb` (default) shares one map across +> instances. `sqlite` is single-host only. Cloud Run with sqlite would silently +> break. + +### 2.2 Playlist entity type is a setting + +ftrack has two candidates: `AssetVersionList` (Lists, the default) and +`ReviewSession` (Client Reviews). Both are implemented as `PlaylistAdapter` +subclasses. `FtrackProvider.playlist_adapter()` resolves the type **per call**, +so a change takes effect without a restart. + +**This is the seam for the eventual user-facing setting.** When that lands, only +`playlist_adapter()` needs to learn how to read the preference — nothing built +on top of it changes. `FTRACK_PLAYLIST_ENTITY` accepts schema names and UI names +(`Lists`, `ClientReviews`). + +### 2.3 Flat queries, hydrated in layers (user-directed) + +Projections never use a dotted path. Per the user: deep projections like +`asset.parent.object_type.name` make the ftrack server build the joins and are +reliably slower than several shallow queries. + +`_hydrate_versions` walks the layers — versions → assets → parent contexts → +object types, with statuses, users, tasks and projects alongside — and stitches +the nested shape the converters expect via a small `_Stitched` dict subclass. +Each layer is one `id in (...)` query for the whole batch, so a playlist costs +~10 queries whatever its size. + +Filters use flat foreign keys (`project_id`) for the same reason. **One join +remains**: a version's `entity` filter is `asset.context_id`, because a version +reaches its context through the asset in between. Flattening that would need a +two-step query in `_filter_clause`; not done. + +Two tests guard the invariant: one rejects a dotted path in any `*_PROJECTION` +constant, another rejects one in any query a playlist load issues. + +--- + +## 3. Other batching + +- **Id warming.** `_warm_ids(entities)` pre-maps every uuid a batch will need + (a version touches 6: itself, project, user, task, task type, parent context) + in one store read. It is **type-aware** — it walks each type's own references + rather than probing attribute names, because a blind probe is only safe while + no type carries an unprojected attribute by that name. Wired into playlist + reads, `find`, all three `search` helpers, project and playlist listings. +- **Component paths** go through `pick_locations` / `get_filesystem_paths`: one + availability request plus one per location, not two per version. +- **Note recipients** resolve in one `where id in (...)` query. +- **Project and status lookups** are cached for `FTRACK_CACHE_SECONDS` + (default 300, `0` disables). The TTL exists because the provider is a + process-lifetime `lru_cache` singleton — a schema edit must not need a + redeploy. +- **version → project** is cached without expiry (a version never moves), and + filled in by every playlist read, so a publish round skips the lookup. + +--- + +## 4. Environment variables + +| Variable | Default | Notes | +|---|---|---| +| `PRODTRACK_PROVIDER` | `shotgrid` | Set to `ftrack` | +| `FTRACK_SERVER` | – | Required | +| `FTRACK_API_KEY` | – | Required | +| `FTRACK_API_USER` | – | Required | +| `FTRACK_PLAYLIST_ENTITY` | `AssetVersionList` | Or `ReviewSession` / `ClientReviews` / `Lists` | +| `FTRACK_ID_MAP` | `mongodb` | `mongodb` \| `sqlite` \| `memory` | +| `FTRACK_ID_MAP_PATH` | `/tmp/dna_ftrack_id_map.db` | sqlite backend only | +| `FTRACK_CACHE_SECONDS` | `300` | Project + status lookups; `0` disables | +| `FTRACK_LIST_CATEGORY` | – | ListCategory name for created Lists; falls back to the first on the server | +| `FTRACK_MOVIE_COMPONENTS` | *(empty)* | Comma-separated, most preferred first. Empty = no `movie_path` | +| `FTRACK_FRAME_COMPONENTS` | *(empty)* | Same, for `frame_path` | + +--- + +## 5. Known behavioural gaps vs ShotGrid + +These are deliberate and documented in the README, not bugs — but they are +places the UI may look different on ftrack. + +| Area | Behaviour | +|---|---| +| Note subject | ftrack notes have no subject field; DNA's round-trips through note `metadata` under `dna_subject`. If a server rejects note metadata it is dropped (content is kept). | +| Note cc | ftrack has one recipient list; `cc_users` merge into `to_users`. | +| Note links | ftrack notes attach to exactly one parent; extra `links` are dropped. | +| `updated_at` | Always `None` — `AssetVersion` records creation only. | +| `movie_path` / `frame_path` | Empty unless opted into. `ftrackreview-*` components live in the ftrack.server location, whose `ServerAccessor` has no `get_filesystem_path` at all; `movie`/`main` resolve to studio mounts a containerised DNA cannot read. | +| Thumbnails | Proxied via `GET /api/ftrack-thumbnails/{version_id}` — ftrack's own URL carries the API key in the query string. **The endpoint is unauthenticated**, mirroring the existing `/api/mock-thumbnails/` pattern (an `` cannot send a bearer token). Revisit if that is not acceptable. | +| Transcripts | Stored as a Note on the version with `dna_*` metadata; ftrack has no equivalent of ShotGrid's spare custom-entity slots. `transcript_entity_type()` returns `"Note"`. | +| `get_projects_for_user` | Returns all active projects after confirming the user exists — ftrack grants access by security role, not a per-project member list. | +| Playlist notes | If the configured playlist type has no `notes` relation on that server's schema, the note lands on the **project** with `[playlist name]` as the first line. | +| Status codes | ftrack statuses have no short code; the name doubles as the code and is resolved back by name. | +| `search` | Silently skips `project`/`task`/`note`/`playlist` types (matches ShotGrid, which skips types with no name field). Raises for genuinely unknown types. | + +--- + +## 6. What is NOT verified — read this before trusting anything + +### 6.1 Schema names checked; nothing has actually run + +Every ftrack interaction is asserted against a fake, so the schema names the +provider uses started out as assumptions. All of them have since been checked by +hand against a real server (2026-09-23), and two were wrong — both foreign keys, +listed below. + +What remains unverified is *behaviour*, not names: no read or write has yet been +executed against a live server. See §6.2 and §7. + +Both corrections were also wrong in the test fixtures, so the suite stayed green +through both. A fake written from the same assumptions cannot catch a wrong +assumption — which is the whole reason §7 step 2 exists. + +**Confirmed:** + +- `Project.project_schema_id` — verified by manual check against a real server, + 2026-09-23. The `get_statuses("AssetVersion")` helper it feeds is also sound: + `ftrack_api/entity/project_schema.py` handles that schema name explicitly, via + the `_version_workflow` branch. +- `Asset` links to its context through **`context_id`**, not `parent_id` — + corrected 2026-09-23 after it was flagged as wrong. Worth remembering that + ftrack is not uniform here: `Task` and `Note` do use `parent_id`, and those + uses in the provider are correct. When checking the rest of this list, verify + each foreign key by name rather than assuming the pattern carries over. +- `Component.version_id` — confirmed 2026-09-23. `_hydrate_components` groups on + it. +- `TypedContext` is queryable by id and returns concrete subclasses (`Shot`, + `AssetBuild`) carrying a usable `entity_type` — confirmed 2026-09-23. This is + what lets `_stitch_contexts` fetch every parent in one query and still pick + the right DNA model. +- `ObjectType`, `Type` and `Status` are queryable by id with `id, name` — + confirmed 2026-09-23. +- `AssetVersionList.items` is the member collection, `ListCategory` exists, and + `owner` / `category` are accepted on create — confirmed 2026-09-23. Covers the + default (Lists) playlist adapter end to end. +- `Note.metadata` is projectable and writable — confirmed 2026-09-23. This is + where the DNA note subject lives (`dna_subject`) and where transcripts keep + their `dna_*` bookkeeping. +- `NoteComponent` with `component_id` / `note_id` is the attachment join — + confirmed 2026-09-23. +- `Project` supports `where status is active` — confirmed 2026-09-23. +- `User.is_active` exists — confirmed 2026-09-23. Used to scope user search. +- `ReviewSessionObject` links to the version through **`version_id`**, not + `asset_version_id` — corrected 2026-09-23 after it was flagged as wrong. + `review_session_id` and the writable `name` / `version` / `description` + display fields are confirmed, as are the `review_session` / `asset_version` + relationship attributes (confirmed 2026-09-23). Two things to keep straight + when reading this entity: the relationship is `asset_version` while its column + is `version_id`, and that column sits beside a separate string field also + called `version` holding the display copy of the version number. `add_version` + links by column, matching how the rest of the adapter queries; either form + works. + +### 6.2 The repo's test suite has never run + +At the time this was written no `fastapi` or `pydantic` was installable on the +host (the configured devpi index has neither) and Docker was unavailable, so +`make test` never ran. The flake added since (§9) supplies the interpreter and +both prodtrack SDKs, but the rest of `requirements.txt` still needs a working +index — so this is not resolved yet, only made easier. + +The 123 new tests pass under a **throwaway pydantic shim** in a scratchpad — +which exercises logic, query shape and round-trip counts, but **not pydantic +field-type validation**. The entity models are the part this cannot vouch for: +if e.g. `Version.created_at` gets a type the model rejects, only the real suite +will show it. + +**First action in a new session: run `make test` in `backend/`.** + +Also unrun: the modified `test_publish_transcript_endpoint.py` and +`test_prodtrack_provider_base.py`. + +### 6.3 Performance is unmeasured + +The layered-query design follows the user's stated experience that deep +projections are slow. Tests verify query *shape and count*, never latency. +Worth timing one real playlist load to confirm the win. + +### 6.4 Formatting + +Local `black` is 22.8; the repo was formatted with a newer version. Running +`black` on `src/main.py` will try to reformat one pre-existing, untouched line +(`Strict-Transport-Security`). That was deliberately left in the repo's style — +don't "fix" it. + +--- + +## 7. Suggested order of work + +1. **Run `make test`.** Fix whatever pydantic validation surfaces. +2. **Run it against a sandbox project.** The schema names are checked (§6.1) but + nothing has actually executed: load a playlist, publish a note with a subject + and an attachment, set a status, create a playlist. Reading is the low-risk + half; the writes are where a wrong assumption still bites. +3. **Time a playlist load** with a realistic version count; confirm the layered + fetch beats a deep projection on that server. +4. **Decide on the thumbnail endpoint's auth** (§5) before anything ships + externally. +5. **Wire the playlist-type user setting** — the seam is `playlist_adapter()`; + see §2.2. Exercise the Client Reviews adapter against a sandbox as part of + that work: it is the least-tried path, and its `ReviewSessionObject` create + is the one write still resting on a partly-unverified entity (§6.1). +6. Consider the remaining `asset.context_id` filter join (§2.3) only if `/find` + with an `entity` filter turns out to be a hot path. + +--- + +## 8. Running the new tests without Docker + +The scratchpad harness used during development: + +``` +/shim/pydantic.py # minimal pydantic v2 stand-in +/shim/conftest.py # puts the shim on sys.path +PYTHONPATH=:backend/src python3.9 -m pytest test_ftrack_provider.py -c /dev/null +``` + +It is deliberately **not** committed — it is a crutch for an environment without +the real dependencies, and it would rot. `make test` is the real check. + +--- + +## 9. Nix development environment + +`flake.nix` / `flake.lock` at the repo root (nixpkgs pinned to `b6c98e9e6633`). +Added because the missing host dependencies above are what blocked `make test` +for the whole of the first session. + +``` +git add flake.nix flake.lock # flakes only see git-tracked files +nix develop +``` + +**What the shell gives you:** `python311` (3.11.16, matching +`backend/Dockerfile`) with `shotgun_api3` and `ftrack_api` already importable, +plus `uv`, `nodejs_22`, `mongodb-ce`, `black`, `isort` and `docker-compose`. + +**What it deliberately does not do:** install the backend's pinned dependencies. +The shellHook prints the commands rather than running them, so entering the +shell is fast and works offline: + +``` +uv venv --system-site-packages backend/.venv +uv pip install --python backend/.venv -r backend/requirements.txt +``` + +`--system-site-packages` is what lets the venv see the two nix-built SDKs. + +### Why four derivations for two SDKs + +Neither `shotgun_api3` nor `ftrack-python-api` is in nixpkgs, and ftrack drags +in two more that are missing or wrong-versioned: + +| Package | Source | Why | +|---|---|---| +| `shotgun_api3` 3.9.2 | GitHub `shotgunsoftware/python-api` | Plain setuptools, vendors its own httplib2 | +| `ftrack-python-api` 3.0.6 | GitHub `ftrackhq/ftrack-python-api` | Needs patching, see below | +| `clique` 1.6.1 | **PyPI** | ftrack pins `==1.6.1`; `4degrees/clique`'s GitHub tags stop at 1.5.0, so 1.6.1 exists only on PyPI. Hard dependency — `import clique` at `ftrack_api/session.py:29` | +| `arrow` 0.17.0 | GitHub `arrow-py/arrow` | See below | + +**arrow is pinned on purpose.** ftrack's `arrow>=0.4.4,<1` is not conservative: +`ftrack_api/entity/user.py:92` calls `arrow.now().replace(tzinfo="utc")`, and +arrow 1.0 removed `tzinfo` from `.replace()` (it moved to `.to()`). nixpkgs +ships arrow 1.4, which would raise at runtime on a code path +`get_versions_for_playlist` reaches. Do not "upgrade" this without rechecking +that call. + +**pyparsing and websocket-client are relaxed on purpose.** ftrack pins them `<3` +and `<1`; nixpkgs has 3.3 and 1.9. Both are used only by the event hub +(`ftrack_api/event/expression.py`, `event/hub.py`), and the provider connects +with `auto_connect_event_hub=False` — see `FtrackProvider.connect`. They still +have to import, and they do; only the event-subscription paths would be at risk +and nothing in DNA reaches them. + +**ftrack needs build patching.** Upstream builds through +`poetry-dynamic-versioning`, which reads the version from a git tag; a source +tarball has no `.git`, so an unpatched build produces `0.1.0`. The derivation +pins the version, swaps the backend to plain `poetry-core`, and drops +`sphinx-notfound-page` — listed as a runtime dependency but docs-only, and it +would pull all of sphinx into the closure. + +### Verified + +Built and imported successfully on `x86_64-linux`, 2026-09-23: + +``` +python 3.11.16 | shotgun_api3 3.9.2 | ftrack_api 3.0.6 +arrow 0.17.0 | clique 1.6.1 +ftrack_api.session, event.hub, event.expression all import +arrow.now().replace(tzinfo="utc") works +``` + +### Note on nodejs + +`nodejs_20` has been **removed** from nixpkgs (upstream EOL 2026-04-30). The +flake uses `nodejs_22`. Anything still assuming Node 20 needs to move. diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..4d053248 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1790055231, + "narHash": "sha256-QpXotokGh5eeMG1Njiuh27oP0rK6t5+cMp7ymXSs7mc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b6c98e9e6633ee64753b594ff4a5febf0367fc00", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..89ad9666 --- /dev/null +++ b/flake.nix @@ -0,0 +1,224 @@ +{ + description = "DNA development environment — host toolchain plus the two prodtrack SDKs that are not in nixpkgs"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + + # Matches backend/Dockerfile (python:3.11-slim). Keep them in step. + python = pkgs.python311; + py = python.pkgs; + + # ------------------------------------------------------------------ + # Packages nixpkgs does not carry + # + # shotgun_api3 and ftrack-python-api are both absent from nixpkgs, and + # ftrack pulls two more that are missing or wrong-versioned there. + # ------------------------------------------------------------------ + + # ftrack pins arrow <1 and means it: ftrack_api/entity/user.py calls + # `arrow.now().replace(tzinfo="utc")`, and arrow 1.0 removed tzinfo + # from .replace() (it moved to .to()). nixpkgs ships arrow 1.4, which + # would raise at runtime, so this builds the version ftrack expects. + arrow_0_17 = py.buildPythonPackage rec { + pname = "arrow"; + version = "0.17.0"; + pyproject = true; + + src = pkgs.fetchFromGitHub { + owner = "arrow-py"; + repo = "arrow"; + rev = version; + hash = "sha256-a16rSojvEFO8Ash6iEwxhgp7h3tnSBJrbaWeE1K8xkU="; + }; + + build-system = [ py.setuptools ]; + dependencies = [ py.python-dateutil ]; + + # The 0.17 suite predates the pytest in nixpkgs and is not what we + # are here to verify. + doCheck = false; + + pythonImportsCheck = [ "arrow" ]; + + meta = { + description = "Better dates and times for Python (pinned <1 for ftrack)"; + homepage = "https://github.com/arrow-py/arrow"; + license = pkgs.lib.licenses.asl20; + }; + }; + + # ftrack pins clique==1.6.1. That release was never tagged on GitHub + # (its tags stop at 1.5.0), so this one comes from PyPI — the only + # place 1.6.1 is published. + clique = py.buildPythonPackage rec { + pname = "clique"; + version = "1.6.1"; + pyproject = true; + + src = py.fetchPypi { + inherit pname version; + hash = "sha256-kBZcHPFi1N0brvg86qGvyIa0U+N5CU+ltg6kcNFzPmY="; + }; + + build-system = [ py.setuptools ]; + + # Test deps are not packaged and the library is a hard dependency of + # ftrack's session module, not something we are validating here. + doCheck = false; + + pythonImportsCheck = [ "clique" ]; + + meta = { + description = "Manage collections of sequentially numbered items"; + homepage = "https://gitlab.com/4degrees/clique"; + license = pkgs.lib.licenses.asl20; + }; + }; + + shotgun-api3 = py.buildPythonPackage rec { + pname = "shotgun_api3"; + version = "3.9.2"; + pyproject = true; + + src = pkgs.fetchFromGitHub { + owner = "shotgunsoftware"; + repo = "python-api"; + rev = "v${version}"; + hash = "sha256-GlYJ7Nu79Tmb6c/57GQ1VRj/eDkncEXyrmU9OPLqU+Y="; + }; + + build-system = [ py.setuptools ]; + + # No install_requires: it vendors httplib2 and friends under + # shotgun_api3/lib. + dependencies = [ ]; + + # The suite wants live ShotGrid credentials. + doCheck = false; + + pythonImportsCheck = [ "shotgun_api3" ]; + + meta = { + description = "Autodesk ShotGrid Python API"; + homepage = "https://github.com/shotgunsoftware/python-api"; + license = pkgs.lib.licenses.mit; + }; + }; + + ftrack-python-api = py.buildPythonPackage rec { + pname = "ftrack-python-api"; + version = "3.0.6"; + pyproject = true; + + src = pkgs.fetchFromGitHub { + owner = "ftrackhq"; + repo = "ftrack-python-api"; + rev = "v${version}"; + hash = "sha256-rUDUKvKFUSu3+8BQls2pzjxsyEIK2ZIDyx/VewIpOdo="; + }; + + # Upstream builds through poetry-dynamic-versioning, which reads the + # version from a git tag. A source tarball has no .git, so the build + # would produce 0.1.0. Pin the version and drop the plugin. + # sphinx-notfound-page is a docs dependency listed as a runtime one; + # keeping it would drag all of sphinx into the closure. + postPatch = '' + substituteInPlace pyproject.toml \ + --replace-fail 'version = "0.1.0"' 'version = "${version}"' \ + --replace-fail 'requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"]' 'requires = ["poetry-core>=1.0.0"]' \ + --replace-fail 'build-backend = "poetry_dynamic_versioning.backend"' 'build-backend = "poetry.core.masonry.api"' \ + --replace-fail 'sphinx-notfound-page = "^1.0.4"' "" + echo '__version__ = "${version}"' > source/ftrack_api/_version.py + ''; + + build-system = [ py.poetry-core ]; + + dependencies = [ + py.requests + py.platformdirs + arrow_0_17 + clique + py.pyparsing + py.websocket-client + ]; + + # pyparsing <3 and websocket-client <1 are relaxed on purpose, unlike + # arrow above. Both are used only by the event hub + # (ftrack_api/event/expression.py and event/hub.py), and DNA connects + # with auto_connect_event_hub=False — see FtrackProvider.connect. They + # still have to import, which they do; only the event-subscription + # paths would be at risk, and nothing in DNA reaches them. + pythonRelaxDeps = [ "pyparsing" "websocket-client" ]; + + # The suite wants a live ftrack server. + doCheck = false; + + pythonImportsCheck = [ "ftrack_api" ]; + + meta = { + description = "ftrack Python API"; + homepage = "https://github.com/ftrackhq/ftrack-python-api"; + license = pkgs.lib.licenses.asl20; + }; + }; + + # The two SDKs on the interpreter, so `python -c "import ftrack_api"` + # works straight out of the shell. Everything else comes from the venv + # (see the shellHook) so the pinned versions in requirements.txt are + # what actually runs. + pythonEnv = python.withPackages (_: [ shotgun-api3 ftrack-python-api ]); + + in + { + packages = { + inherit arrow_0_17 clique shotgun-api3 ftrack-python-api; + default = pythonEnv; + }; + + devShells.default = pkgs.mkShell { + name = "dna"; + + packages = [ + pythonEnv + pkgs.uv + + # nodejs_20 was dropped from nixpkgs (upstream EOL 2026-04-30). + pkgs.nodejs_22 + + pkgs.mongodb-ce + pkgs.black + pkgs.isort + pkgs.docker-compose + ]; + + # Let the venv see the nix-provided SDKs rather than reinstalling + # them, and keep uv from downloading its own interpreter. + UV_PYTHON = "${pythonEnv}/bin/python"; + UV_PYTHON_DOWNLOADS = "never"; + + shellHook = '' + echo "DNA dev shell" + echo " python $(${pythonEnv}/bin/python --version 2>&1 | cut -d' ' -f2) (shotgun_api3, ftrack_api built in)" + echo " node $(node --version)" + echo " uv $(uv --version | cut -d' ' -f2)" + echo + echo "Backend deps are NOT installed automatically — the pinned set in" + echo "backend/requirements.txt is the source of truth and needs an index:" + echo " uv venv --system-site-packages backend/.venv" + echo " uv pip install --python backend/.venv -r backend/requirements.txt" + echo + echo "Tests still run in Docker (make -C backend test); this shell is for" + echo "editing, linting and one-off scripts on the host." + ''; + }; + + formatter = pkgs.nixpkgs-fmt; + }); +} From d3bd076e0983cb661c5bd6d4051823935970985c Mon Sep 17 00:00:00 2001 From: Erik Johansson Date: Wed, 23 Sep 2026 11:40:58 +0200 Subject: [PATCH 3/5] build: lock backend dependencies and build the dev shell from the lock requirements.txt pinned only top-level packages, so transitive dependencies floated per install and anthropic was a range. Declare the dependencies in backend/pyproject.toml, lock the full tree in backend/uv.lock, and build the nix dev shell's Python environment from it with uv2nix. requirements.txt is now exported from the lock, so the dev shell, Docker and CI install the same set. This replaces the hand-built shotgun_api3, ftrack-python-api, arrow and clique derivations; uv resolves them from PyPI and honours ftrack's pins instead of relaxing them. Also allow the unfree mongodb-ce (SSPL) through allowUnfreePredicate, without which the flake failed to evaluate. Co-Authored-By: Claude Opus 5.5 (1M context) --- backend/docs/ftrack-provider-handoff.md | 123 ++- backend/pyproject.toml | 31 + backend/requirements.txt | 276 ++++- backend/uv.lock | 1336 +++++++++++++++++++++++ flake.lock | 74 +- flake.nix | 241 ++-- 6 files changed, 1839 insertions(+), 242 deletions(-) create mode 100644 backend/uv.lock diff --git a/backend/docs/ftrack-provider-handoff.md b/backend/docs/ftrack-provider-handoff.md index a4d6c597..b5eb6f8e 100644 --- a/backend/docs/ftrack-provider-handoff.md +++ b/backend/docs/ftrack-provider-handoff.md @@ -211,21 +211,28 @@ assumption — which is the whole reason §7 step 2 exists. links by column, matching how the rest of the adapter queries; either form works. -### 6.2 The repo's test suite has never run +### 6.2 The repo's test suite -At the time this was written no `fastapi` or `pydantic` was installable on the +For the whole first session no `fastapi` or `pydantic` was installable on the host (the configured devpi index has neither) and Docker was unavailable, so -`make test` never ran. The flake added since (§9) supplies the interpreter and -both prodtrack SDKs, but the rest of `requirements.txt` still needs a working -index — so this is not resolved yet, only made easier. +`make test` never ran. The nix shell (§9) now carries the full locked backend +environment, and the suite has run on the host with it: -The 123 new tests pass under a **throwaway pydantic shim** in a scratchpad — +``` +nix develop --command bash -c 'cd backend && python -m pytest' +823 passed — 2026-09-23, x86_64-linux +``` + +That is the host, not the container; `make test` in Docker is still worth one +run before merging. + +Before that, the 123 new tests passed under a **throwaway pydantic shim** in a scratchpad — which exercises logic, query shape and round-trip counts, but **not pydantic field-type validation**. The entity models are the part this cannot vouch for: if e.g. `Version.created_at` gets a type the model rejects, only the real suite will show it. -**First action in a new session: run `make test` in `backend/`.** +The real suite has since covered this (above). Also unrun: the modified `test_publish_transcript_endpoint.py` and `test_prodtrack_provider_base.py`. @@ -247,7 +254,8 @@ don't "fix" it. ## 7. Suggested order of work -1. **Run `make test`.** Fix whatever pydantic validation surfaces. +1. ~~Run the suite.~~ Done on the host via the nix shell (§6.2); run + `make test` in Docker once before merging. 2. **Run it against a sandbox project.** The schema names are checked (§6.1) but nothing has actually executed: load a playlist, publish a note with a subject and an attachment, set a status, create a playlist. Reading is the low-risk @@ -276,7 +284,8 @@ PYTHONPATH=:backend/src python3.9 -m pytest test_ftrack_provider.py -c /de ``` It is deliberately **not** committed — it is a crutch for an environment without -the real dependencies, and it would rot. `make test` is the real check. +the real dependencies, and it would rot. Superseded by the nix shell (§9), +which runs the real suite on the host. --- @@ -287,69 +296,83 @@ Added because the missing host dependencies above are what blocked `make test` for the whole of the first session. ``` -git add flake.nix flake.lock # flakes only see git-tracked files +git add flake.nix flake.lock backend/uv.lock # flakes only see git-tracked files nix develop ``` **What the shell gives you:** `python311` (3.11.16, matching -`backend/Dockerfile`) with `shotgun_api3` and `ftrack_api` already importable, -plus `uv`, `nodejs_22`, `mongodb-ce`, `black`, `isort` and `docker-compose`. +`backend/Dockerfile`) with every backend dependency installed at the exact +version in `backend/uv.lock`, `PYTHONPATH` set to `backend/src` as in the +container, plus `uv`, `nodejs_22`, `mongodb-ce`, `black`, `isort` and +`docker-compose`. Nothing is installed into a venv by hand. + +### One lock for the shell, Docker and CI + +`backend/requirements.txt` used to pin only top-level packages, so everything +beneath them (starlette, anyio, pydantic-core, ...) floated with each install, +and `anthropic` was a range. Now: -**What it deliberately does not do:** install the backend's pinned dependencies. -The shellHook prints the commands rather than running them, so entering the -shell is fast and works offline: +- `backend/pyproject.toml` `[project].dependencies` is where dependencies are + declared (same list, same pins as the old `requirements.txt`). +- `backend/uv.lock` pins the whole tree by version and hash. The flake builds + the shell's environment from it with + [uv2nix](https://github.com/pyproject-nix/uv2nix). +- `backend/requirements.txt` is **generated** from the lock + (`uv export`), so the Dockerfile and `.github/workflows/backend-tests.yml` + keep their `pip install -r requirements.txt` unchanged and get the same set. + +To change a dependency: edit `pyproject.toml`, then ``` -uv venv --system-site-packages backend/.venv -uv pip install --python backend/.venv -r backend/requirements.txt +cd backend +uv lock +uv export --frozen --no-hashes --no-emit-project -o requirements.txt ``` -`--system-site-packages` is what lets the venv see the two nix-built SDKs. +and commit all three. Never edit `requirements.txt` by hand. + +Every locked package ships a wheel for CPython 3.11 on x86_64 Linux, so nothing +is built from source. -### Why four derivations for two SDKs +### ftrack's pins are honoured, not patched -Neither `shotgun_api3` nor `ftrack-python-api` is in nixpkgs, and ftrack drags -in two more that are missing or wrong-versioned: +The first version of the flake hand-built `shotgun_api3`, `ftrack-python-api`, +`arrow` and `clique`, and relaxed two of ftrack's pins to fit nixpkgs. With +everything resolved from PyPI by uv none of that is needed, and ftrack gets +exactly what it asks for: -| Package | Source | Why | +| Package | Locked | Why it matters | |---|---|---| -| `shotgun_api3` 3.9.2 | GitHub `shotgunsoftware/python-api` | Plain setuptools, vendors its own httplib2 | -| `ftrack-python-api` 3.0.6 | GitHub `ftrackhq/ftrack-python-api` | Needs patching, see below | -| `clique` 1.6.1 | **PyPI** | ftrack pins `==1.6.1`; `4degrees/clique`'s GitHub tags stop at 1.5.0, so 1.6.1 exists only on PyPI. Hard dependency — `import clique` at `ftrack_api/session.py:29` | -| `arrow` 0.17.0 | GitHub `arrow-py/arrow` | See below | - -**arrow is pinned on purpose.** ftrack's `arrow>=0.4.4,<1` is not conservative: -`ftrack_api/entity/user.py:92` calls `arrow.now().replace(tzinfo="utc")`, and -arrow 1.0 removed `tzinfo` from `.replace()` (it moved to `.to()`). nixpkgs -ships arrow 1.4, which would raise at runtime on a code path -`get_versions_for_playlist` reaches. Do not "upgrade" this without rechecking -that call. - -**pyparsing and websocket-client are relaxed on purpose.** ftrack pins them `<3` -and `<1`; nixpkgs has 3.3 and 1.9. Both are used only by the event hub -(`ftrack_api/event/expression.py`, `event/hub.py`), and the provider connects -with `auto_connect_event_hub=False` — see `FtrackProvider.connect`. They still -have to import, and they do; only the event-subscription paths would be at risk -and nothing in DNA reaches them. - -**ftrack needs build patching.** Upstream builds through -`poetry-dynamic-versioning`, which reads the version from a git tag; a source -tarball has no `.git`, so an unpatched build produces `0.1.0`. The derivation -pins the version, swaps the backend to plain `poetry-core`, and drops -`sphinx-notfound-page` — listed as a runtime dependency but docs-only, and it -would pull all of sphinx into the closure. +| `arrow` | 0.17.0 | ftrack pins `<1` for a reason: `ftrack_api/entity/user.py:92` calls `arrow.now().replace(tzinfo="utc")`, which arrow 1.0 removed. `get_versions_for_playlist` reaches it. | +| `clique` | 1.6.1 | ftrack pins `==1.6.1`; hard import at `ftrack_api/session.py:29` | +| `pyparsing` | 2.4.7 | ftrack's `<3`, event hub only | +| `websocket-client` | 0.59.0 | ftrack's `<1`, event hub only | + +PyPI's ftrack 3.0.6 declares `sphinx-notfound-page` as a runtime dependency, so +sphinx is in the environment. It was already in every Docker image; the lock +just makes that visible. ### Verified -Built and imported successfully on `x86_64-linux`, 2026-09-23: +On `x86_64-linux`, 2026-09-23: ``` -python 3.11.16 | shotgun_api3 3.9.2 | ftrack_api 3.0.6 -arrow 0.17.0 | clique 1.6.1 +python 3.11.16 | fastapi 0.104.1 | pydantic 2.13.4 | anthropic 0.125.0 +arrow 0.17.0 | clique 1.6.1 | pyparsing 2.4.7 | websocket-client 0.59.0 ftrack_api.session, event.hub, event.expression all import arrow.now().replace(tzinfo="utc") works +import dna resolves to backend/src/dna +pytest (backend): 823 passed ``` +### Note on mongodb-ce + +`mongodb-ce` is SSPL, which nixpkgs classes as unfree, and a flake ignores +`NIXPKGS_ALLOW_UNFREE` without `--impure`. The first version of the flake +therefore failed to evaluate. `flake.nix` now allows unfree for that one package +through `allowUnfreePredicate`, not a blanket `allowUnfree`, so any other unfree +package still fails loudly. Add to that list deliberately. + ### Note on nodejs `nodejs_20` has been **removed** from nixpkgs (upstream EOL 2026-04-30). The diff --git a/backend/pyproject.toml b/backend/pyproject.toml index a2a13fd1..ed3ad38f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,3 +1,34 @@ +[project] +name = "dna-backend" +version = "0.0.0" +# Matches backend/Dockerfile (python:3.11-slim). +requires-python = "==3.11.*" +dependencies = [ + "fastapi==0.104.1", + "uvicorn[standard]==0.24.0", + "pydantic==2.13.4", + "pydantic-settings==2.8.1", + "instructor==1.15.1", + "pytest==7.4.3", + "pytest-cov==4.1.0", + "pytest-asyncio==0.21.1", + "httpx==0.25.2", + "shotgun_api3==3.9.2", + "ftrack-python-api==3.0.6", + "pymongo==4.10.1", + "websockets==12.0", + "openai==2.36.0", + "anthropic>=0.49,<1.0", + "google-auth==2.0.0", + "requests==2.32.3", + "python-multipart==0.0.9", + "PyYAML==6.0.1", +] + +[tool.uv] +# The backend runs from src/ on PYTHONPATH; it is not an installable package. +package = false + [tool.isort] profile = "black" src_paths = ["dna", "tests"] diff --git a/backend/requirements.txt b/backend/requirements.txt index bdfabb13..53d3503b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,19 +1,269 @@ +# This file was autogenerated by uv via the following command: +# uv export --frozen --no-hashes --no-emit-project -o requirements.txt +aiohappyeyeballs==2.7.1 + # via aiohttp +aiohttp==3.14.3 + # via instructor +aiosignal==1.4.0 + # via aiohttp +alabaster==1.0.0 + # via sphinx +annotated-doc==0.0.5 + # via typer +annotated-types==0.8.0 + # via pydantic +anthropic==0.125.0 + # via dna-backend +anyio==3.7.1 + # via + # anthropic + # fastapi + # httpx + # openai + # starlette + # watchfiles +arrow==0.17.0 + # via ftrack-python-api +attrs==26.1.0 + # via aiohttp +babel==2.18.0 + # via sphinx +cachetools==4.2.4 + # via google-auth +certifi==2026.7.22 + # via + # httpcore + # httpx + # requests +charset-normalizer==3.5.1 + # via requests +click==8.5.0 + # via uvicorn +clique==1.6.1 + # via ftrack-python-api +colorama==0.4.6 ; sys_platform == 'win32' + # via + # pytest + # sphinx + # tqdm + # typer + # uvicorn +coverage==7.16.1 + # via pytest-cov +distro==1.9.0 + # via + # anthropic + # openai +dnspython==2.8.0 + # via pymongo +docstring-parser==0.18.0 + # via + # anthropic + # instructor +docutils==0.22.4 + # via sphinx fastapi==0.104.1 -uvicorn[standard]==0.24.0 + # via dna-backend +frozenlist==1.8.0 + # via + # aiohttp + # aiosignal +ftrack-python-api==3.0.6 + # via dna-backend +google-auth==2.0.0 + # via dna-backend +h11==0.16.0 + # via + # httpcore + # uvicorn +httpcore==1.0.9 + # via httpx +httptools==0.8.0 + # via uvicorn +httpx==0.25.2 + # via + # anthropic + # dna-backend + # openai +idna==3.20 + # via + # anyio + # httpx + # requests + # yarl +imagesize==2.0.1 + # via sphinx +iniconfig==2.3.0 + # via pytest +instructor==1.15.1 + # via dna-backend +jinja2==3.1.6 + # via + # instructor + # sphinx +jiter==0.13.0 + # via + # anthropic + # instructor + # openai +markdown-it-py==4.2.0 + # via rich +markupsafe==3.0.3 + # via jinja2 +mdurl==0.1.2 + # via markdown-it-py +multidict==6.9.1 + # via + # aiohttp + # yarl +openai==2.36.0 + # via + # dna-backend + # instructor +packaging==26.3 + # via + # pytest + # sphinx +platformdirs==4.11.12 + # via ftrack-python-api +pluggy==1.6.0 + # via pytest +propcache==0.5.4 + # via + # aiohttp + # yarl +pyasn1==0.6.4 + # via + # pyasn1-modules + # rsa +pyasn1-modules==0.4.2 + # via google-auth pydantic==2.13.4 + # via + # anthropic + # dna-backend + # fastapi + # instructor + # openai + # pydantic-settings +pydantic-core==2.46.4 + # via + # instructor + # pydantic pydantic-settings==2.8.1 -instructor==1.15.1 + # via dna-backend +pygments==2.21.0 + # via + # rich + # sphinx +pymongo==4.10.1 + # via dna-backend +pyparsing==2.4.7 + # via ftrack-python-api pytest==7.4.3 -pytest-cov==4.1.0 + # via + # dna-backend + # pytest-asyncio + # pytest-cov pytest-asyncio==0.21.1 -httpx==0.25.2 -shotgun_api3==3.9.2 -ftrack-python-api==3.0.6 -pymongo==4.10.1 -websockets==12.0 -openai==2.36.0 -anthropic>=0.49,<1.0 -google-auth==2.0.0 -requests==2.32.3 + # via dna-backend +pytest-cov==4.1.0 + # via dna-backend +python-dateutil==2.9.0.post0 + # via arrow +python-dotenv==1.2.3 + # via + # pydantic-settings + # uvicorn python-multipart==0.0.9 -PyYAML==6.0.1 + # via dna-backend +pyyaml==6.0.1 + # via + # dna-backend + # uvicorn +requests==2.32.3 + # via + # dna-backend + # ftrack-python-api + # instructor + # sphinx +rich==14.3.4 + # via + # instructor + # typer +roman-numerals==4.1.0 + # via sphinx +rsa==4.9.1 + # via google-auth +setuptools==84.0.0 + # via google-auth +shellingham==1.5.4 + # via typer +shotgun-api3==3.9.2 + # via dna-backend +six==1.17.0 + # via + # python-dateutil + # websocket-client +sniffio==1.3.1 + # via + # anthropic + # anyio + # httpx + # openai +snowballstemmer==3.1.1 + # via sphinx +sphinx==9.0.4 + # via sphinx-notfound-page +sphinx-notfound-page==1.1.0 + # via ftrack-python-api +sphinxcontrib-applehelp==2.0.0 + # via sphinx +sphinxcontrib-devhelp==2.0.0 + # via sphinx +sphinxcontrib-htmlhelp==2.1.0 + # via sphinx +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==2.0.0 + # via sphinx +sphinxcontrib-serializinghtml==2.0.0 + # via sphinx +starlette==0.27.0 + # via fastapi +tenacity==9.1.4 + # via instructor +tomli==2.4.1 ; python_full_version <= '3.11' + # via coverage +tqdm==4.70.1 + # via openai +typer==0.27.2 + # via instructor +typing-extensions==4.16.0 + # via + # aiohttp + # aiosignal + # anthropic + # fastapi + # openai + # pydantic + # pydantic-core + # typing-inspection +typing-inspection==0.4.4 + # via pydantic +urllib3==2.8.0 + # via requests +uvicorn==0.24.0 + # via dna-backend +uvloop==0.22.1 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32' + # via uvicorn +watchfiles==1.3.0 + # via uvicorn +websocket-client==0.59.0 + # via ftrack-python-api +websockets==12.0 + # via + # dna-backend + # uvicorn +yarl==1.25.1 + # via aiohttp diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 00000000..0c75ac3e --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,1336 @@ +version = 1 +revision = 3 +requires-python = "==3.11.*" + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, + { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, + { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, + { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, + { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, + { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anthropic" +version = "0.125.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/f8/6f0560884b5363848347bd640b6c1d04abc25e7aa61787a232f790c6b60a/anthropic-0.125.0.tar.gz", hash = "sha256:e0cdd336580cb7411c1cdab69f80973e9bf4bff7f8e08141811d46307d45c682", size = 1112593, upload-time = "2026-08-19T22:00:42.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/1a/b1bd30cda3790557e8791bec5922a6ec8fabb6fa8b008c76a39cf7be6152/anthropic-0.125.0-py3-none-any.whl", hash = "sha256:3486013602eca76d8b12540764e53654f02cf4951110bca86cf06e67428a9f21", size = 1184067, upload-time = "2026-08-19T22:00:44.596Z" }, +] + +[[package]] +name = "anyio" +version = "3.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/99/2dfd53fd55ce9838e6ff2d4dac20ce58263798bd1a0dbe18b3a9af3fcfce/anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780", size = 142927, upload-time = "2023-07-05T16:45:02.294Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/24/44299477fe7dcc9cb58d0a57d5a7588d6af2ff403fdd2d47a246c91a3246/anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5", size = 80896, upload-time = "2023-07-05T16:44:59.805Z" }, +] + +[[package]] +name = "arrow" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/74/1cf2d9912921cebdba3fa954949206c8aa159c9cc803b88140fb227f8a0e/arrow-0.17.0.tar.gz", hash = "sha256:ff08d10cda1d36c68657d6ad20d74fbea493d980f8b2d45344e00d6ed2bf6ed4", size = 97411, upload-time = "2020-10-06T20:45:04.154Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/bc/ebc1afb3c54377e128a01024c006f983d03ee124bc52392b78ba98c421b8/arrow-0.17.0-py2.py3-none-any.whl", hash = "sha256:e098abbd9af3665aea81bdd6c869e93af4feb078e98468dd351c383af187aac5", size = 50687, upload-time = "2020-10-06T20:45:02.126Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "cachetools" +version = "4.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/69/c457a860456cbf80ecc2e44ed4c201b49ec7ad124d769b71f6d0a7935dca/cachetools-4.2.4.tar.gz", hash = "sha256:89ea6f1b638d5a73a4f9226be57ac5e4f399d22770b92355f92dcb0f7f001693", size = 25487, upload-time = "2021-09-30T10:26:22.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/c1/4740af52db75e6dbdd57fc7e9478439815bbac549c1c05881be27d19a17d/cachetools-4.2.4-py3-none-any.whl", hash = "sha256:92971d3cb7d2a97efff7c7bb1657f21a8f5fb309a37530537c71b1774189f2d1", size = 10358, upload-time = "2021-09-30T10:26:20.649Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" }, + { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" }, + { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" }, + { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" }, + { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "clique" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/be/5ec6cdb229f7ef7c6af15f443e150f0a514a05ffd84677fd7f0567bc56d9/clique-1.6.1.tar.gz", hash = "sha256:90165c1cf162d4dd1baef83ceaa1afc886b453e379094fa5b60ea470d1733e66", size = 58346, upload-time = "2021-03-30T09:54:23.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/92/d56a23bc947a0ceba194e34b0d64ef44a21c88e90594fafa18ee6d12ffb1/clique-1.6.1-py2.py3-none-any.whl", hash = "sha256:8619774fa035661928dd8c93cd805acf2d42533ccea1b536c09815ed426c9858", size = 13853, upload-time = "2021-03-30T09:54:22.163Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/2d/c738872f477f5687152acae68635790387425d407ae37dd3d3a8a6692307/coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", size = 969651, upload-time = "2026-09-13T19:12:21.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/3a/d09495dfd5191b5593852bbe2f037afae768157443378d066d9ecea69a49/coverage-7.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72e013665e25cf9d44779f01f340af26319756f9a76822b7c94ce6b1d93813da", size = 223307, upload-time = "2026-09-13T19:08:44.914Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/310196a80258e28ebf6e5aa814a5a6ffbb18c03cf3c0c0c04191ed893e88/coverage-7.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb05c0ff98b56ba6969adf35556bc43bcb8d094df8bc9cb403acff53460c4e07", size = 223817, upload-time = "2026-09-13T19:08:46.738Z" }, + { url = "https://files.pythonhosted.org/packages/73/29/ed0fda1fcd440cdfc07e72a07f3c9c3b43f65a6b3d53bff2cba9b9de50e8/coverage-7.16.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0ececb32090e3fbb03e0d352b973a0485879b4de6c58daf47227b9988b99e5", size = 254222, upload-time = "2026-09-13T19:08:48.358Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4e/5e2507fbd71ec1047620978f39568b0d2ed6c4468d08495a0c24f5677f65/coverage-7.16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f0ba3892d81aacf36996c52f16bca04e39af31a6c5de930b7688ab617f4a6475", size = 256134, upload-time = "2026-09-13T19:08:49.926Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b1/a9f97846554bc240473656f9c3874591105f29c08eccf4282daadc6bc281/coverage-7.16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6410b75fe07d5271eaa95fc24bd0a9177ed588d9d1c10c0cf67829adb8f0567", size = 258240, upload-time = "2026-09-13T19:08:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/2b1ab208d06719394851cdd7dc322157870a93b178410b1bda6cac7755f9/coverage-7.16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1039cb2de093225d597109342ce1675626bd127565e80b3044f4eca07c15b2e", size = 260202, upload-time = "2026-09-13T19:08:53.204Z" }, + { url = "https://files.pythonhosted.org/packages/af/14/ff4db31d6e3126d5b72aab1e868d35ec94e1b71dee46f5c3b54cb1b2438e/coverage-7.16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cf047bc39fde5425be2628666d0f435ed8817859111c3aacc84b32d858069f5d", size = 254305, upload-time = "2026-09-13T19:08:54.833Z" }, + { url = "https://files.pythonhosted.org/packages/ce/27/c28faa4818f61c49ca3bfb4a77e1c191e79a40447504b89af0bd859cd6fc/coverage-7.16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c05913d0d5badf7ac83200f35dcf9514cce5df16a7cc89e7d1d7fff0461813b", size = 255935, upload-time = "2026-09-13T19:08:56.453Z" }, + { url = "https://files.pythonhosted.org/packages/46/5a/42e64e5b716048cae33e2e15f8c6fe04a927467056c533b8e88900da895e/coverage-7.16.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4027bf6d7bc0a16df058ce913b69f10c5687f8e1ca668f08caa659ce101744bf", size = 253995, upload-time = "2026-09-13T19:08:58.159Z" }, + { url = "https://files.pythonhosted.org/packages/84/e5/860287acd5a1e29acd337f302b476b54148203590091b1a1555b2e914b50/coverage-7.16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4f48b345f831eaf4402ab6333c2dc3e2e2b5bc7b9c1b8fe12680dee3f0538f01", size = 257767, upload-time = "2026-09-13T19:08:59.958Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c5/4b68006da567b4b413352f891776c35776b3557a0479a28187ab57c21659/coverage-7.16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8643baeb590726c558b2faed6cd59b0917480f9367fcc692026f1a86d824fd08", size = 253715, upload-time = "2026-09-13T19:09:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/a1/0e/b2d0c47cfbf11770e197f1d0f11a92864eb29fd7eaca45bc9915573d9725/coverage-7.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d06dcc420b570bf683cdb647cc8fe62b672d9e429ef711c3cbbb7a6880ca1572", size = 254624, upload-time = "2026-09-13T19:09:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/2e/14/3bab8821b2b942155578fc34e88e21b0670d922f039ab23651f6035ba7f7/coverage-7.16.1-cp311-cp311-win32.whl", hash = "sha256:946f58aa59b08bcd6afcc6a7bd0ff54ed5eee844f32ad69fe6814836d15856a2", size = 225404, upload-time = "2026-09-13T19:09:05.174Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ce/4f4ce667a97d1c538b46a79b89161395ad2beffc248b6c782afc9f521927/coverage-7.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:684c7ee9b4c04358fe6ac8b517ab51ec35fcd79d08ff0f105dd8bcd96885bbb7", size = 225879, upload-time = "2026-09-13T19:09:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/26/17/f3dce5e47351ab34522dd037fd64b111742958e24861bdd2820971bc1a26/coverage-7.16.1-cp311-cp311-win_arm64.whl", hash = "sha256:1b24f79e25bcf6c73931aeca7a3dfc7595c0cb5e9364aba3fdf387a3de4b1c22", size = 225428, upload-time = "2026-09-13T19:09:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "dna-backend" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "anthropic" }, + { name = "fastapi" }, + { name = "ftrack-python-api" }, + { name = "google-auth" }, + { name = "httpx" }, + { name = "instructor" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pymongo" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "shotgun-api3" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "anthropic", specifier = ">=0.49,<1.0" }, + { name = "fastapi", specifier = "==0.104.1" }, + { name = "ftrack-python-api", specifier = "==3.0.6" }, + { name = "google-auth", specifier = "==2.0.0" }, + { name = "httpx", specifier = "==0.25.2" }, + { name = "instructor", specifier = "==1.15.1" }, + { name = "openai", specifier = "==2.36.0" }, + { name = "pydantic", specifier = "==2.13.4" }, + { name = "pydantic-settings", specifier = "==2.8.1" }, + { name = "pymongo", specifier = "==4.10.1" }, + { name = "pytest", specifier = "==7.4.3" }, + { name = "pytest-asyncio", specifier = "==0.21.1" }, + { name = "pytest-cov", specifier = "==4.1.0" }, + { name = "python-multipart", specifier = "==0.0.9" }, + { name = "pyyaml", specifier = "==6.0.1" }, + { name = "requests", specifier = "==2.32.3" }, + { name = "shotgun-api3", specifier = "==3.9.2" }, + { name = "uvicorn", extras = ["standard"], specifier = "==0.24.0" }, + { name = "websockets", specifier = "==12.0" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "fastapi" +version = "0.104.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/d8/002e0ba7cf848a981b3ee92aaf5aa396c5700b0d7dec5d060031432a87d8/fastapi-0.104.1.tar.gz", hash = "sha256:e5e4540a7c5e1dcfbbcf5b903c234feddcdcd881f191977a1c5dfd917487e7ae", size = 11295150, upload-time = "2023-10-30T10:07:39.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/4f/0ce34195b63240b6693086496c9bab4ef23999112184399a3e88854c7674/fastapi-0.104.1-py3-none-any.whl", hash = "sha256:752dc31160cdbd0436bb93bad51560b57e525cbb1d4bbf6f4904ceee75548241", size = 92862, upload-time = "2023-10-30T10:07:35.636Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "ftrack-python-api" +version = "3.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, + { name = "clique" }, + { name = "platformdirs" }, + { name = "pyparsing" }, + { name = "requests" }, + { name = "sphinx-notfound-page" }, + { name = "websocket-client" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/5a/13b429df66efd9fe7ccf9c50c3a6edb71a0a726e40ac0a4d75b2b67f03be/ftrack_python_api-3.0.6-py3-none-any.whl", hash = "sha256:86868e18602485fa88801c7dab230bf4a8c4c7b875b17c695e12c4fc31c29b44", size = 114944, upload-time = "2026-01-19T11:38:23.285Z" }, +] + +[[package]] +name = "google-auth" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/0a/96f0691211d9293cdd29ade3fb844da92a56baae95dd9417e5ff5fbb6159/google-auth-2.0.0.tar.gz", hash = "sha256:9e00c76e1bc7b1a7fe80472f1347f3802e4caa1fa53ba1f11b7af3772b484a82", size = 180337, upload-time = "2021-08-17T17:02:15.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/38/b195782590b972ae058a28a09d8d34d24c2754cec1988ea13bceff9e6e4d/google_auth-2.0.0-py2.py3-none-any.whl", hash = "sha256:5c01b4be3fbece1526a76cb81551a936f938c7c3d490d10947c66f6ffd1e5161", size = 152232, upload-time = "2021-08-17T17:02:13.626Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, +] + +[[package]] +name = "httpx" +version = "0.25.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/23/911d93a022979d3ea295f659fbe7edb07b3f4561a477e83b3a6d0e0c914e/httpx-0.25.2.tar.gz", hash = "sha256:8b8fcaa0c8ea7b05edd69a094e63a2094c4efcb48129fb757361bc423c0ad9e8", size = 123889, upload-time = "2023-11-24T12:36:33.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/65/6940eeb21dcb2953778a6895281c179efd9100463ff08cb6232bb6480da7/httpx-0.25.2-py3-none-any.whl", hash = "sha256:a05d3d052d9b2dfce0e3896636467f8a5342fb2b902c819428e1ac65413ca118", size = 74980, upload-time = "2023-11-24T12:36:31.403Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/5e/513ff06670c84e7b9887c1fdf61b2d42b4f574a831f2f1d2222023049d8a/imagesize-2.0.1.tar.gz", hash = "sha256:b2ba6a4dea487a7ebcd53248d3476aca449d30db12a2dde5e0c5ca9624fd77e5", size = 1883774, upload-time = "2026-08-24T12:35:19.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/f9/575c8d760eae1fc99651b7cc5efd96ad5379ca4d6b53750b0fb4fe983f34/imagesize-2.0.1-py3-none-any.whl", hash = "sha256:ea0c9a0384df69ed86a943a15cde37d0360b82491b3910dc2215e202e62b5b02", size = 14794, upload-time = "2026-08-24T12:35:12.548Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "instructor" +version = "1.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "docstring-parser" }, + { name = "jinja2" }, + { name = "jiter" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "pydantic-core" }, + { name = "requests" }, + { name = "rich" }, + { name = "tenacity" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/a4/832cfb15420360e26d2d85bd9d5fe1e4b839d52587574d389bc31284bf6f/instructor-1.15.1.tar.gz", hash = "sha256:c72406469d9025b742e83cf0c13e914b317db2089d08d889944e74fcd659ef94", size = 69948370, upload-time = "2026-04-03T01:51:30.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/c8/36c5d9b80aaf40ba9a7084a8fc18c967db6bf248a4cc8d0f0816b14284be/instructor-1.15.1-py3-none-any.whl", hash = "sha256:be81d17ba2b154a04ab4720808f24f9d6b598f80992f82eaf9cc79006099cf6c", size = 178156, upload-time = "2026-04-03T01:51:23.098Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jiter" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" }, + { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0d/061faffcfe94608cbc28a0d42a77a74222bdf5055ccdbe5fd2292b94f510/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c", size = 362587, upload-time = "2026-02-02T12:35:42.025Z" }, + { url = "https://files.pythonhosted.org/packages/92/c9/c66a7864982fd38a9773ec6e932e0398d1262677b8c60faecd02ffb67bf3/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4", size = 487537, upload-time = "2026-02-02T12:35:43.459Z" }, + { url = "https://files.pythonhosted.org/packages/6c/86/84eb4352cd3668f16d1a88929b5888a3fe0418ea8c1dfc2ad4e7bf6e069a/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165", size = 373717, upload-time = "2026-02-02T12:35:44.928Z" }, + { url = "https://files.pythonhosted.org/packages/6e/09/9fe4c159358176f82d4390407a03f506a8659ed13ca3ac93a843402acecf/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018", size = 362683, upload-time = "2026-02-02T12:35:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5e/85f3ab9caca0c1d0897937d378b4a515cae9e119730563572361ea0c48ae/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411", size = 392345, upload-time = "2026-02-02T12:35:48.088Z" }, + { url = "https://files.pythonhosted.org/packages/12/4c/05b8629ad546191939e6f0c2f17e29f542a398f4a52fb987bc70b6d1eb8b/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5", size = 517775, upload-time = "2026-02-02T12:35:49.482Z" }, + { url = "https://files.pythonhosted.org/packages/4d/88/367ea2eb6bc582c7052e4baf5ddf57ebe5ab924a88e0e09830dfb585c02d/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3", size = 551325, upload-time = "2026-02-02T12:35:51.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/12/fa377ffb94a2f28c41afaed093e0d70cfe512035d5ecb0cad0ae4792d35e/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1", size = 204709, upload-time = "2026-02-02T12:35:52.467Z" }, + { url = "https://files.pythonhosted.org/packages/cb/16/8e8203ce92f844dfcd3d9d6a5a7322c77077248dbb12da52d23193a839cd/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654", size = 204560, upload-time = "2026-02-02T12:35:53.925Z" }, + { url = "https://files.pythonhosted.org/packages/44/26/97cc40663deb17b9e13c3a5cf29251788c271b18ee4d262c8f94798b8336/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5", size = 189608, upload-time = "2026-02-02T12:35:55.304Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/3c29819a27178d0e461a8571fb63c6ae38be6dc36b78b3ec2876bbd6a910/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c", size = 307016, upload-time = "2026-02-02T12:37:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/60993e4b07b1ac5ebe46da7aa99fdbb802eb986c38d26e3883ac0125c4e0/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2", size = 305024, upload-time = "2026-02-02T12:37:44.774Z" }, + { url = "https://files.pythonhosted.org/packages/77/fa/2227e590e9cf98803db2811f172b2d6460a21539ab73006f251c66f44b14/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434", size = 339337, upload-time = "2026-02-02T12:37:46.668Z" }, + { url = "https://files.pythonhosted.org/packages/2d/92/015173281f7eb96c0ef580c997da8ef50870d4f7f4c9e03c845a1d62ae04/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d", size = 346395, upload-time = "2026-02-02T12:37:48.09Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "multidict" +version = "6.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/99/1d4d69c3512d0ddbfa3a1b69cfd9a151012ab2eb4eabbb096201b1f0b7d8/multidict-6.9.1.tar.gz", hash = "sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2", size = 182404, upload-time = "2026-09-21T17:59:05.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/24/3823efc330630a1f132bcd0a9b182ddec3c53f452f551c8e8d3aaf5a2d20/multidict-6.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:910d4260512660484c0dc1588a316fbb35a40c081c36fc51d1225351af17cfe4", size = 96739, upload-time = "2026-09-21T17:54:42.815Z" }, + { url = "https://files.pythonhosted.org/packages/6a/69/36331fe1d3aeb0c525a9972d6cf82791075a395aa96f026f060009d691ae/multidict-6.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33fa55b990f81c2927e01399ace0d18926c69d69baa8cdaa819424132fb97987", size = 58514, upload-time = "2026-09-21T17:54:44.064Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/5860651f782078ef13f2761c2593f4ba066d76f626b2f30f23a7f838b775/multidict-6.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:369b5aa01b241cd3fea6890bdbb11a1425d87bf1515831500d518f4223e9d72c", size = 57303, upload-time = "2026-09-21T17:54:45.304Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9f/c56e2fa223cb5d182660e1a09eec38880f96b7cd22769582f74447c60418/multidict-6.9.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:803f8b575a71b1b299d677c28db0653459c79b5308874efec813f17b7457c7f1", size = 320076, upload-time = "2026-09-21T17:54:46.826Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/eda4fe0cad51f96096363ed32d3e0f8df28dab00beff4d92adc9db517726/multidict-6.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8a65621b98984a62e59403009591b8a5a7736273aefe1cab64cfb85b365cc07", size = 315489, upload-time = "2026-09-21T17:54:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/14/10b1ded0a4085a51c3054125f854da4f47ce4fec724a2d2503231b83f3db/multidict-6.9.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:13849a1d4f54c3809ae721e9e83ab28f5ea602f33660cb84eb6ef261eac706c1", size = 296716, upload-time = "2026-09-21T17:54:49.749Z" }, + { url = "https://files.pythonhosted.org/packages/dc/14/04d155dd18528443cb9009f3a9f35f7417773797d072c95104ff17b8b9b7/multidict-6.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd9a137a4a9becda3094f3831cd026380f75f6855e051eefe4c73ade524f1cc3", size = 330244, upload-time = "2026-09-21T17:54:51.26Z" }, + { url = "https://files.pythonhosted.org/packages/8b/90/284d10b3a9e5b312a8ec5b7a175fc7d560eefdc99886320449891a197992/multidict-6.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ae9614c317c50836689ce2dfde07c05fe0b16378562e2221746c3913ede3c80", size = 331288, upload-time = "2026-09-21T17:54:52.595Z" }, + { url = "https://files.pythonhosted.org/packages/27/50/f420de3683f9b047fc5588064d84043ef57a7152ef451b8373dfc7068d22/multidict-6.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8f1e362c9352b50ed120f001046fdbb80810c9d56580f4c3fc13bbe30823387", size = 320663, upload-time = "2026-09-21T17:54:53.91Z" }, + { url = "https://files.pythonhosted.org/packages/01/ab/0120a650d7ce4fe167299c13a13cc4ee2dc72458a9c0e9e9330b824b73c9/multidict-6.9.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dd655518f136febd96c05131a76a863e32fc2a1d7acd4e3c959e3ceb77d8345", size = 291139, upload-time = "2026-09-21T17:54:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/c66342f73ee5cd2c9b1dda6cfce89b5f348c48921b8c8b6e59aafbbfe31a/multidict-6.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c5d675da8f1cb5650271c8ad5e95c0a3e5a183c105e72d953b12877b1c8d0fd", size = 310044, upload-time = "2026-09-21T17:54:56.745Z" }, + { url = "https://files.pythonhosted.org/packages/05/0d/e44b90d9e77e44ec3473c4c7ac7ea95764d7e8fbf6d5a085ef9bff7ce0c2/multidict-6.9.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:658f90f49cf5af2441cad0a2b801c3ef520471989a1ec55bcb25b255b2ca8d2f", size = 303279, upload-time = "2026-09-21T17:54:58.178Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/7741f7c23c211fae31ab546f0ba41569dae7671cf7ca30d5afb59e9d92aa/multidict-6.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:43124fe172ada86d03ac3c8dc8179091341f6724d5e5d5b160e1587e4cd3761b", size = 326177, upload-time = "2026-09-21T17:54:59.825Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/0cdbfbbe36316b2ef4b8e011afaca2cf291a73fe4fabf34dd0b22e3ee48a/multidict-6.9.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f0adc22a4eb31e545221d93fc73a0f6a8cc2379d0f4309f71d1d17ba938b82", size = 323526, upload-time = "2026-09-21T17:55:01.43Z" }, + { url = "https://files.pythonhosted.org/packages/98/05/0c86e9678e78f4596df0ed7f59d2aa755107da3f2a92f1b7fa150e94cbb8/multidict-6.9.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7fc59e9ba821b220944ccfe0f89c9dc4745f6d097569992356eb03869f21e953", size = 290278, upload-time = "2026-09-21T17:55:02.993Z" }, + { url = "https://files.pythonhosted.org/packages/95/8e/71bd7f43c4883cba6c5f96db88bd2311d52e74ad7680be5915938490d56f/multidict-6.9.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:87cc632c88ee5dc80e12681047839304d98ee5c9a708d686505767001c9b8b9a", size = 321142, upload-time = "2026-09-21T17:55:04.48Z" }, + { url = "https://files.pythonhosted.org/packages/2d/00/bf59d6bb22a4c152e4039574c29490caf3c65107715121807b4bd7cde244/multidict-6.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b828cf64d62dc09ac183f03c1aeedd164ade96a2ce4934109452edf29de1dd13", size = 319045, upload-time = "2026-09-21T17:55:06.119Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/f27f4f045198bf1c4bcd6eb3b6cf06be2dceaceb6e67222b903fce7a6749/multidict-6.9.1-cp311-cp311-win32.whl", hash = "sha256:2c1aeb92eea59d824f004341b26d5e4b47a8a441cf9726769b9a90abf9d0e08f", size = 51309, upload-time = "2026-09-21T17:55:07.531Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/c2064aef64efc007bc89a571f843e5772f27946eb9bc018731bf8016e319/multidict-6.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:5f89dad732280e7a10b74d40b91364f88e13c3f2c08c2ef83a8cd42f7a61af2e", size = 59148, upload-time = "2026-09-21T17:55:08.936Z" }, + { url = "https://files.pythonhosted.org/packages/45/a1/3bab1827edd813dfb90712af5cbb7fc1473ed4d9c871d103df4f4bb950c2/multidict-6.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:5800368526647146978389dfaa46da3356291e9f0fff9a4ef12e8c2bef964a0d", size = 54258, upload-time = "2026-09-21T17:55:10.191Z" }, + { url = "https://files.pythonhosted.org/packages/be/59/e26cb779be4c591d1a910f59d29aca9fba4de70349840a833beba2652371/multidict-6.9.1-py3-none-any.whl", hash = "sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b", size = 19176, upload-time = "2026-09-21T17:59:03.501Z" }, +] + +[[package]] +name = "openai" +version = "2.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/a1/4d5e84cf51720fc1526cc49e10ac1961abcccb55b0efb3d970db1e9a2728/openai-2.36.0.tar.gz", hash = "sha256:139dea0edd2f1b30c33d46ae1a6929e03906254140318e4608e98fe8c566f2e7", size = 753003, upload-time = "2026-05-07T17:33:17.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/1c/5d43735b2553baae2a5e899dcbcd0670a86930d993184d72ca909bf11c9b/openai-2.36.0-py3-none-any.whl", hash = "sha256:143f6194b548dbc2c921af1f1b03b9f14c85fed8a75b5b516f5bcc11a2a50c63", size = 1302361, upload-time = "2026-05-07T17:33:15.063Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/dd/65804b0c2925a1c821a05502ea57517b69a073ff400d25ab9faa3a2cf012/platformdirs-4.11.12.tar.gz", hash = "sha256:e8dc1cb58f1153fd7f61db1374317770baababec2480b37b8f01c6cc25b45267", size = 40016, upload-time = "2026-09-22T01:02:36.973Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/2b/69de6317fbf48865bb12aa56c9cfcfff46b3add0cd45250725d96146dce0/platformdirs-4.11.12-py3-none-any.whl", hash = "sha256:b1ba966ac76153d9985a0787ffab951e7c18416189873d4e4ddb2c8a04a0a254", size = 24976, upload-time = "2026-09-22T01:02:35.742Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/9a/9fbf4e4ec0c2d7f1c32519fff782ef467859b8faa9fbc5331a96f6395d43/propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558", size = 61545, upload-time = "2026-09-16T00:17:14.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/40/14b21e505b7921617466576423f188a5c9caddfdaa1cf4b2b8a83d8fe216/propcache-0.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:897d1ddf6716e8f47200f7aad9a0efa6cc7586df66c6defa572f9eab379c078e", size = 86393, upload-time = "2026-09-16T00:14:06.9Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4b/5a52e1a7b43563f7d408814194bb23cc8bf214eb6b86639b667a33a8d0d0/propcache-0.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9cbfff4423eef4cc6cafc021469641a2b835f610b2647a6c5281903e21b8670d", size = 50431, upload-time = "2026-09-16T00:14:08.025Z" }, + { url = "https://files.pythonhosted.org/packages/05/cf/b5248180bf056cc76acc60c9c6e8c0ebbfdbd1c6cffd31fd14996927b7c8/propcache-0.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3fc24f209c1b7f7f688b66b98293954f5504279760999b58920ee12dd8471c1d", size = 52116, upload-time = "2026-09-16T00:14:09.114Z" }, + { url = "https://files.pythonhosted.org/packages/86/a8/7c6cd6bfead1a11f2e411e688640e6d26574cb0bde7dcaa7423b0b65ed7a/propcache-0.5.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62530ca89187827e4a4fe733f971abe81a7542eeea48ff61995f19b64d7199c8", size = 238729, upload-time = "2026-09-16T00:14:10.357Z" }, + { url = "https://files.pythonhosted.org/packages/5c/b4/442715b2e980df51be52d203549279e027728f24c80b00b5e525e31cd5ea/propcache-0.5.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fc3f7599528db40b1efa0889a620116e2704144495273d66066e8164e45838", size = 246121, upload-time = "2026-09-16T00:14:11.735Z" }, + { url = "https://files.pythonhosted.org/packages/bc/5d/df0684fc2b1732a01a7bec26d7897369022712422d25b09c37ce7dbc88a2/propcache-0.5.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f2d880ff60f45898f4acfa152aac8d04e3ee627d90ff4003491bf92239d5757", size = 251735, upload-time = "2026-09-16T00:14:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/c7/06/519a5ebb48b6f94beb48396e55c905f12246a25c3a3608a7ec7bceabf50e/propcache-0.5.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e9368e87a3efc285e559131092c5db643eb8e56de4ee42064d5baec22ef2bb5", size = 235381, upload-time = "2026-09-16T00:14:14.398Z" }, + { url = "https://files.pythonhosted.org/packages/3c/07/1e0a9bb310830f2245edbd5cd3c6d24a783c053c4efd8e08e386e513c940/propcache-0.5.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:004e685b315646c410771836e72a44f143bbe624f29653a42687815069a303d5", size = 208973, upload-time = "2026-09-16T00:14:15.715Z" }, + { url = "https://files.pythonhosted.org/packages/62/5c/9324fab27d6088eecc47fe4332bf7aaf8c1ded93c36f558391e8a06d41a7/propcache-0.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:594eb4c6ec35e7179b058481f4e9f02521b56de16fa577c4b85c76fb1bf8a9f8", size = 233897, upload-time = "2026-09-16T00:14:17.25Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a3/570d92fc952eae93b676f3a1568f4b89264102abd3c982ab6a9ebec58dcf/propcache-0.5.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2dba2f02d2d5c09ef8a0e6c1a42aeaa451f4be9898cb00b04fe98717da2eb23b", size = 223512, upload-time = "2026-09-16T00:14:18.87Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/26810d889d89bba31db397e6a88f8984af775f5ed6bad0a29dce84324cff/propcache-0.5.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c3ef2818d63bc86071e9d2989ae75a1bc32b8f7059cfd9f5abbbee70c32e2ed6", size = 239043, upload-time = "2026-09-16T00:14:20.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/f9c47691aa024c8299a3afacd78d22a01ab57eb627b481b6089708e71017/propcache-0.5.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:dd2ac8f5b643454c2cc6b6118b13da16e88f4a6434fc3ba61aca384029f04f36", size = 208218, upload-time = "2026-09-16T00:14:21.801Z" }, + { url = "https://files.pythonhosted.org/packages/74/6b/d510c0c378cabbf9d0ac7b663af6d00f2e9074073d93b20c85f24aa5c071/propcache-0.5.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4054acf80d40456a0537f2913b349718649d8d6458a14ab7f48d0ce28c30869d", size = 240301, upload-time = "2026-09-16T00:14:23.121Z" }, + { url = "https://files.pythonhosted.org/packages/3d/80/c80f6adaaa1e51f0db2dce8c9b3714d94ec45e358a21f9a1910b10b40a80/propcache-0.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:40e94adb1e7d39ff28a8bd8d8b8fbd1df6b9f40976dbe379134f1ce058e532dd", size = 230785, upload-time = "2026-09-16T00:14:24.458Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e2/c32a7df3f39caa7f11b2eb37ea5b6960a6946f2bc7c4b8ff97bbdf6d6b6e/propcache-0.5.4-cp311-cp311-win32.whl", hash = "sha256:9f86f7259efe2c951f43e57d471c9b41daa5bfc7db9f67189059cf1ae6d77fd9", size = 42747, upload-time = "2026-09-16T00:14:25.715Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8a/3db6a3543d8101263b4c52978b6276a04ead2caff2c5ab880d934f47bd89/propcache-0.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:e904d4d01f36bd6e197590be1533c44e06058771e0746dd073a8ebb3ef880858", size = 46268, upload-time = "2026-09-16T00:14:26.996Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/5222e2665bbf6e45847492ecbf3b9f3e4975a0ae300e5fd465df7d48ce55/propcache-0.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:d42a9a856a4a6e2f6c10f1318c07e7daa498d6593abe745c71dae4521a26ca39", size = 43547, upload-time = "2026-09-16T00:14:28.143Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cd/785c64ed382f3f04201870267b02783f63b4678c2acfddc177a3ebcc2727/propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468", size = 16338, upload-time = "2026-09-16T00:17:13.106Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550, upload-time = "2025-02-27T10:10:32.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839, upload-time = "2025-02-27T10:10:30.711Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pymongo" +version = "4.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/35/b62a3139f908c68b69aac6a6a3f8cc146869de0a7929b994600e2c587c77/pymongo-4.10.1.tar.gz", hash = "sha256:a9de02be53b6bb98efe0b9eda84ffa1ec027fcb23a2de62c4f941d9a2f2f3330", size = 1903902, upload-time = "2024-10-01T23:07:58.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/a3/d6403ec53fa2fe922b4a5c86388ea5fada01dd51d803e17bb2a7c9cda839/pymongo-4.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:57ee6becae534e6d47848c97f6a6dff69e3cce7c70648d6049bd586764febe59", size = 889238, upload-time = "2024-10-01T23:06:36.03Z" }, + { url = "https://files.pythonhosted.org/packages/29/a2/9643450424bcf241e80bb713497ec2e3273c183d548b4eca357f75d71885/pymongo-4.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6f437a612f4d4f7aca1812311b1e84477145e950fdafe3285b687ab8c52541f3", size = 889504, upload-time = "2024-10-01T23:06:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/ec/40/4759984f34415509e9111be8ee863034611affdc1e0b41016c9d53b2f1b3/pymongo-4.10.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a970fd3117ab40a4001c3dad333bbf3c43687d90f35287a6237149b5ccae61d", size = 1649069, upload-time = "2024-10-01T23:06:38.553Z" }, + { url = "https://files.pythonhosted.org/packages/56/0f/b6e917478a3ada81e768475516cd544982cc42cbb7d3be325182768139e1/pymongo-4.10.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c4d0e7cd08ef9f8fbf2d15ba281ed55604368a32752e476250724c3ce36c72e", size = 1714927, upload-time = "2024-10-01T23:06:40.292Z" }, + { url = "https://files.pythonhosted.org/packages/56/c5/4237d94dfa19ebdf9a92b1071e2139c91f48908c5782e592c571c33b67ab/pymongo-4.10.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca6f700cff6833de4872a4e738f43123db34400173558b558ae079b5535857a4", size = 1683454, upload-time = "2024-10-01T23:06:42.257Z" }, + { url = "https://files.pythonhosted.org/packages/9a/16/dbffca9d4ad66f2a325c280f1177912fa23235987f7b9033e283da889b7a/pymongo-4.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cec237c305fcbeef75c0bcbe9d223d1e22a6e3ba1b53b2f0b79d3d29c742b45b", size = 1653840, upload-time = "2024-10-01T23:06:43.991Z" }, + { url = "https://files.pythonhosted.org/packages/2b/4d/21df934ef5cf8f0e587bac922a129e13d4c0346c54e9bf2371b90dd31112/pymongo-4.10.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3337804ea0394a06e916add4e5fac1c89902f1b6f33936074a12505cab4ff05", size = 1613233, upload-time = "2024-10-01T23:06:46.113Z" }, + { url = "https://files.pythonhosted.org/packages/24/07/dd9c3db30e754680606295d5574521956898005db0629411a89163cc6eee/pymongo-4.10.1-cp311-cp311-win32.whl", hash = "sha256:778ac646ce6ac1e469664062dfe9ae1f5c9961f7790682809f5ec3b8fda29d65", size = 857331, upload-time = "2024-10-01T23:06:47.812Z" }, + { url = "https://files.pythonhosted.org/packages/02/68/b71c4106d03eef2482eade440c6f5737c2a4a42f6155726009f80ea38d06/pymongo-4.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:9df4ab5594fdd208dcba81be815fa8a8a5d8dedaf3b346cbf8b61c7296246a7a", size = 876473, upload-time = "2024-10-01T23:06:49.201Z" }, +] + +[[package]] +name = "pyparsing" +version = "2.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/47/dfc9c342c9842bbe0036c7f763d2d6686bcf5eb1808ba3e170afdb282210/pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", size = 649718, upload-time = "2020-04-05T22:21:25.349Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/bb/488841f56197b13700afd5658fc279a2025a39e22449b7cf29864669b15d/pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b", size = 67842, upload-time = "2020-04-05T22:21:22.897Z" }, +] + +[[package]] +name = "pytest" +version = "7.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/d4/174f020da50c5afe9f5963ad0fc5b56a4287e3586e3de5b3c8bce9c547b4/pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5", size = 1356179, upload-time = "2023-10-24T19:23:16.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/8c/f16efd81ca8e293b2cc78f111190a79ee539d0d5d36ccd49975cb3beac60/pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac", size = 325075, upload-time = "2023-10-24T19:23:14.124Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/85/d39ef5f69d5597a206f213ce387bcdfa47922423875829f7a98a87d33281/pytest-asyncio-0.21.1.tar.gz", hash = "sha256:40a7eae6dded22c7b604986855ea48400ab15b069ae38116e8c01238e9eeb64d", size = 30492, upload-time = "2023-07-12T10:19:59.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/2c/2e5ab8708667972ee31b88bb6fed680ed5ba92dfc2db28e07d0d68d8b3b1/pytest_asyncio-0.21.1-py3-none-any.whl", hash = "sha256:8666c1c8ac02631d7c51ba282e0c69a8a452b211ffedf2599099845da5c5c37b", size = 13228, upload-time = "2023-07-12T10:19:57.81Z" }, +] + +[[package]] +name = "pytest-cov" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/15/da3df99fd551507694a9b01f512a2f6cf1254f33601605843c3775f39460/pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6", size = 63245, upload-time = "2023-05-24T18:44:56.845Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/4b/8b78d126e275efa2379b1c2e09dc52cf70df16fc3b90613ef82531499d73/pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a", size = 21949, upload-time = "2023-05-24T18:44:54.079Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/0f/9c55ac6c84c0336e22a26fa84ca6c51d58d7ac3a2d78b0dfa8748826c883/python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026", size = 31516, upload-time = "2024-02-10T13:32:04.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/47/444768600d9e0ebc82f8e347775d24aef8f6348cf00e9fa0e81910814e6d/python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215", size = 22299, upload-time = "2024-02-10T13:32:02.969Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/e5/af35f7ea75cf72f2cd079c95ee16797de7cd71f29ea7c68ae5ce7be1eda0/PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", size = 125201, upload-time = "2023-07-18T00:00:23.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/0d/26fb23e8863e0aeaac0c64e03fd27367ad2ae3f3cccf3798ee98ce160368/PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", size = 187867, upload-time = "2023-07-17T23:57:34.35Z" }, + { url = "https://files.pythonhosted.org/packages/28/09/55f715ddbf95a054b764b547f617e22f1d5e45d83905660e9a088078fe67/PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", size = 167530, upload-time = "2023-07-17T23:57:36.975Z" }, + { url = "https://files.pythonhosted.org/packages/5e/94/7d5ee059dfb92ca9e62f4057dcdec9ac08a9e42679644854dc01177f8145/PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", size = 732244, upload-time = "2023-07-17T23:57:43.774Z" }, + { url = "https://files.pythonhosted.org/packages/06/92/e0224aa6ebf9dc54a06a4609da37da40bb08d126f5535d81bff6b417b2ae/PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", size = 752871, upload-time = "2023-07-17T23:57:51.921Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5e/efd033ab7199a0b2044dab3b9f7a4f6670e6a52c089de572e928d2873b06/PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", size = 757729, upload-time = "2023-07-17T23:57:59.865Z" }, + { url = "https://files.pythonhosted.org/packages/03/5c/c4671451b2f1d76ebe352c0945d4cd13500adb5d05f5a51ee296d80152f7/PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", size = 748528, upload-time = "2023-08-28T18:43:23.207Z" }, + { url = "https://files.pythonhosted.org/packages/73/9c/766e78d1efc0d1fca637a6b62cea1b4510a7fb93617eb805223294fef681/PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", size = 130286, upload-time = "2023-07-17T23:58:02.964Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/65bb4b2d7908044963ebf614fe0fdb080773fc7030d7e39c8d3eddcd4257/PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", size = 144699, upload-time = "2023-07-17T23:58:05.586Z" }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, +] + +[[package]] +name = "rich" +version = "14.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/67/cae617f1351490c25a4b8ac3b8b63a4dda609295d8222bad12242dfdc629/rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9", size = 230524, upload-time = "2026-04-11T02:57:45.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "shotgun-api3" +version = "3.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/bc/6fe2f583b7c518a7c3c2f8447985e0c286132c10f7778d9df02cc7390b75/shotgun_api3-3.9.2.tar.gz", hash = "sha256:42b3db7f2c308f66cfd9a040ff515d0308d4e0f0e56ff5bef9a2adb724dc0deb", size = 418150, upload-time = "2025-12-10T22:48:01.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/9b/1bc875ad0892f61d73c1b3e54b7aba17955187d1f6e1110f4f4e89649108/shotgun_api3-3.9.2-py2.py3-none-any.whl", hash = "sha256:0f77320e447f43ee9ff374560e823d10aa3779d3579cecf88acbeb448f2a2b6b", size = 386026, upload-time = "2025-12-10T22:48:00.772Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, +] + +[[package]] +name = "sphinx" +version = "9.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, +] + +[[package]] +name = "sphinx-notfound-page" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/b2/67603444a8ee97b4a8ea71b0a9d6bab1727ed65e362c87e02f818ee57b8a/sphinx_notfound_page-1.1.0.tar.gz", hash = "sha256:913e1754370bb3db201d9300d458a8b8b5fb22e9246a816643a819a9ea2b8067", size = 7392, upload-time = "2025-01-28T18:45:02.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/d4/019fe439c840a7966012bbb95ccbdd81c5c10271749706793b43beb05145/sphinx_notfound_page-1.1.0-py3-none-any.whl", hash = "sha256:835dc76ff7914577a1f58d80a2c8418fb6138c0932c8da8adce4d9096fbcd389", size = 8167, upload-time = "2025-01-28T18:45:00.465Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "starlette" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/68/559bed5484e746f1ab2ebbe22312f2c25ec62e4b534916d41a8c21147bf8/starlette-0.27.0.tar.gz", hash = "sha256:6a6b0d042acb8d469a01eba54e9cda6cbd24ac602c4cd016723117d6a7e73b75", size = 51394, upload-time = "2023-05-16T10:59:56.286Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/f8/e2cca22387965584a409795913b774235752be4176d276714e15e1a58884/starlette-0.27.0-py3-none-any.whl", hash = "sha256:918416370e846586541235ccd38a474c08b80443ed31c578a418e2209b3eef91", size = 66978, upload-time = "2023-05-16T10:59:53.927Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/ea/b2a5bd54b28a324dae8211928b2d730b6547500342c7e6c6dea08bd0a485/tqdm-4.70.1.tar.gz", hash = "sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4", size = 171846, upload-time = "2026-09-11T07:25:16.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73", size = 80199, upload-time = "2026-09-11T07:25:14.599Z" }, +] + +[[package]] +name = "typer" +version = "0.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045, upload-time = "2026-08-28T10:26:55.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130, upload-time = "2026-08-28T10:26:53.752Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/c9/dc0b3b6f944271d5f71564c2db08a1879a384cda7100f6f2f71b4ec9b751/uvicorn-0.24.0.tar.gz", hash = "sha256:368d5d81520a51be96431845169c225d771c9dd22a58613e1a181e6c4512ac33", size = 40092, upload-time = "2023-11-04T19:31:11.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/0c/a9b90a856bbdd75bf71a1dd191af1e9c9ac8a272ed337f7200950c3d3dd4/uvicorn-0.24.0-py3-none-any.whl", hash = "sha256:3d19f13dfd2c2af1bfe34dd0f7155118ce689425fdf931177abe832ca44b8a04", size = 59609, upload-time = "2023-11-04T19:31:09.321Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" }, + { url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" }, + { url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" }, + { url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" }, + { url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" }, + { url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" }, + { url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" }, + { url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/cd/eb/289fec71eba51d6c3ef3a28c81f7213f4e860084ffae5abc866933c564f0/watchfiles-1.3.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:45726c6b5a8d67c8087eb2933c4398948546d04206ef40f629dc4881d65d23d1", size = 396932, upload-time = "2026-09-21T09:08:50.217Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/a3c3f557992a20a5bb24607f0da44d39cf6d6ba3f8f6ed7cb31f6c73371a/watchfiles-1.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6398d250994baff581147ccba846223076fe6492ca93c0d1b3588fcf5355eb5c", size = 394852, upload-time = "2026-09-21T09:08:51.412Z" }, + { url = "https://files.pythonhosted.org/packages/55/65/d45763a5e1108a65af85a217af2930ebf1081c73fd8169d3f75e586992bb/watchfiles-1.3.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:591de392458bca26f6130fea4e667c06b657e873ad334ea06fad5a79f5086cef", size = 451789, upload-time = "2026-09-21T09:08:53.144Z" }, + { url = "https://files.pythonhosted.org/packages/02/96/165c6e540bf765ca1115062017adf861d0816525f02464ba71313fe7cf92/watchfiles-1.3.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ec46a1ff4c1d81405a5fc645abdbff5e4e9388f7c9a6cdb392316a7c29b884f", size = 456919, upload-time = "2026-09-21T09:08:54.372Z" }, +] + +[[package]] +name = "websocket-client" +version = "0.59.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/bf/c706b56243f1641159ff211b51d3341024e1cdf63defc2b74595b6319039/websocket-client-0.59.0.tar.gz", hash = "sha256:d376bd60eace9d437ab6d7ee16f4ab4e821c9dae591e1b783c58ebd8aaf80c5c", size = 57897, upload-time = "2021-05-05T01:43:07.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/0c/d52a2a63512a613817846d430d16a8fbe5ea56dd889e89c68facf6b91cb6/websocket_client-0.59.0-py2.py3-none-any.whl", hash = "sha256:2e50d26ca593f70aba7b13a489435ef88b8fc3b5c5643c1ce8808ff9b40f0b32", size = 67216, upload-time = "2021-05-05T01:43:04.863Z" }, +] + +[[package]] +name = "websockets" +version = "12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/62/7a7874b7285413c954a4cca3c11fd851f11b2fe5b4ae2d9bee4f6d9bdb10/websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b", size = 104994, upload-time = "2023-10-21T14:21:11.88Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/73/9c1e168a2e7fdf26841dc98f5f5502e91dea47428da7690a08101f616169/websockets-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5d873c7de42dea355d73f170be0f23788cf3fa9f7bed718fd2830eefedce01b4", size = 124047, upload-time = "2023-10-21T14:19:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/e4/2d/9a683359ad2ed11b2303a7a94800db19c61d33fa3bde271df09e99936022/websockets-12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f61726cae9f65b872502ff3c1496abc93ffbe31b278455c418492016e2afc8f", size = 121282, upload-time = "2023-10-21T14:19:47.739Z" }, + { url = "https://files.pythonhosted.org/packages/95/aa/75fa3b893142d6d98a48cb461169bd268141f2da8bfca97392d6462a02eb/websockets-12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed2fcf7a07334c77fc8a230755c2209223a7cc44fc27597729b8ef5425aa61a3", size = 121325, upload-time = "2023-10-21T14:19:49.4Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a4/51a25e591d645df71ee0dc3a2c880b28e5514c00ce752f98a40a87abcd1e/websockets-12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e332c210b14b57904869ca9f9bf4ca32f5427a03eeb625da9b616c85a3a506c", size = 131502, upload-time = "2023-10-21T14:19:50.683Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ea/0ceeea4f5b87398fe2d9f5bcecfa00a1bcd542e2bfcac2f2e5dd612c4e9e/websockets-12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5693ef74233122f8ebab026817b1b37fe25c411ecfca084b29bc7d6efc548f45", size = 130491, upload-time = "2023-10-21T14:19:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/e3/05/f52a60b66d9faf07a4f7d71dc056bffafe36a7e98c4eb5b78f04fe6e4e85/websockets-12.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e9e7db18b4539a29cc5ad8c8b252738a30e2b13f033c2d6e9d0549b45841c04", size = 130872, upload-time = "2023-10-21T14:19:53.071Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4e/c7361b2d7b964c40fea924d64881145164961fcd6c90b88b7e3ab2c4f431/websockets-12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6e2df67b8014767d0f785baa98393725739287684b9f8d8a1001eb2839031447", size = 136318, upload-time = "2023-10-21T14:19:54.41Z" }, + { url = "https://files.pythonhosted.org/packages/0a/31/337bf35ae5faeaf364c9cddec66681cdf51dc4414ee7a20f92a18e57880f/websockets-12.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bea88d71630c5900690fcb03161ab18f8f244805c59e2e0dc4ffadae0a7ee0ca", size = 135594, upload-time = "2023-10-21T14:19:55.982Z" }, + { url = "https://files.pythonhosted.org/packages/95/aa/1ac767825c96f9d7e43c4c95683757d4ef28cf11fa47a69aca42428d3e3a/websockets-12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dff6cdf35e31d1315790149fee351f9e52978130cef6c87c4b6c9b3baf78bc53", size = 136191, upload-time = "2023-10-21T14:19:57.349Z" }, + { url = "https://files.pythonhosted.org/packages/28/4b/344ec5cfeb6bc417da097f8253607c3aed11d9a305fb58346f506bf556d8/websockets-12.0-cp311-cp311-win32.whl", hash = "sha256:3e3aa8c468af01d70332a382350ee95f6986db479ce7af14d5e81ec52aa2b402", size = 124453, upload-time = "2023-10-21T14:19:59.11Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/6b169cd1957476374f51f4486a3e85003149e62a14e6b78a958c2222337a/websockets-12.0-cp311-cp311-win_amd64.whl", hash = "sha256:25eb766c8ad27da0f79420b2af4b85d29914ba0edf69f547cc4f06ca6f1d403b", size = 124971, upload-time = "2023-10-21T14:20:00.243Z" }, + { url = "https://files.pythonhosted.org/packages/79/4d/9cc401e7b07e80532ebc8c8e993f42541534da9e9249c59ee0139dcb0352/websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e", size = 118370, upload-time = "2023-10-21T14:21:10.075Z" }, +] + +[[package]] +name = "yarl" +version = "1.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/b3/2cea721d495ca57f8f414aa4867ee263f486b274e07463158cf52f02ba7f/yarl-1.25.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d693bf4bf534e9ba3ae2780cfd577f5135629f7b5ac653490859d0b77864865", size = 143794, upload-time = "2026-09-15T19:30:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/55/e6/cd145cff8e5cf60b8b3c41fbecfa2a45028a8dec3fbc52bec03595ff3d3b/yarl-1.25.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ab2054c5531af2a9ba7b69b8ec91e4f884420e83a8c5e579b013084cb57e5e5d", size = 103993, upload-time = "2026-09-15T19:30:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7c/fbb40fe2d53747c40aa36a9e2bd2178a202f942bda0b670f3306d4aefbce/yarl-1.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:564fdc7085d2245ab84f88882fdb1d6ac0723124bff6ded35bfb1c00f812630d", size = 104010, upload-time = "2026-09-15T19:30:31.069Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0b/5a516f70641092283f57cf3670bdb75e7327bcc0dcb5038697e4dfbfd569/yarl-1.25.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acae6b45d1ace09b6ba3876da43b88366ef368f73b988c7f57e14231753d4420", size = 116444, upload-time = "2026-09-15T19:30:32.894Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/d1a627f827b0a404ae0f5647cab0534959081cde13b0756a783469fb3b5e/yarl-1.25.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1fb2a01ba8cd9c5d2c5dc1ec35e0fc951d04b4f037541d4ac090c993ce58b3d7", size = 107565, upload-time = "2026-09-15T19:30:35.188Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cf/c8e0aaec886840a6c4480fe44eaec7cd4319f79a563b79321473be79c56f/yarl-1.25.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e92b6bcc741b86d67606c40d3cb9c7cc8e6c737f81e31f4a94efc204456c92e3", size = 125006, upload-time = "2026-09-15T19:30:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/50/26/0cce366d54a93cdc8342965dc7663385e4db161625cc1e8b18e786753d24/yarl-1.25.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72c34ac7ad4314c19362d5ce27626dcc8429bd30bbf8c179f4234078851f9492", size = 128717, upload-time = "2026-09-15T19:30:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/a71501bbc1a674ff72c4d6c2b75f4d9a5af819f5244c3a7558080a8802c5/yarl-1.25.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5add7b4ca7afeea91d52e4d4e4db3b1fe9885b71f07054560d8c4296b7441a2", size = 117728, upload-time = "2026-09-15T19:30:40.809Z" }, + { url = "https://files.pythonhosted.org/packages/e7/6f/c3267ca01defeed9ed9c4ff9b17bd54915432c405945233265b707475d1c/yarl-1.25.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:def538065f9e4d4cf1ae164bd59aba00dfa84f03923e0de4c3788f252d6bcd17", size = 116224, upload-time = "2026-09-15T19:30:42.818Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/fad57ee52d648431718ee0f1f68966a99c1352c3924688ffbdcc9d3fe51a/yarl-1.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de", size = 116299, upload-time = "2026-09-15T19:30:44.966Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d4/6a8c1e29f33338687ca278cba0a8fbf6525a322c2c02a9a500ccbe041152/yarl-1.25.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:71f42c5b9a948c113bbdebfa544598321431d064ff959d32e99b1feb61d68345", size = 108625, upload-time = "2026-09-15T19:30:46.874Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d2/3a35ae791c9cb6522c106923ff25c3230d999091e5e65511bca23bbd9914/yarl-1.25.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:72849d892954be4d09e569b8b831ac39ce58417fedc767d4308a0fe542018a40", size = 124515, upload-time = "2026-09-15T19:30:49.082Z" }, + { url = "https://files.pythonhosted.org/packages/be/fd/2b022109a6b4af0f7dc371cf7500af380b0d4f034010243e1b0ce218dc93/yarl-1.25.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:efb01a106f971cb3752856bca2318bbdf7f01bd8823779c461586cbe5ffd5258", size = 115711, upload-time = "2026-09-15T19:30:51.208Z" }, + { url = "https://files.pythonhosted.org/packages/18/59/f7586271136c3ddb0126bbfe661844699369b76b555fe38c4efe86870b2e/yarl-1.25.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a1daf47cd95a7c3a63456336bc5aaa8c86dd3a47d07ed3d0e76132ae4666a5a1", size = 122751, upload-time = "2026-09-15T19:30:53.535Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0b/07f7a2d881f7e16c385b47fc1753382600847cad305dcc7fa0c25828acf8/yarl-1.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9489e6abf47ba37f332075a91444c7cfedb03e6ce99fbb2f116bfe1ce810da3b", size = 117983, upload-time = "2026-09-15T19:30:55.277Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9d/8cdceec66a9b940700cb45931741403f045b162afd79bcb93c41cadd0972/yarl-1.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7306dee25b8a0e737363f347362b875094b4dc4e367311470656ae420fdbf8e", size = 102894, upload-time = "2026-09-15T19:30:57.606Z" }, + { url = "https://files.pythonhosted.org/packages/17/f1/7ec357db1d3ad2863542d71e8fe64a126bbec17b134cd7d30951196809a5/yarl-1.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:abb1384477f5901d436b5d2e5465954de46ea6098f59163d243660b5c4461d35", size = 98609, upload-time = "2026-09-15T19:30:59.705Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, +] diff --git a/flake.lock b/flake.lock index 4d053248..38e88be4 100644 --- a/flake.lock +++ b/flake.lock @@ -34,10 +34,59 @@ "type": "github" } }, + "pyproject-build-systems": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "pyproject-nix": [ + "pyproject-nix" + ], + "uv2nix": [ + "uv2nix" + ] + }, + "locked": { + "lastModified": 1789963190, + "narHash": "sha256-mj5BLUKECIHv3rwkBXL6xQp45gIBJV7ZOYgT46LXwho=", + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "rev": "14b45373ea9a14ed2fcd0abf3ed35c8e3c430020", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "type": "github" + } + }, + "pyproject-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1788394333, + "narHash": "sha256-xYOP0Xym1eVGUaHFx+eJEaycFGoiCbst3fZf0xFHP78=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "6a8a7881d75b6f98967e7b8069f4ead331384301", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "type": "github" + } + }, "root": { "inputs": { "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "pyproject-build-systems": "pyproject-build-systems", + "pyproject-nix": "pyproject-nix", + "uv2nix": "uv2nix" } }, "systems": { @@ -54,6 +103,29 @@ "repo": "default", "type": "github" } + }, + "uv2nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "pyproject-nix": [ + "pyproject-nix" + ] + }, + "locked": { + "lastModified": 1789383061, + "narHash": "sha256-+DkygIVr/+RtXnsjd07BzFFB0ihwmjjLuYh1fyc/bCg=", + "owner": "pyproject-nix", + "repo": "uv2nix", + "rev": "079be4e68c529c4cbc44dbf1afc692e4b30695b3", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "uv2nix", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 89ad9666..89c04419 100644 --- a/flake.nix +++ b/flake.nix @@ -1,192 +1,72 @@ { - description = "DNA development environment — host toolchain plus the two prodtrack SDKs that are not in nixpkgs"; + description = "DNA development environment — host toolchain plus the backend's locked Python dependencies"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; flake-utils.url = "github:numtide/flake-utils"; + + pyproject-nix = { + url = "github:pyproject-nix/pyproject.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + uv2nix = { + url = "github:pyproject-nix/uv2nix"; + inputs.pyproject-nix.follows = "pyproject-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + pyproject-build-systems = { + url = "github:pyproject-nix/build-system-pkgs"; + inputs.pyproject-nix.follows = "pyproject-nix"; + inputs.uv2nix.follows = "uv2nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; - outputs = { self, nixpkgs, flake-utils }: + outputs = { self, nixpkgs, flake-utils, pyproject-nix, uv2nix, pyproject-build-systems }: + let + # backend/uv.lock is the single source of truth for Python packages: + # every transitive dependency is pinned there by version and hash. + # backend/requirements.txt is exported from it for Docker and CI (see + # the shellHook), so all three install exactly the same set. + workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./backend; }; + + # Every locked package publishes a wheel for Python 3.11, so nothing is + # built from source and no build-system overrides are needed. + overlay = workspace.mkPyprojectOverlay { sourcePreference = "wheel"; }; + in flake-utils.lib.eachDefaultSystem (system: let - pkgs = import nixpkgs { inherit system; }; - - # Matches backend/Dockerfile (python:3.11-slim). Keep them in step. - python = pkgs.python311; - py = python.pkgs; - - # ------------------------------------------------------------------ - # Packages nixpkgs does not carry - # - # shotgun_api3 and ftrack-python-api are both absent from nixpkgs, and - # ftrack pulls two more that are missing or wrong-versioned there. - # ------------------------------------------------------------------ - - # ftrack pins arrow <1 and means it: ftrack_api/entity/user.py calls - # `arrow.now().replace(tzinfo="utc")`, and arrow 1.0 removed tzinfo - # from .replace() (it moved to .to()). nixpkgs ships arrow 1.4, which - # would raise at runtime, so this builds the version ftrack expects. - arrow_0_17 = py.buildPythonPackage rec { - pname = "arrow"; - version = "0.17.0"; - pyproject = true; - - src = pkgs.fetchFromGitHub { - owner = "arrow-py"; - repo = "arrow"; - rev = version; - hash = "sha256-a16rSojvEFO8Ash6iEwxhgp7h3tnSBJrbaWeE1K8xkU="; - }; - - build-system = [ py.setuptools ]; - dependencies = [ py.python-dateutil ]; - - # The 0.17 suite predates the pytest in nixpkgs and is not what we - # are here to verify. - doCheck = false; - - pythonImportsCheck = [ "arrow" ]; - - meta = { - description = "Better dates and times for Python (pinned <1 for ftrack)"; - homepage = "https://github.com/arrow-py/arrow"; - license = pkgs.lib.licenses.asl20; - }; - }; - - # ftrack pins clique==1.6.1. That release was never tagged on GitHub - # (its tags stop at 1.5.0), so this one comes from PyPI — the only - # place 1.6.1 is published. - clique = py.buildPythonPackage rec { - pname = "clique"; - version = "1.6.1"; - pyproject = true; - - src = py.fetchPypi { - inherit pname version; - hash = "sha256-kBZcHPFi1N0brvg86qGvyIa0U+N5CU+ltg6kcNFzPmY="; - }; - - build-system = [ py.setuptools ]; - - # Test deps are not packaged and the library is a hard dependency of - # ftrack's session module, not something we are validating here. - doCheck = false; - - pythonImportsCheck = [ "clique" ]; - - meta = { - description = "Manage collections of sequentially numbered items"; - homepage = "https://gitlab.com/4degrees/clique"; - license = pkgs.lib.licenses.asl20; - }; - }; - - shotgun-api3 = py.buildPythonPackage rec { - pname = "shotgun_api3"; - version = "3.9.2"; - pyproject = true; - - src = pkgs.fetchFromGitHub { - owner = "shotgunsoftware"; - repo = "python-api"; - rev = "v${version}"; - hash = "sha256-GlYJ7Nu79Tmb6c/57GQ1VRj/eDkncEXyrmU9OPLqU+Y="; - }; - - build-system = [ py.setuptools ]; - - # No install_requires: it vendors httplib2 and friends under - # shotgun_api3/lib. - dependencies = [ ]; - - # The suite wants live ShotGrid credentials. - doCheck = false; - - pythonImportsCheck = [ "shotgun_api3" ]; - - meta = { - description = "Autodesk ShotGrid Python API"; - homepage = "https://github.com/shotgunsoftware/python-api"; - license = pkgs.lib.licenses.mit; - }; + pkgs = import nixpkgs { + inherit system; + # mongodb-ce is SSPL, which nixpkgs classes as unfree. Allow just + # that one rather than every unfree package. + config.allowUnfreePredicate = pkg: + builtins.elem (nixpkgs.lib.getName pkg) [ "mongodb-ce" ]; }; - ftrack-python-api = py.buildPythonPackage rec { - pname = "ftrack-python-api"; - version = "3.0.6"; - pyproject = true; - - src = pkgs.fetchFromGitHub { - owner = "ftrackhq"; - repo = "ftrack-python-api"; - rev = "v${version}"; - hash = "sha256-rUDUKvKFUSu3+8BQls2pzjxsyEIK2ZIDyx/VewIpOdo="; - }; - - # Upstream builds through poetry-dynamic-versioning, which reads the - # version from a git tag. A source tarball has no .git, so the build - # would produce 0.1.0. Pin the version and drop the plugin. - # sphinx-notfound-page is a docs dependency listed as a runtime one; - # keeping it would drag all of sphinx into the closure. - postPatch = '' - substituteInPlace pyproject.toml \ - --replace-fail 'version = "0.1.0"' 'version = "${version}"' \ - --replace-fail 'requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"]' 'requires = ["poetry-core>=1.0.0"]' \ - --replace-fail 'build-backend = "poetry_dynamic_versioning.backend"' 'build-backend = "poetry.core.masonry.api"' \ - --replace-fail 'sphinx-notfound-page = "^1.0.4"' "" - echo '__version__ = "${version}"' > source/ftrack_api/_version.py - ''; - - build-system = [ py.poetry-core ]; - - dependencies = [ - py.requests - py.platformdirs - arrow_0_17 - clique - py.pyparsing - py.websocket-client - ]; - - # pyparsing <3 and websocket-client <1 are relaxed on purpose, unlike - # arrow above. Both are used only by the event hub - # (ftrack_api/event/expression.py and event/hub.py), and DNA connects - # with auto_connect_event_hub=False — see FtrackProvider.connect. They - # still have to import, which they do; only the event-subscription - # paths would be at risk, and nothing in DNA reaches them. - pythonRelaxDeps = [ "pyparsing" "websocket-client" ]; - - # The suite wants a live ftrack server. - doCheck = false; - - pythonImportsCheck = [ "ftrack_api" ]; - - meta = { - description = "ftrack Python API"; - homepage = "https://github.com/ftrackhq/ftrack-python-api"; - license = pkgs.lib.licenses.asl20; - }; - }; + # Matches backend/Dockerfile (python:3.11-slim) and requires-python in + # backend/pyproject.toml. Keep them in step. + python = pkgs.python311; - # The two SDKs on the interpreter, so `python -c "import ftrack_api"` - # works straight out of the shell. Everything else comes from the venv - # (see the shellHook) so the pinned versions in requirements.txt are - # what actually runs. - pythonEnv = python.withPackages (_: [ shotgun-api3 ftrack-python-api ]); + pythonSet = + (pkgs.callPackage pyproject-nix.build.packages { inherit python; }).overrideScope + (nixpkgs.lib.composeManyExtensions [ + pyproject-build-systems.overlays.wheel + overlay + ]); + backendEnv = pythonSet.mkVirtualEnv "dna-backend-env" workspace.deps.default; in { - packages = { - inherit arrow_0_17 clique shotgun-api3 ftrack-python-api; - default = pythonEnv; - }; + packages.default = backendEnv; devShells.default = pkgs.mkShell { name = "dna"; packages = [ - pythonEnv + backendEnv pkgs.uv # nodejs_20 was dropped from nixpkgs (upstream EOL 2026-04-30). @@ -198,21 +78,26 @@ pkgs.docker-compose ]; - # Let the venv see the nix-provided SDKs rather than reinstalling - # them, and keep uv from downloading its own interpreter. - UV_PYTHON = "${pythonEnv}/bin/python"; - UV_PYTHON_DOWNLOADS = "never"; + env = { + # The environment comes from the lock via nix; uv is here only to + # change the lock, never to install into a venv of its own. + UV_NO_SYNC = "1"; + UV_PYTHON = "${backendEnv}/bin/python"; + UV_PYTHON_DOWNLOADS = "never"; + }; shellHook = '' + # Same import root as the container (PYTHONPATH=/app/src). + export PYTHONPATH="$(git rev-parse --show-toplevel)/backend/src" + echo "DNA dev shell" - echo " python $(${pythonEnv}/bin/python --version 2>&1 | cut -d' ' -f2) (shotgun_api3, ftrack_api built in)" + echo " python $(python --version 2>&1 | cut -d' ' -f2) (backend deps from backend/uv.lock)" echo " node $(node --version)" echo " uv $(uv --version | cut -d' ' -f2)" echo - echo "Backend deps are NOT installed automatically — the pinned set in" - echo "backend/requirements.txt is the source of truth and needs an index:" - echo " uv venv --system-site-packages backend/.venv" - echo " uv pip install --python backend/.venv -r backend/requirements.txt" + echo "To change a backend dependency, edit backend/pyproject.toml, then:" + echo " (cd backend && uv lock && uv export --frozen --no-hashes --no-emit-project -o requirements.txt)" + echo "and re-enter the shell." echo echo "Tests still run in Docker (make -C backend test); this shell is for" echo "editing, linting and one-off scripts on the host." From d831cd6a8ea5c5a5cdba4b1cca50b647a31736f5 Mon Sep 17 00:00:00 2001 From: Erik Johansson Date: Wed, 23 Sep 2026 12:27:51 +0200 Subject: [PATCH 4/5] fix(ftrack): resolve note authors from author_id without auto-populating NOTE_PROJECTION fetches only author_id, so reading note["author"] made ftrack auto-populate the link with an extra query per note. The author is now fetched explicitly by id when links are resolved, and passed into _note_from rather than read off the note. A freshly created note keeps its locally populated author. Adds tests for single-entity reads, hydration of each entity type, the thumbnail fetch, and the /api/ftrack-thumbnails endpoint. Co-Authored-By: Claude Opus 5.5 (1M context) --- backend/src/dna/prodtrack_providers/ftrack.py | 24 +- .../tests/providers/test_ftrack_provider.py | 294 +++++++++++++++++- backend/tests/test_main.py | 39 +++ nix/smoke.sh | 131 ++++++++ nix/stack-env.sh | 74 +++++ nix/stack.nix | 90 ++++++ 6 files changed, 633 insertions(+), 19 deletions(-) create mode 100644 nix/smoke.sh create mode 100644 nix/stack-env.sh create mode 100644 nix/stack.nix diff --git a/backend/src/dna/prodtrack_providers/ftrack.py b/backend/src/dna/prodtrack_providers/ftrack.py index 0e495430..3b2f14c1 100644 --- a/backend/src/dna/prodtrack_providers/ftrack.py +++ b/backend/src/dna/prodtrack_providers/ftrack.py @@ -526,7 +526,9 @@ def collect_task(task: Any) -> None: collect_task(entity) collect_context(entity.get("parent")) elif kind == "Note": - collect(entity.get("author"), "User") + # Notes are fetched with author_id only; callers that resolve + # the author warm it with the author row. + pass elif kind not in _PROJECTLESS_TYPES: # Contexts, lists and review sessions all carry a project link # and nothing else the conversions reach for. @@ -823,10 +825,9 @@ def _task_from(self, task: Any, entity: Optional[EntityBase] = None) -> Task: ) def _note_from(self, note: Any, author: Optional[User] = None) -> Note: + # The author is passed in rather than read off the note: NOTE_PROJECTION + # fetches only author_id, and reading the link would auto-populate. metadata = self._note_metadata(note) - author_entity = author - if author_entity is None and note.get("author"): - author_entity = self._user_from(note["author"]) return Note( id=self._to_id(note, "Note"), subject=metadata.get(NOTE_SUBJECT_KEY), @@ -834,7 +835,7 @@ def _note_from(self, note: Any, author: Optional[User] = None) -> Note: # ftrack notes belong to their parent, not to a project of their own. project=None, note_links=[], - author=author_entity, + author=author, ) def _note_metadata(self, note: Any) -> dict[str, Any]: @@ -1113,8 +1114,13 @@ def get_entity( if entity_type == "note": note = self._require(entity_type, entity_id, uuid) author = None - if resolve_links and note.get("author"): - author = self._user_from(note["author"]) + if resolve_links and note.get("author_id"): + row = self._fetch_by_ids( + "User", [note["author_id"]], USER_PROJECTION + ).get(note["author_id"]) + if row is not None: + self._warm_ids([row]) + author = self._user_from(row) return self._note_from(note, author) raise ValueError(f"Unknown entity type: {entity_type}") @@ -1197,7 +1203,9 @@ def add_entity(self, entity_type: str, entity: EntityBase) -> EntityBase: ).first(), recipients=[], ) - return self._note_from(created) + # The note was just created locally, so its author link is populated. + author = created.get("author") + return self._note_from(created, self._user_from(author) if author else None) def find( self, entity_type: str, filters: list[dict[str, Any]], limit: int = 0 diff --git a/backend/tests/providers/test_ftrack_provider.py b/backend/tests/providers/test_ftrack_provider.py index 6c436872..749562e4 100644 --- a/backend/tests/providers/test_ftrack_provider.py +++ b/backend/tests/providers/test_ftrack_provider.py @@ -462,6 +462,22 @@ def make_playlist(session, project, uuid="list-1", name="dailies", versions=()): ) +def make_note(session, parent, uuid="note-1", author_id="user-1"): + return session.register( + FakeEntity( + "Note", + { + "id": uuid, + "content": "Warmer grade", + "parent_id": parent["id"], + "parent_type": parent.entity_type, + "metadata": {"dna_subject": "Grade"}, + "author_id": author_id, + }, + ) + ) + + # --------------------------------------------------------------------------- # Playlist entity type setting # --------------------------------------------------------------------------- @@ -1124,6 +1140,117 @@ def test_shallow_read_skips_the_versions(self, provider, session): assert result.versions == [] + def test_reads_a_project(self, provider, session): + project = make_project(session) + + result = provider.get_entity("project", provider._to_id(project, "Project")) + + assert result.name == "Skyfall" + + def test_reads_a_user(self, provider, session): + user = make_user(session) + + result = provider.get_entity("user", provider._to_id(user, "User")) + + assert result.email == "artist@example.com" + + def test_reads_a_task_with_its_parent(self, provider, session): + project = make_project(session) + make_version(session, project) + task = session.get("Task", "task-for-version-1") + + result = provider.get_entity("task", provider._to_id(task, "Task")) + + assert result.name == "comp" + assert result.pipeline_step["name"] == "Compositing" + assert result.entity.name == "sh010" + + def test_shallow_task_read_skips_the_parent(self, provider, session): + project = make_project(session) + make_version(session, project) + task = session.get("Task", "task-for-version-1") + + result = provider.get_entity( + "task", provider._to_id(task, "Task"), resolve_links=False + ) + + assert result.entity is None + + def test_reads_a_version_with_its_links(self, provider, session): + project = make_project(session) + version = make_version(session, project) + make_note(session, version) + + result = provider.get_entity( + "version", provider._to_id(version, "AssetVersion") + ) + + assert isinstance(result, Version) + assert result.name == "sh010_comp_v003" + assert result.entity.name == "sh010" + assert result.task.name == "comp" + assert result.user.email == "artist@example.com" + assert [n.subject for n in result.notes] == ["Grade"] + assert result.notes[0].author.email == "artist@example.com" + + def test_shallow_version_read_skips_the_links(self, provider, session): + project = make_project(session) + version = make_version(session, project) + make_note(session, version) + + result = provider.get_entity( + "version", provider._to_id(version, "AssetVersion"), resolve_links=False + ) + + assert result.entity is None + assert result.task is None + assert result.notes == [] + + def test_reads_a_note_with_its_author(self, provider, session): + project = make_project(session) + version = make_version(session, project) + note = make_note(session, version) + + result = provider.get_entity("note", provider._to_id(note, "Note")) + + assert isinstance(result, Note) + assert result.subject == "Grade" + assert result.content == "Warmer grade" + assert result.author.email == "artist@example.com" + + def test_reading_a_note_fetches_only_what_it_projects(self, provider, session): + """The author comes from author_id, not from an auto-populated link.""" + project = make_project(session) + version = make_version(session, project) + note = make_note(session, version) + note_id = provider._to_id(note, "Note") + + lazy_reads = [] + FakeEntity.lazy_reads = lazy_reads + try: + provider.get_entity("note", note_id) + finally: + FakeEntity.lazy_reads = None + + assert lazy_reads == [] + + @pytest.mark.parametrize( + "entity_type, ftrack_type, uuid", + [ + ("shot", "Shot", "missing-shot"), + ("task", "Task", "missing-task"), + ("version", "AssetVersion", "missing-version"), + ("project", "Project", "missing-project"), + ], + ) + def test_a_mapped_id_that_is_gone_is_not_found( + self, provider, entity_type, ftrack_type, uuid + ): + entity_id = provider._to_id(FakeEntity(ftrack_type, {"id": uuid}), ftrack_type) + + with pytest.raises(ValueError, match="Entity not found"): + provider.get_entity(entity_type, entity_id) + def test_rejects_an_unknown_entity_type(self, provider): with pytest.raises(ValueError, match="Unknown entity type"): provider.get_entity("sequence", 1) @@ -1408,6 +1535,70 @@ def test_filtered_queries_ask_only_for_ids(self, provider, session): 'select id from Shot where project_id is "project-1"' ] + def test_no_match_returns_nothing(self, provider, session): + make_project(session) + + assert ( + provider.find("shot", [{"field": "name", "operator": "is", "value": "x"}]) + == [] + ) + + def test_hydrates_versions(self, provider, session): + project = make_project(session) + make_version(session, project) + + results = provider.find("version", []) + + # find is shallow for every type: links are left for get_entity. + assert [r.name for r in results] == ["sh010_comp_v003"] + assert results[0].status == "Pending Review" + assert results[0].project["name"] == "Skyfall" + assert results[0].entity is None + + def test_hydrates_tasks(self, provider, session): + project = make_project(session) + make_version(session, project) + + results = provider.find("task", []) + + assert [(r.name, r.pipeline_step["name"]) for r in results] == [ + ("comp", "Compositing") + ] + + def test_hydrates_projects_and_users(self, provider, session): + make_project(session) + make_user(session) + + assert [p.name for p in provider.find("project", [])] == ["Skyfall"] + assert [u.email for u in provider.find("user", [])] == ["artist@example.com"] + + def test_hydrates_notes(self, provider, session): + project = make_project(session) + version = make_version(session, project) + make_note(session, version) + + lazy_reads = [] + FakeEntity.lazy_reads = lazy_reads + try: + results = provider.find("note", []) + finally: + FakeEntity.lazy_reads = None + + assert [n.subject for n in results] == ["Grade"] + assert lazy_reads == [] + + def test_hydrates_playlists_with_their_project( + self, provider, session, monkeypatch + ): + monkeypatch.setenv("FTRACK_PLAYLIST_ENTITY", "AssetVersionList") + project = make_project(session) + make_playlist(session, project) + + results = provider.find("playlist", []) + + assert [p.code for p in results] == ["dailies"] + assert results[0].project["name"] == "Skyfall" + # --------------------------------------------------------------------------- # Notes @@ -1754,23 +1945,104 @@ def test_update_of_an_unknown_row_reports_failure(self, provider): ) +class TestGetThumbnail: + class FakeAccessor: + def __init__(self): + self.requested = [] + + def get_thumbnail_url(self, component_id, size=None): + self.requested.append((component_id, size)) + return f"{SERVER_URL}/component/thumbnail?id={component_id}&apiKey=key" + + def _server_location(self, session): + location = session.register( + FakeEntity("Location", {"id": "location-server", "name": "ftrack.server"}) + ) + location.accessor = self.FakeAccessor() + return location + + def _response(self, status_code=200, content=b"jpeg", content_type="image/png"): + return mock.Mock( + status_code=status_code, + content=content, + headers={"Content-Type": content_type}, + ) + + def test_fetches_the_image_server_side(self, provider, session): + project = make_project(session) + version = make_version(session, project) + location = self._server_location(session) + + with mock.patch("requests.get", return_value=self._response()) as get: + result = provider.get_thumbnail(provider._to_id(version, "AssetVersion")) + + assert result == (b"jpeg", "image/png") + assert location.accessor.requested == [("thumb-1", 300)] + assert get.call_args.args[0].startswith(f"{SERVER_URL}/component/thumbnail") + + def test_no_thumbnail_is_none(self, provider, session): + project = make_project(session) + version = make_version(session, project) + version["thumbnail_id"] = None + self._server_location(session) + + with mock.patch("requests.get") as get: + assert ( + provider.get_thumbnail(provider._to_id(version, "AssetVersion")) is None + ) + get.assert_not_called() + + def test_missing_server_location_is_none(self, provider, session): + project = make_project(session) + version = make_version(session, project) + + assert provider.get_thumbnail(provider._to_id(version, "AssetVersion")) is None + + def test_an_error_status_is_none(self, provider, session): + project = make_project(session) + version = make_version(session, project) + self._server_location(session) + + with mock.patch("requests.get", return_value=self._response(status_code=404)): + assert ( + provider.get_thumbnail(provider._to_id(version, "AssetVersion")) is None + ) + + def test_a_failed_request_is_none_not_an_error(self, provider, session): + project = make_project(session) + version = make_version(session, project) + self._server_location(session) + + with mock.patch("requests.get", side_effect=ConnectionError("down")): + assert ( + provider.get_thumbnail(provider._to_id(version, "AssetVersion")) is None + ) + + def test_an_unmapped_version_raises(self, provider): + with pytest.raises(ValueError): + provider.get_thumbnail(99999) + + # --------------------------------------------------------------------------- -# Wiring +# Provider factory # --------------------------------------------------------------------------- class TestProviderFactory: def test_builds_an_ftrack_provider(self): - with mock.patch.dict( - os.environ, - { - "PRODTRACK_PROVIDER": "ftrack", - "FTRACK_SERVER": SERVER_URL, - "FTRACK_API_KEY": "key", - "FTRACK_API_USER": "api@example.com", - "FTRACK_ID_MAP": "memory", - }, - ), mock.patch("dna.prodtrack_providers.ftrack.ftrack_api.Session") as session: + with ( + mock.patch.dict( + os.environ, + { + "PRODTRACK_PROVIDER": "ftrack", + "FTRACK_SERVER": SERVER_URL, + "FTRACK_API_KEY": "key", + "FTRACK_API_USER": "api@example.com", + "FTRACK_ID_MAP": "memory", + }, + ), + mock.patch("dna.prodtrack_providers.ftrack.ftrack_api.Session") as session, + ): provider = get_prodtrack_provider() assert isinstance(provider, FtrackProvider) diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index a6b48ad2..25c6a898 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -1163,6 +1163,45 @@ def test_get_mock_thumbnail_404(self, tmp_path): assert response.status_code == 404 +class TestFtrackThumbnailEndpoint: + """Tests for GET /api/ftrack-thumbnails/{version_id}.""" + + def _get(self, provider, version_id=7): + app.dependency_overrides[get_prodtrack_provider_cached] = lambda: provider + try: + return client.get(f"/api/ftrack-thumbnails/{version_id}") + finally: + app.dependency_overrides.clear() + + def test_streams_the_image(self): + provider = mock.MagicMock() + provider.get_thumbnail.return_value = (b"\x89PNG", "image/png") + + response = self._get(provider) + + assert response.status_code == 200 + assert response.content == b"\x89PNG" + assert response.headers["content-type"] == "image/png" + provider.get_thumbnail.assert_called_once_with(7) + + def test_no_thumbnail_returns_404(self): + provider = mock.MagicMock() + provider.get_thumbnail.return_value = None + + assert self._get(provider).status_code == 404 + + def test_unknown_version_returns_404(self): + provider = mock.MagicMock() + provider.get_thumbnail.side_effect = ValueError("Unknown version id 7") + + assert self._get(provider).status_code == 404 + + def test_provider_without_thumbnails_returns_404(self): + provider = mock.MagicMock(spec=["get_entity"]) + + assert self._get(provider).status_code == 404 + + class TestAttachmentsEndpoint: """Tests for POST/GET/DELETE /api/attachments.""" diff --git a/nix/smoke.sh b/nix/smoke.sh new file mode 100644 index 00000000..977df22b --- /dev/null +++ b/nix/smoke.sh @@ -0,0 +1,131 @@ +# Start mongo, the backend and the frontend from the working tree, check that +# each answers and that they work together, then stop everything. Uses its +# own ports and a throwaway database so it can run next to `nix run .#dev`. + +export DNA_MONGO_PORT="${DNA_MONGO_PORT:-27117}" +export DNA_API_PORT="${DNA_API_PORT:-8100}" +export DNA_FRONTEND_PORT="${DNA_FRONTEND_PORT:-5273}" + +smoke_state="$(mktemp -d "${TMPDIR:-/tmp}/dna-smoke.XXXXXX")" +export DNA_STATE="$smoke_state" + +# shellcheck disable=SC1091 +. @stackEnv@ + +dna_require_ports +dna_frontend_deps + +logs="$DNA_STATE/logs" +pids=() + +cleanup() { + local status=$? + for pid in "${pids[@]}"; do + kill "$pid" 2>/dev/null || true + done + wait 2>/dev/null || true + if [ "$status" -ne 0 ]; then + for log in "$logs"/*.log; do + echo + echo "==> $(basename "$log") (last 40 lines)" + tail -n 40 "$log" + done + echo + echo "Smoke test FAILED. Full logs kept in $logs" + else + rm -rf "$smoke_state" + fi + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT TERM + +wait_for() { + local what="$1" tries="$2" + shift 2 + for _ in $(seq "$tries"); do + if "$@" >/dev/null 2>&1; then + echo " ok $what is up" + return 0 + fi + sleep 1 + done + echo " FAIL $what did not come up" >&2 + return 1 +} + +echo "Starting services (state in $DNA_STATE)" +mongod --dbpath "$DNA_STATE/mongo" --port "$DNA_MONGO_PORT" --bind_ip 127.0.0.1 \ + >"$logs/mongo.log" 2>&1 & +pids+=($!) +wait_for mongo 60 bash -c "exec 3<>/dev/tcp/127.0.0.1/$DNA_MONGO_PORT" + +(cd "$DNA_ROOT/backend" && exec uvicorn main:app --app-dir src \ + --host 127.0.0.1 --port "$DNA_API_PORT") >"$logs/api.log" 2>&1 & +pids+=($!) +wait_for api 60 curl -fsS "http://127.0.0.1:$DNA_API_PORT/health" + +(cd "$DNA_ROOT/frontend" && exec npm run dev -- \ + --host 127.0.0.1 --port "$DNA_FRONTEND_PORT" --strictPort) >"$logs/frontend.log" 2>&1 & +pids+=($!) +wait_for frontend 90 curl -fsS "http://127.0.0.1:$DNA_FRONTEND_PORT/" + +api="http://127.0.0.1:$DNA_API_PORT" +user="smoke@localhost" +failed=0 + +check() { + local what="$1" + shift + if out="$("$@" 2>&1)"; then + echo " ok $what${out:+ ($out)}" + else + echo " FAIL $what: $out" >&2 + failed=1 + fi +} + +get() { curl -fsS "$api$1"; } + +echo "Checking backend ($PRODTRACK_PROVIDER tracker, $STORAGE_PROVIDER storage)" +check "health" bash -c "curl -fsS $api/health | jq -e '.status == \"healthy\"' >/dev/null" + +project_id="$(get "/projects/user/$user" | jq -er '.[0].id')" || project_id="" +check "projects for user" test -n "$project_id" + +playlist_id="" +if [ -n "$project_id" ]; then + playlist_id="$(get "/projects/$project_id/playlists" | jq -er '.[0].id')" || playlist_id="" +fi +check "playlists for project ${project_id:-?}" test -n "$playlist_id" + +version_id="" +if [ -n "$playlist_id" ]; then + version_id="$(get "/playlists/$playlist_id/versions" | jq -er '.[0].id')" || version_id="" +fi +check "versions in playlist ${playlist_id:-?}" test -n "$version_id" + +if [ -n "$version_id" ]; then + note="/playlists/$playlist_id/versions/$version_id/draft-notes/$user" + check "draft note write (storage)" bash -c \ + "curl -fsS -X PUT -H 'Content-Type: application/json' -d '{\"content\":\"smoke\"}' $api$note | jq -e '.content == \"smoke\"' >/dev/null" + check "draft note read back" bash -c \ + "curl -fsS $api$note | jq -e '.content == \"smoke\"' >/dev/null" + check "draft notes for playlist" bash -c \ + "curl -fsS $api/playlists/$playlist_id/draft-notes | jq -e 'length >= 1' >/dev/null" +fi + +echo "Checking frontend" +web="http://127.0.0.1:$DNA_FRONTEND_PORT" +check "index page" bash -c "curl -fsS $web/ | grep -q 'id=\"root\"'" +# Vite compiles modules on request, so this catches broken imports and +# syntax errors in the entry point without a full build. +check "app entry compiles" curl -fsS -o /dev/null "$web/src/main.tsx" +check "CORS allows the frontend origin" bash -c \ + "curl -fsS -o /dev/null -D - -H 'Origin: http://localhost:$DNA_FRONTEND_PORT' $api/health | grep -qi '^access-control-allow-origin: http://localhost:$DNA_FRONTEND_PORT'" + +if [ "$failed" -ne 0 ]; then + exit 1 +fi +echo +echo "Smoke test passed." diff --git a/nix/stack-env.sh b/nix/stack-env.sh new file mode 100644 index 00000000..98de8aa6 --- /dev/null +++ b/nix/stack-env.sh @@ -0,0 +1,74 @@ +# Sourced by the dev and smoke apps. Resolves where the checkout and its +# runtime state live, and gives every service setting a default that an +# exported variable (or $DNA_STATE/env) overrides. + +DNA_ROOT="${DNA_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || true)}" +if [ -z "$DNA_ROOT" ] || [ ! -f "$DNA_ROOT/backend/pyproject.toml" ]; then + echo "error: run this from inside the dna checkout (or set DNA_ROOT)" >&2 + exit 1 +fi +export DNA_ROOT + +# Database files, logs and the optional env file. Gitignored. +export DNA_STATE="${DNA_STATE:-$DNA_ROOT/.dna-dev}" +mkdir -p "$DNA_STATE/mongo" "$DNA_STATE/logs" + +# Per-checkout overrides, e.g. PRODTRACK_PROVIDER=ftrack and its credentials. +if [ -f "$DNA_STATE/env" ]; then + set -a + # shellcheck disable=SC1091 + . "$DNA_STATE/env" + set +a +fi + +export DNA_MONGO_PORT="${DNA_MONGO_PORT:-27017}" +export DNA_API_PORT="${DNA_API_PORT:-8000}" +# The backend's default CORS list allows http://localhost:5173 only. +export DNA_FRONTEND_PORT="${DNA_FRONTEND_PORT:-5173}" +if [ "$DNA_FRONTEND_PORT" != 5173 ] && [ -z "${CORS_ALLOWED_ORIGINS:-}" ]; then + export CORS_ALLOWED_ORIGINS="http://localhost:$DNA_FRONTEND_PORT" +fi + +# Backend: the read-only mock tracker and no auth, so a fresh checkout runs +# with no credentials. Same defaults as example.docker-compose.local.yml. +export PYTHONUNBUFFERED=1 +export PYTHONPATH="$DNA_ROOT/backend/src" +export PRODTRACK_PROVIDER="${PRODTRACK_PROVIDER:-mock}" +export AUTH_PROVIDER="${AUTH_PROVIDER:-none}" +export STORAGE_PROVIDER="${STORAGE_PROVIDER:-mongodb}" +export MONGODB_URL="${MONGODB_URL:-mongodb://127.0.0.1:$DNA_MONGO_PORT}" +export API_BASE_URL="${API_BASE_URL:-http://localhost:$DNA_API_PORT}" +export ATTACHMENT_STORE_DIR="${ATTACHMENT_STORE_DIR:-$DNA_STATE/attachments}" +export FTRACK_ID_MAP_PATH="${FTRACK_ID_MAP_PATH:-$DNA_STATE/ftrack_id_map.db}" + +# Frontend: Vite lets process env override packages/app/.env. +export VITE_API_BASE_URL="${VITE_API_BASE_URL:-http://localhost:$DNA_API_PORT}" +export VITE_WS_URL="${VITE_WS_URL:-ws://localhost:$DNA_API_PORT/ws}" +export VITE_AUTH_PROVIDER="${VITE_AUTH_PROVIDER:-$AUTH_PROVIDER}" + +# Fail early with a clear message instead of a service crash-looping on a +# port that the Docker stack (or a previous run) still holds. +dna_port_free() { + ! (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null +} + +dna_require_ports() { + local name port busy=0 + for name in DNA_MONGO_PORT DNA_API_PORT DNA_FRONTEND_PORT; do + port="${!name}" + if ! dna_port_free "$port"; then + echo "error: port $port ($name) is already in use; stop what holds it or export $name" >&2 + busy=1 + fi + done + return "$busy" +} + +# npm ci only when the lock has changed since the last install. +dna_frontend_deps() { + local marker="$DNA_ROOT/frontend/node_modules/.package-lock.json" + if [ ! -f "$marker" ] || [ "$DNA_ROOT/frontend/package-lock.json" -nt "$marker" ]; then + echo "Installing frontend dependencies (npm ci)..." + (cd "$DNA_ROOT/frontend" && npm ci --no-audit --no-fund) + fi +} diff --git a/nix/stack.nix b/nix/stack.nix new file mode 100644 index 00000000..bb14b8b1 --- /dev/null +++ b/nix/stack.nix @@ -0,0 +1,90 @@ +# Apps that run the whole project from the working tree on the host: +# +# nix run .#dev mongo + backend (auto-reload) + frontend (Vite) under +# process-compose, with the mock tracker and no auth. +# nix run .#smoke the same stack headless on spare ports; checks that the +# services answer and work together, then stops them. +# +# Both read the checkout they are run from, not a store copy, so they test +# uncommitted changes. See nix/stack-env.sh for the settings and overrides. +{ pkgs, backendEnv }: +let + runtime = [ + backendEnv + pkgs.mongodb-ce + pkgs.nodejs_22 + pkgs.curl + pkgs.jq + pkgs.git + pkgs.coreutils + ]; + + stackEnv = pkgs.writeText "dna-stack-env.sh" (builtins.readFile ./stack-env.sh); + + # process-compose would otherwise substitute $VARS itself, before the + # shell sees them; leave that to the shell so they resolve at run time. + processCompose = pkgs.writeText "dna-process-compose.json" (builtins.toJSON { + version = "0.5"; + disable_env_expansion = true; + processes = { + mongo = { + command = ''exec mongod --dbpath "$DNA_STATE/mongo" --port "$DNA_MONGO_PORT" --bind_ip 127.0.0.1''; + readiness_probe = { + exec.command = ''exec 3<>"/dev/tcp/127.0.0.1/$DNA_MONGO_PORT"''; + period_seconds = 1; + failure_threshold = 60; + }; + shutdown.signal = 15; + }; + + api = { + command = ''cd "$DNA_ROOT/backend" && exec uvicorn main:app --app-dir src --reload --reload-dir src --host 127.0.0.1 --port "$DNA_API_PORT"''; + depends_on.mongo.condition = "process_healthy"; + readiness_probe = { + exec.command = ''curl -fsS "http://127.0.0.1:$DNA_API_PORT/health"''; + initial_delay_seconds = 1; + period_seconds = 2; + failure_threshold = 30; + }; + availability.restart = "on_failure"; + }; + + frontend = { + command = ''cd "$DNA_ROOT/frontend" && exec npm run dev -- --host 127.0.0.1 --port "$DNA_FRONTEND_PORT" --strictPort''; + readiness_probe = { + exec.command = ''curl -fsS "http://127.0.0.1:$DNA_FRONTEND_PORT/"''; + initial_delay_seconds = 1; + period_seconds = 2; + failure_threshold = 45; + }; + }; + }; + }); +in +{ + dev = pkgs.writeShellApplication { + name = "dna-dev"; + runtimeInputs = runtime ++ [ pkgs.process-compose ]; + text = '' + # shellcheck disable=SC1091 + . ${stackEnv} + + dna_require_ports + dna_frontend_deps + + echo "DNA stack: frontend http://localhost:$DNA_FRONTEND_PORT api http://localhost:$DNA_API_PORT/docs" + echo " tracker $PRODTRACK_PROVIDER, auth $AUTH_PROVIDER, state in $DNA_STATE" + exec process-compose up \ + --config ${processCompose} \ + --log-file "$DNA_STATE/logs/process-compose.log" \ + --use-uds --unix-socket "$DNA_STATE/process-compose.sock" \ + "$@" + ''; + }; + + smoke = pkgs.writeShellApplication { + name = "dna-smoke"; + runtimeInputs = runtime; + text = builtins.replaceStrings [ "@stackEnv@" ] [ "${stackEnv}" ] (builtins.readFile ./smoke.sh); + }; +} From 47ca461b85f392986c2d071304a95b190a4d95a2 Mon Sep 17 00:00:00 2001 From: Erik Johansson Date: Wed, 23 Sep 2026 12:27:51 +0200 Subject: [PATCH 5/5] build: run the whole stack from the flake Adds two apps that run mongo, the backend and the frontend on the host from the working tree, so uncommitted changes are what gets exercised: nix run .#dev all three under process-compose, backend reloading, mock tracker and no auth by default nix run .#smoke the same headless on spare ports and a throwaway database; checks health, projects -> playlists -> versions through the tracker, a draft note round trip through storage, that the frontend serves and compiles its entry, and CORS; then stops everything Settings live in nix/stack-env.sh. Exported variables or .dna-dev/env override them, e.g. PRODTRACK_PROVIDER=ftrack with its credentials. Both apps refuse to start on a busy port instead of crash-looping. The dev shell now lists the backend checks as CI runs them instead of pointing at Docker, marks its prompt with (dna), and adds process-compose and jq. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 1 + flake.nix | 27 +++++++++++++++++++++++++-- nix/smoke.sh | 4 ++-- nix/stack.nix | 2 +- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 611deac2..bb1087ab 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ backend/docker-compose.override.yml # Cursor files *.issue-body-temp.md* +.dna-dev/ diff --git a/flake.nix b/flake.nix index 89c04419..4ac0ee7d 100644 --- a/flake.nix +++ b/flake.nix @@ -58,10 +58,17 @@ ]); backendEnv = pythonSet.mkVirtualEnv "dna-backend-env" workspace.deps.default; + + stack = import ./nix/stack.nix { inherit pkgs backendEnv; }; in { packages.default = backendEnv; + apps = { + dev = { type = "app"; program = "${stack.dev}/bin/dna-dev"; }; + smoke = { type = "app"; program = "${stack.smoke}/bin/dna-smoke"; }; + }; + devShells.default = pkgs.mkShell { name = "dna"; @@ -76,6 +83,8 @@ pkgs.black pkgs.isort pkgs.docker-compose + pkgs.process-compose + pkgs.jq ]; env = { @@ -99,8 +108,22 @@ echo " (cd backend && uv lock && uv export --frozen --no-hashes --no-emit-project -o requirements.txt)" echo "and re-enter the shell." echo - echo "Tests still run in Docker (make -C backend test); this shell is for" - echo "editing, linting and one-off scripts on the host." + echo "Backend checks, as CI runs them:" + echo " (cd backend && pytest --cov-fail-under=90)" + echo " (cd backend && black --check src tests && isort --check-only src tests)" + echo + echo "Run the whole stack from this checkout (mongo, backend, frontend):" + echo " nix run .#dev interactive, mock tracker, no auth" + echo " nix run .#smoke start, check it works end to end, stop" + + # Mark the prompt so it is obvious this shell is active. The hook + # runs after ~/.bashrc, so this wins over a prompt set there; the + # guard keeps a nested shell from stacking the prefix. + export DNA_DEV_SHELL=1 + case "$PS1" in + *"(dna)"*) ;; + *) PS1='\[\e[1;35m\](dna)\[\e[0m\] '"$PS1" ;; + esac ''; }; diff --git a/nix/smoke.sh b/nix/smoke.sh index 977df22b..a39e9e8b 100644 --- a/nix/smoke.sh +++ b/nix/smoke.sh @@ -55,7 +55,7 @@ wait_for() { } echo "Starting services (state in $DNA_STATE)" -mongod --dbpath "$DNA_STATE/mongo" --port "$DNA_MONGO_PORT" --bind_ip 127.0.0.1 \ +mongod --dbpath "$DNA_STATE/mongo" --port "$DNA_MONGO_PORT" --bind_ip 127.0.0.1 --quiet \ >"$logs/mongo.log" 2>&1 & pids+=($!) wait_for mongo 60 bash -c "exec 3<>/dev/tcp/127.0.0.1/$DNA_MONGO_PORT" @@ -65,7 +65,7 @@ wait_for mongo 60 bash -c "exec 3<>/dev/tcp/127.0.0.1/$DNA_MONGO_PORT" pids+=($!) wait_for api 60 curl -fsS "http://127.0.0.1:$DNA_API_PORT/health" -(cd "$DNA_ROOT/frontend" && exec npm run dev -- \ +(cd "$DNA_ROOT/frontend/packages/app" && exec npm run dev -- \ --host 127.0.0.1 --port "$DNA_FRONTEND_PORT" --strictPort) >"$logs/frontend.log" 2>&1 & pids+=($!) wait_for frontend 90 curl -fsS "http://127.0.0.1:$DNA_FRONTEND_PORT/" diff --git a/nix/stack.nix b/nix/stack.nix index bb14b8b1..5e8b4ebb 100644 --- a/nix/stack.nix +++ b/nix/stack.nix @@ -50,7 +50,7 @@ let }; frontend = { - command = ''cd "$DNA_ROOT/frontend" && exec npm run dev -- --host 127.0.0.1 --port "$DNA_FRONTEND_PORT" --strictPort''; + command = ''cd "$DNA_ROOT/frontend/packages/app" && exec npm run dev -- --host 127.0.0.1 --port "$DNA_FRONTEND_PORT" --strictPort''; readiness_probe = { exec.command = ''curl -fsS "http://127.0.0.1:$DNA_FRONTEND_PORT/"''; initial_delay_seconds = 1;