From 80aa15aef3a10439a2a97bcf525273475f52d6e4 Mon Sep 17 00:00:00 2001 From: Talha Date: Tue, 28 Jul 2026 23:32:45 +0500 Subject: [PATCH 01/46] docs: define application platform architecture --- docs/architecture/platform.md | 1371 +++++++++++++++++++++++++++++++++ 1 file changed, 1371 insertions(+) create mode 100644 docs/architecture/platform.md diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md new file mode 100644 index 0000000..096b278 --- /dev/null +++ b/docs/architecture/platform.md @@ -0,0 +1,1371 @@ +# VidXP Platform Architecture + +Status: accepted +Last validated: 2026-07-28 +Scope: CLI, API, MCP, Streamlit, desktop, media ingestion, indexing, search, +artifacts, natural-language query, CPU/GPU execution, and deployment + +## 1. Purpose + +This document defines the target architecture and delivery gates for VidXP's +application platform. Changes to its ownership boundaries or accepted decisions +require a reviewed architecture update or ADR with supporting evidence. + +## 2. Product outcomes + +VidXP must support: + +1. A local CLI operating directly on local media and repositories. +2. A local interactive UI, currently Streamlit, using the same application layer. +3. A future packaged desktop UI using the same application layer. +4. A remotely accessible HTTP API. +5. A remotely accessible MCP server over Streamable HTTP. +6. Local MCP over stdio for adjacent clients; stdio is not the media path. +7. Durable indexing and artifact jobs with progress and cancellation. +8. CPU execution first and an explicit, tested GPU runtime afterwards. +9. Natural-language questions over all indexed collectors with timestamped evidence. +10. A Coolify-compatible multi-service deployment. +11. Native local operation on supported Apple Silicon macOS without requiring Docker. +12. A thin-client mode for using a self-hosted VidXP server from another machine. + +## 3. Non-negotiable invariants + +1. CLI, UI, API, MCP, and desktop are adapters. They parse input, authenticate, + invoke application commands, and format output. They do not implement indexing, + media persistence, job state, search policy, or artifact generation. +2. FastAPI and MCP are sibling adapters. MCP never calls VidXP's HTTP API internally. +3. Public transports reuse the same Pydantic command and result models. +4. Video bytes are never passed as MCP tool arguments or over stdio. +5. Remote MCP clients reference previously registered media by `media_id`. +6. A failed or cancelled indexing job cannot damage the last committed index. +7. Search reads an immutable committed index generation. +8. Job state and index state are separate. +9. No remote contract exposes arbitrary server filesystem paths. +10. CPU/GPU selection is a validated runtime profile, not a free-form string. +11. Unexpected internal errors are logged with correlation data and masked publicly. +12. Every unbounded collection exposed by an adapter is paginated or capped centrally. +13. Stable dependencies are kept current; compatibility pins require a recorded + blocker, an owner, and a removal gate. +14. CPU behavior across Linux, Windows, and Apple Silicon is accepted before + platform-specific acceleration work. + +## 4. Dependency direction + +```text +Composition root + ├── Typer CLI adapter + ├── Streamlit adapter + ├── Desktop adapter + ├── FastAPI adapter + └── MCP adapter + │ + ▼ +Application commands, queries, results, and public errors + ├── media use cases + ├── indexing use cases + ├── search use cases + ├── natural-language query use cases + ├── actor use cases + └── artifact use cases + │ + ▼ +Domain contracts and ports + ├── MediaCatalog / MediaStore + ├── IndexCatalog / IndexRepository + ├── JobService + ├── ArtifactStore + ├── CapabilityRegistry + ├── QueryPlanner / AnswerSynthesizer + ├── ModelRuntime + └── ResourceScheduler + │ + ▼ +Infrastructure implementations + ├── filesystem / object storage + ├── Chroma + ├── DBOS SQLite / Postgres + ├── FFmpeg / ffprobe / OpenCV + ├── local and GPU model providers + └── environment and Docker wiring +``` + +Only the composition root may import both adapters and concrete infrastructure. +Application and domain modules must not import FastAPI, MCP, Typer, Streamlit, +Chroma, DBOS, OpenCV, or environment variables. + +## 5. Composition and settings + +Use one immutable `VidXPSettings` model built with `pydantic-settings`. + +It owns: + +- repository root and repository selection +- media and artifact storage backends +- trusted local import roots +- upload limits and retention +- authentication mode and secrets +- public base URL +- MCP transport configuration +- MCP canonical resource URL, body limit, allowed hosts, and allowed origins +- workflow database and queue selection +- resolved CPU/GPU runtime request +- model cache path and offline/download policy +- allowed model identifiers +- concurrency and memory-related limits + +Settings are constructed once by a composition root and injected. Adapters must not +mutate process environment variables to communicate configuration. Application +objects must not be created at module import time. + +### 5.1 Version and upgrade policy + +VidXP targets the latest stable, non-pre-release versions available when a delivery +phase begins. As of this validation, the current Python release is 3.14.6. Runtime +images and primary development use the latest 3.14 maintenance release. +The library test matrix covers supported CPython 3.11 through 3.14 while those +versions remain security-supported. + +Direct dependency ranges are bounded at the next incompatible major version. +Release environments and images lock exact direct and transitive artifacts. Update +automation opens dependency changes continuously, but an update merges only after +contract, packaging, platform, benchmark, and license gates pass. + +A dependency may remain below latest stable only when a reproduced incompatibility +or material regression is recorded. The exception must identify: + +- the blocking package and upstream issue +- affected operating systems/runtime profiles +- the newest compatible version +- the replacement or upgrade path +- the test that removes the exception + +An ML package does not hold the control plane, Python runtime, or unrelated +capabilities back. When necessary, the package is replaced or isolated behind its +worker/provider contract. Pre-release packages are excluded from production +constraints. + +Model weights follow a different rule from libraries: the default is the +best-performing current stable model that passes VidXP's quality, latency, memory, +license, redistribution, and cross-platform benchmarks. It is pinned by immutable +revision and checksum. “Newest” alone is not sufficient to change indexed embedding +semantics. + +## 6. Shared public contracts + +The application layer owns the canonical Pydantic models. HTTP and MCP may project +transport-only fields such as links or status codes, but may not redefine business +state. + +Required identifiers: + +- `RepositoryId` +- `MediaId` +- `VideoId` +- `IndexGenerationId` +- `IndexSnapshotId` +- `JobId` +- `ArtifactId` + +Required shared models: + +- `MediaAsset` +- `MediaProbe` +- `IndexPlan` +- `IndexGeneration` +- `IndexSnapshot` +- `IndexGenerationStatus` +- `Job` +- `JobProgress` +- `ProgressEvent` +- `SearchCommand` +- `SearchHit` +- `SearchResult` +- `QueryPlan` +- `QueryAnswer` +- `Evidence` +- `Artifact` +- `Page[T]` +- `RuntimeProfile` + +Public models carry a real schema version where compatibility requires one. Adapters +must not append schema fields manually during serialization. + +## 7. Public error model + +Application errors have: + +- stable machine code +- category +- safe public message +- optional safe details +- retryability +- internal cause retained only for logs + +Initial categories: + +- validation +- authentication +- authorization +- not found +- conflict +- unavailable +- resource limit +- cancelled +- internal + +FastAPI maps categories to HTTP statuses and a consistent error envelope. MCP maps +them to controlled protocol errors. CLI maps them to exit codes and terminal +messages. No adapter infers product meaning from broad built-in exceptions. + +Every MCP tool is registered through one wrapper that catches expected application +errors and raises a public SDK `MCPError` with: + +- an assigned safe protocol integer code +- a safe public message +- the stable VidXP application code and correlation ID in `data` + +The wrapper catches every other exception, logs the cause with correlation data, and +raises only a generic internal `MCPError`. `MCPServer` re-raises `MCPError` without +converting the underlying exception to text, so the SDK's default +`str(exception)` leakage path is not used. Errors remain outside the Pydantic success +schema; directly returned `CallToolResult(isError=True)` values are not used because +the high-level SDK validates returned values against that success schema. + +## 8. Repository layout + +One `RepositoryLayout` defines all persistent paths: + +```text +repository/ + repository.json + media/ + indexes/ + generations/ + / + snapshots/ + .json + active-snapshot.json + artifacts/ + local-workflows/ +``` + +Deployment-wide model caches do not live inside repositories unless explicit +isolation is requested. + +Remote API/MCP responses never expose these internal paths. + +## 9. Media ingestion and identity + +### 9.1 Media asset + +`MediaAsset` records: + +- stable `media_id` +- content checksum +- original filename +- byte size +- declared and detected MIME/container +- duration, streams and codecs from ffprobe +- managed storage key or approved external source reference +- repository and owner/principal where applicable +- ingest state +- associated `video_id` +- creation and retention timestamps + +Checksum is calculated once during ingest and reused for deduplication and indexing. +Untrusted content is not published into the catalog until ffprobe validation succeeds. + +### 9.2 Local CLI and desktop + +Local clients call `ImportMedia` with a path. Policy decides whether VidXP references +the original file or copies it into managed storage. Paths must resolve beneath +configured import roots when a boundary is required. + +Local media does not need to travel through an HTTP upload endpoint. + +### 9.3 Remote upload + +Remote upload is a four-stage protocol: + +1. An authenticated client creates an upload intent. +2. tusd's blocking `pre-create` HTTP hook validates the principal, repository, + declared size/type, and intent and assigns an opaque upload ID. +3. tusd returns an HTTPS upload URL. That URL is an unscoped bearer credential for + subsequent HEAD/PATCH requests because tusd cannot bind resumptions to the user + who created the upload. +4. The non-blocking `post-finish` hook idempotently upserts completion by upload ID + and enqueues the durable ffprobe/import workflow. + +Upload URLs and IDs are never placed in application logs, analytics, referrers, or +MCP results. Hook handlers assume duplicate and out-of-order delivery and never run +ffprobe or encoding inline. A recovery sweep finds completed uploads whose finish +hook was missed. A retention workflow removes abandoned intents and quarantine +objects. + +The hook endpoint is private to the Compose network. Client authorization is read +from the hook request body and redacted; client tokens are never stored in tus +metadata. Only the tus upload route is public. A completed upload is not a +`MediaAsset` until durable probe/import succeeds. + +The single-node default uses tusd filestore on a named quarantine volume shared +read-only with the worker after completion. It is not horizontally scalable. The +production/HA profile uses tusd's S3-compatible backend; no local media/artifact +volume is shared across replicas. Clients upload directly to tusd/storage; FastAPI +does not proxy large video bodies. + +tusd is pinned to a tested release digest, uses an explicit base path/public HTTPS +URL, a default 50 GiB maximum upload size, per-principal/repository storage quotas, +restricted CORS origins, and disabled downloads. Deployments may lower the maximum +but cannot raise it above the storage backend's reviewed hard ceiling. Termination is +disabled in the first release; cleanup is owned by retention workflows. + +FastAPI retains one small multipart compatibility endpoint with a central hard +maximum of 256 MiB. It rejects oversized declared `Content-Length` before reading +and enforces the same maximum while streaming to quarantine. It is never used for +multi-gigabyte media. + +URL ingestion is deferred. If added later, it is an asynchronous downloader with +SSRF controls, redirect and DNS revalidation, size/time limits, quarantine, and +ffprobe validation. + +### 9.4 MCP and media + +Remote MCP workflow: + +```text +HTTP upload/import service -> media_id -> MCP start_indexing(media_id) +``` + +MCP does not carry video bytes, base64 video, or arbitrary server paths. Stdio may +operate on an existing local `media_id`, but is not a video injection protocol. + +## 10. Index generations and repository snapshots + +An `IndexGeneration` is an immutable index of one media asset under one capability +and model/runtime manifest. An `IndexSnapshot` is an immutable repository view that +maps each active `media_id` to the generation containing its current searchable +records. + +This separation supports incremental libraries. Adding or re-indexing one video does +not rebuild every other video and does not mutate the currently searchable version. + +An upsert indexing job: + +1. Resolve and validate the media asset. +2. Acquire a lease for that repository and media asset. +3. Build under `indexes/generations/`. +4. Persist manifests and checkpoints inside the generation. +5. Validate capability completion and storage consistency. +6. Create a new snapshot by replacing only that media asset's generation mapping. +7. Atomically update `active-snapshot.json`. +8. Release the lease. +9. Retain or garbage-collect unreferenced generations according to policy. + +Removing media creates a snapshot without that media mapping; it does not delete +index data before the new snapshot is active. A full rebuild creates generations for +the selected assets and commits one new snapshot after all required generations +succeed. + +Failure or cancellation before step 7 leaves the prior snapshot and prior generation +searchable. + +Search resolves `active-snapshot.json` once and queries only generations referenced +by that immutable snapshot. Indexing and search therefore coexist safely. + +The Chroma adapter stores generation identity with every record and implements +snapshot-scoped search and garbage collection. Chroma remains replaceable behind the +`IndexRepository` port; snapshot semantics do not depend on Chroma collection layout. + +The manifest is authoritative for completed generation state. Progress files and +workflow events are not used to decide whether a generation or snapshot is valid. + +## 11. Durable jobs and process execution + +The application exposes one `JobService` contract: + +- submit +- get +- list +- cancel +- retry/resume where safe +- publish progress +- retrieve result + +DBOS 2.28 is the durable orchestration, queue, and state backend: + +- SQLite for local CLI/UI/desktop +- Postgres for API/Coolify +- the same workflow definitions and job contract in both modes +- separate CPU and GPU worker queues +- concurrency one by default for indexing per constrained runtime + +DBOS owns durable lifecycle, queueing, recovery, status, attempts, and persisted +progress. `set_event` stores the latest `JobProgress`; append-only DBOS streams are +used only when event history is required. Workflow stream writes are exactly once; +step stream writes may repeat after retry. The index manifest remains authoritative +for index state. + +DBOS synchronous workflows and steps run in threads inside the worker process. +DBOS does not provide process isolation or pre-empt blocking CPU/GPU calls. Heavy +work therefore always runs in a dedicated worker process/container, never in the +CLI, desktop, UI, API, or MCP process: + +```text +local: +CLI/UI/desktop -> DBOSClient -> supervised local worker process -> SQLite + +server: +API/MCP -> DBOSClient -> worker process/container -> Postgres +``` + +The local client may detach from the durable worker. SQLite is restricted to one +machine and local storage; it is not used on network filesystems or across hosts. + +The core runner accepts one execution context with progress and cooperative +cancellation. Queued cancellation removes the DBOS workflow. Cancelling an active +synchronous DBOS step only marks it cancelled and is observed at a later step; it +does not stop the running thread. VidXP therefore bridges durable cancellation to +the runner's `CancellationToken` and polls at progress/batch boundaries. Blocking +model and FFmpeg calls that expose no cancellation point finish before cancellation +is observed. Hard interruption is an operator-level termination of the dedicated +concurrency-one worker followed by supervisor restart; it is not promised as a +safe per-call API feature. + +This cooperative/process boundary is execution control, not another job-state +machine. VidXP does not recreate queue/job lifecycle in JSON and locks. + +Retry is enabled only for idempotent steps. Generation-based indexing makes retries +safe because incomplete generations are never active. + +Local workers use a stable executor ID. Server executor IDs are +`--` and remain stable across replacement of the +same release, while blue/green releases receive distinct IDs. The v1 server topology +uses exactly one CPU executor plus exactly one GPU executor when the GPU profile is +enabled. Each calls `DBOS.listen_queues()` before launch. The API uses `DBOSClient` +and never launches or listens to queues. A same-release replacement recovers work +owned by its stable ID; an old-version worker remains available while its workflows +drain. Scaling a release to multiple ordinals requires DBOS Conductor or a separately +reviewed dead-executor recovery coordinator. + +`worker_concurrency=1` limits one executor; repository leases enforce per-repository +exclusion. Queue-global concurrency is configured only where one job across every +worker is intentionally required. + +`application_version` is the immutable VidXP release identity. Recovery occurs only +on a worker supporting that workflow version. Deployments drain old-version jobs or +run blue/green old-version workers until they finish; breaking workflow changes +require an explicit DBOS patch/migration strategy. + +## 12. Resource scheduling and model runtime + +`RuntimeProfile` is resolved at startup: + +- operating system and architecture +- requested backend: `auto`, `cpu`, `cuda:`, or `mps` +- resolved backend per capability +- precision/compute type per capability +- model cache +- offline/download policy +- maximum concurrent indexing jobs +- maximum concurrent inference queries +- allowed model IDs and revisions + +Hardware probing occurs once. Invalid CUDA or MPS configuration fails readiness +clearly instead of falling back silently. + +`auto` is allowed only for local CLI/desktop. It resolves once, records the actual +backend used by each capability in the job/index manifest, and never changes during +a job. A capability may resolve differently from another capability on the same +machine; for example, scene embedding can use Apple MPS while transcription uses +CPU. Server/Coolify workers require explicit `cpu` or `cuda:` so scheduling +cannot drift. + +Model construction and caching belong to `ModelRuntime`. Adapters and capability +handlers do not each load models independently. + +The scheduler applies backpressure to CPU threads, RAM and VRAM. API request +threadpools are not the GPU scheduler. + +## 13. Cross-platform CPU and acceleration + +CPU completion is the first platform gate. The supported local targets are: + +- Apple Silicon macOS on the current and previous two major macOS releases +- Linux x86-64 +- Windows x86-64 + +Linux ARM64 is accepted only after the same native-wheel and model smoke gates pass. +Intel macOS is not a target for the new local runtime. + +The native macOS path does not require Docker. It uses: + +- the current stable CPython ARM64 installer/runtime +- a supervised local worker and DBOS SQLite +- embedded Chroma +- local FFmpeg/ffprobe +- PyTorch MPS for capabilities that pass parity tests +- CPU inference where a provider has no stable Metal backend +- native Ollama, which manages its own Metal acceleration + +The initial macOS transcription provider is CPU-capable. An MLX provider can replace +or supplement it only after it passes the same transcript/timestamp contracts; the +rest of VidXP does not depend on that optimization. + +CUDA is a later delivery phase, after the CPU application, API/MCP, media, jobs, +index snapshots, and deployment paths are complete. It uses the same application +contracts with a distinct Linux worker image: + +- the latest mutually compatible stable Python, PyTorch, CTranslate2, CUDA, and + cuDNN versions at the start of the GPU phase +- Docker/Compose GPU device reservation +- explicit precision and batch defaults per capability +- worker readiness that verifies CUDA without downloading models + +The GPU phase does not inherit the old PyTorch 2.8/WhisperX matrix. If a package +requires an obsolete Python/PyTorch/CUDA stack, replace or isolate that package +instead of downgrading the platform by default. `torchaudio` and `torchvision` are +installed only when a selected provider directly requires them. + +PyTorch CPU/CUDA variants are selected through separate locked constraints and +explicit package indexes. An `extra-index-url` is not used where it can mix CPU and +CUDA artifacts. + +Capability routing is explicit after acceleration acceptance: + +- scene and text embeddings: CPU everywhere; MPS on supported Macs and CUDA on the + GPU worker after parity tests +- transcription: CPU everywhere; CUDA through the selected CTranslate2 provider + after parity tests; an Apple MLX provider is a separate optimization +- actor: CPU until its ONNX GPU provider passes clustering/search parity +- SLM/Ollama: CPU/Metal locally; CPU by default in the server profile + +API/MCP never receives CUDA access. When the GPU profile is enabled, only +`worker-gpu` receives CUDA access. An optional Ollama CUDA profile can receive a +different explicit `device_id`. Sharing one device between the indexing worker and +Ollama requires a later cross-process VRAM admission design. + +Readiness reports platform, architecture, provider versions, selected backend, +precision, and device details. CUDA readiness reports driver, compiled CUDA, +device index/capability, and `torch.cuda.is_available()`. MPS readiness reports +`torch.backends.mps.is_available()`. Unsupported requested acceleration fails +instead of silently using CPU. + +Model weights are not baked into the default release image. First-use download and +offline/preload behavior are documented and observable. Preloaded model images are +not part of this architecture. + +## 14. Capability system + +Capability definitions contain domain metadata only: + +- name and description +- supported index stages +- operations and their shared input/result models +- execution group +- required media/index data +- model/runtime requirements +- query affordances and expected cost + +They do not contain Typer factories or MCP/FastAPI metadata. + +Built-ins are registered by the composition root. External capabilities use Python +package entry points. Execution grouping is explicit; it is not inferred from Python +callable identity. + +Capabilities depend on storage/model/media ports. Chroma and model packages are +infrastructure implementations. + +External packages register under entry-point group `vidxp.capabilities`. Each entry +point loads a zero-argument factory returning a transport-neutral +`CapabilityPlugin` containing a `CapabilityDefinition`, application executor +factory, and declared VidXP capability-contract version. The definition remains +pure metadata; the executor factory binds operations to handlers that depend only on +application/domain ports. + +External capabilities are trusted in-process code, not sandboxed plugins. They are +disabled by default in remote deployments and enabled by an allowlist of distribution +and entry-point name. Built-in names cannot be overridden. Duplicate names, +unsupported contract versions, import failures, and missing runtime requirements +fail readiness with distribution provenance. Discovery/loading occurs only in the +composition root and is sorted deterministically. + +### 14.1 Collector dependency decisions + +- Scene: remove `clip-anytorch` and its `setuptools<81` compatibility pin. Use the + latest stable Hugging Face Transformers vision-text implementation as the default + provider. Benchmark current stable SigLIP-family and other eligible retrieval + checkpoints against the existing scene benchmark; use `open_clip_torch` only when + it produces a material measured advantage. Only checkpoints with verified + redistribution terms are eligible. The winner and immutable checkpoint revision + define a new index schema/model identity. +- Dialogue: replace WhisperX as the default provider because its stable release + blocks current Python and constrains an older Torch family. The first replacement + is latest stable `faster-whisper`/CTranslate2 using batched transcription, VAD, and + word timestamps. Its transcript/timestamp output is tested against the existing + dialogue contract. Forced alignment is an optional provider behind a separate + contract; it cannot hold base transcription or Python back. Sentence embeddings + use the latest stable sentence-transformers stack with a pinned, licensed model + revision. +- Actor: replace `face_recognition`/dlib with OpenCV Zoo YuNet plus SFace through + ONNX Runtime. YuNet model files are MIT and SFace model files are Apache-2.0. + Migration requires clustering/search parity, a new index-schema/model identity, + and CPU performance gates. Actor remains CPU-routed until the ONNX GPU provider + passes the same gates. +- Storage: embedded Chroma `PersistentClient` is local CLI/desktop only. Server + deployments use the latest stable Chroma client/server release behind the + `IndexRepository` port. + +`ModelRuntime` records provider, canonical model ID, immutable revision/digest, +weight checksum, license identifier, precision/quantization, cache state, and offline +policy in every generation manifest. Transitive VAD/alignment/model downloads are +rejected unless allowlisted. + +## 15. Search + +`SearchMoments` is a central application query: + +- query text +- optional modalities/capabilities +- filters from an allowlisted typed grammar +- page size/cursor +- optional reranking profile + +Results are normalized into `SearchHit`: + +- `media_id` +- `video_id` +- source/index generation +- modality +- start/end time +- score and score semantics +- displayable text/metadata +- optional preview reference + +The application owns limits, score normalization, multimodal fusion and deterministic +ordering. UI/API/MCP may request stricter limits but cannot loosen policy. + +Actor cluster and detection queries use the same pagination conventions. + +## 16. Natural-language query layer + +`QueryVideo` is an application use case, not an MCP agent loop and not direct Chroma +access. + +Flow: + +1. Accept question, media/repository scope, and policy. +2. Ask an injected local SLM planner for a strictly typed `QueryPlan`. +3. Validate that the plan uses registered operations and safe parameters only. +4. Execute retrieval through application search/capability operations. +5. Normalize, fuse and optionally rerank evidence. +6. Ask the answer synthesizer for a grounded answer. +7. Return `QueryAnswer` with timestamped citations and supporting hits. + +Pydantic AI is the selected typed-call layer. The first local provider is self-hosted +Ollama >=0.5, pinned to a tested image digest, through pinned +`pydantic-ai-slim[openai]` and `OllamaModel`. Ollama Cloud is excluded because it +does not enforce the local JSON-Schema path required here. `QueryPlan` and +`QueryAnswer` use `NativeOutput`, strict Pydantic models, a bounded request/output +retry budget, and explicit time/token limits. The model receives no executable +tools. Pydantic AI does not replace VidXP's capability registry or search storage. + +When the SLM is unavailable or produces an invalid plan, a deterministic fallback +searches applicable modalities and returns evidence. Generated text can never become +a filesystem path, model identifier, arbitrary filter operator, or executable +operation. + +Before returning a generated answer, the application verifies every citation against +the retrieved hit ID, media ID, generation, and time interval. Missing, invented, or +out-of-range citations invalidate the generated answer and trigger the deterministic +evidence fallback. Typed JSON alone is never treated as proof of grounding. + +The result records planner/model version and plan for reproducibility. + +The exact SLM model ID, immutable revision/digest, and quantization are intentionally +blocked until candidate models pass: + +- both output schemas +- adversarial-plan rejection +- grounded-citation verification +- CPU RAM and latency limits +- GPU VRAM limits where applicable +- offline-cache behavior +- license and redistribution review + +Until that gate passes, the provider is fixed to self-hosted Ollama but no arbitrary +caller-provided model string is accepted. Ollama runs as an internal optional `slm` +Compose profile with a persistent model cache; deterministic evidence retrieval +remains available when it is disabled. + +## 17. Artifact and snippet delivery + +`Artifact` records: + +- `artifact_id` +- source `media_id` +- owning job +- type/profile +- MIME and byte size +- checksum/storage key +- state +- creation/expiry + +Local artifacts are served through protected Starlette `FileResponse`, including +byte-range support. Object-storage deployments return short-lived presigned GET +URLs. FastAPI does not proxy object-store bodies. + +Every delivery URL is minted only after repository/owner authorization. Local +delivery resolves an `ArtifactStore` key beneath its configured root, rejects +symlink/path escapes, and only then constructs `FileResponse`. + +Local source playback applies the identical configured-root and symlink/path-escape +checks to the `MediaStore` key before constructing `FileResponse`. + +Presigned URLs are least-privilege bearer credentials, returned with +`Cache-Control: private, no-store`, and never persisted or logged. Source-playback +URLs expire within five minutes; downloadable artifact URLs expire within fifteen +minutes. These are hard upper bounds in settings, not caller-selected TTLs. + +Each object-store implementation must pass GET/HEAD, single-range 206, +`Accept-Ranges`, `Content-Range`, `Content-Length`, MIME/`Content-Disposition`, +expired-signature, and browser-CORS tests. Clients refresh an expired authorized URL +before resuming. + +Search results normally use an authorized range-enabled source URL and client-side +seeking. VidXP does not generate a clip for every search hit. + +`CreateSnippet` is an asynchronous artifact job used for download/share/MCP resource +links. FFmpeg output is cached by source checksum, interval and encoding profile, +written to a temporary key, validated, and atomically published. + +Actor overlays and other rendered media use the same artifact workflow. Public +commands never accept arbitrary output paths. + +## 18. FastAPI adapter + +FastAPI owns: + +- HTTP routing and content negotiation +- authentication dependency and principal projection +- request/correlation IDs +- transport-level body rejection +- status codes and headers +- OpenAPI +- small multipart compatibility +- protected local artifact responses + +FastAPI calls application commands directly. It has no job manager, media store, +index lock, model cache, or MCP client. + +Use shared application models directly where the HTTP shape matches. Add transport +models only for HTTP-specific links or envelopes. + +Provide: + +- `/health` for minimal process liveness +- `/ready` for a minimal aggregate readiness result +- an authenticated runtime-readiness resource for component and device details + +Readiness does not download model weights. + +## 19. Authentication profiles + +### 19.1 Local + +- CLI/UI/desktop in-process: no network authentication. +- Local HTTP bound to loopback: optional static bearer. +- Local stdio MCP: operating-system process boundary. + +### 19.2 Private self-hosted + +A shared static bearer token is permitted as an explicitly documented single-tenant +mode. It uses constant-time verification and produces a typed principal. It is not +described as OAuth and does not imply users, scopes, or revocation. + +For HTTP API and MCP in this mode, one outer ASGI authentication middleware validates +the token before dispatch. The only public-path exceptions are the minimal +`/health` and `/ready` endpoints, which disclose no configuration or component +details. It does not publish fake OAuth issuer/resource metadata. + +The private tusd hook callback is also excluded from public bearer middleware, but +is not a public exception: the reverse proxy does not route it from the public +listener and only the isolated Compose hook network can reach it. The hook handler +validates the original client authorization carried in the `pre-create` hook event +against the upload intent. Later hook events are authorized by the opaque upload ID +and previously authenticated intent, not by assuming that the client bearer will be +present on every resumed request. Hook contents and credentials are redacted. + +### 19.3 Remote/multi-user + +An external OIDC provider issues access tokens. VidXP validates issuer, audience, +expiry and scopes and produces the same typed principal used by API and MCP. + +The MCP server implements OAuth resource-server and protected-resource metadata +required by the protocol. Tokens received by MCP are not forwarded to an internal +HTTP API. + +VidXP's MCP `TokenVerifier`, not the SDK metadata configuration, validates: + +- allowed signature algorithm and signature through cached JWKS +- exact issuer +- `aud` or RFC 8707 resource equal to the canonical public MCP URL +- expiration and not-before +- required scopes + +Only after those checks does it construct the SDK access token and shared VidXP +principal from subject/client ID, scopes, and allowlisted claims. + +Reverse-proxy authentication may be an additional gate but does not replace MCP +resource-server behavior. + +## 20. MCP adapter + +Use the official MCP Python SDK v2 high-level server. + +Primary remote transport: + +- Streamable HTTP +- explicit `stateless_http=True` +- `json_response=False` so disconnect cancellation uses the streaming/SSE path +- application state persisted outside MCP sessions +- explicit lifecycle through the application composition root +- an initial independent MCP request-body limit of 4 MiB + +Local transport: + +- stdio for clients running beside VidXP +- identical tools and shared contracts +- media referenced by `media_id` + +Initial curated tools: + +- `list_capabilities` +- `get_capability` +- `get_index_status` +- `start_indexing` +- `get_index_job` +- `cancel_index_job` +- `search_moments` +- paginated actor-cluster inspection +- `query_video` when its application service exists + +Tool results use real output schemas and structured content. Descriptions remain +short and agent-oriented; full API response schemas are not embedded as prose. + +Every public tool has an explicit Pydantic result annotation and leaves structured +output enabled. Public tools do not return bare primitives/lists because the SDK +wraps them in a synthetic `result` object. Contract tests cover runtime-only Pydantic +validators in addition to emitted JSON Schema. + +Protocol request cancellation is propagated to in-flight search/query work where +safe. It does not silently cancel an already accepted durable indexing job. +Cancellation is cooperative for synchronous CPU/GPU work; blocking functions are +never treated as pre-emptible merely because AnyIO runs them in a thread. + +MCP does not implement its own queue. Persisted VidXP jobs remain the authoritative +long-running operation contract. + +### 20.1 ASGI mounting and lifecycle + +The remote process exposes API and MCP on one public origin while keeping them as +sibling adapters. + +The MCP ASGI app is created with: + +- Streamable HTTP path `/mcp` +- stateless HTTP enabled +- JSON-only responses disabled +- configured MCP body limit +- explicit transport-security settings + +It is mounted at the outer application's root after concrete FastAPI routes. This +keeps both `/mcp` and the RFC 9728 host-root +`/.well-known/oauth-protected-resource/mcp` route reachable. + +The composition-root lifespan enters `mcp.session_manager.run()` exactly once and +orders shared application startup, MCP startup, MCP shutdown, and application +shutdown explicitly. A mounted Starlette child lifespan is not relied on, and MCP +does not start a second copy of shared database/storage/model resources. + +The public/proxy Host and Origin policy is configured explicitly. Binding to +`0.0.0.0` must not disable DNS-rebinding protections. Deployment validation confirms +that the Coolify proxy preserves `Accept`, `MCP-Protocol-Version`, `Mcp-Method`, and +`Mcp-Name`, and does not buffer MCP streaming responses. + +## 21. Streamlit and desktop adapters + +Streamlit retains: + +- widgets and page/session state +- display formatting +- local user interaction + +It loses: + +- fixed global media/artifact paths +- direct file persistence +- index readiness policy +- process and cancellation ownership +- hardcoded capability branching +- model/storage access + +The desktop application uses the same application commands and a local DBOS SQLite +workflow store, but heavy jobs run in a supervised separate worker process. It +chooses a platform app-data repository root, manages client/worker lifecycle, +displays model preparation progress, and detaches from durable work or requests +cooperative cancellation during shutdown. Closing the UI never waits on an +uncancellable model thread inside the UI process. + +Packaging technology is selected only after the application boundary is stable. + +### 21.1 Operating modes + +The same command and result contracts support three composition profiles: + +1. **Native local:** the CLI, Streamlit, or desktop adapter composes the application + with a local worker, DBOS SQLite, embedded Chroma, and a platform app-data + repository. Media can be imported from allowlisted local paths. This is the + default Apple Silicon experience and does not require Docker. +2. **Remote client:** a thin CLI, UI, desktop, or agent connects to a self-hosted + VidXP deployment. It contains no indexing models or local vector database. + Media crosses the boundary through the resumable upload flow and is subsequently + referenced by `media_id`. Human-facing clients use the HTTP API; agents use the + remote MCP Streamable HTTP endpoint. +3. **Self-hosted server:** API/MCP, workers, workflow state, media ingestion, and + vector search run as the Coolify stack. The same server also supports ordinary + Compose outside Coolify. + +Client configuration is limited to server base URL, authentication, repository, +timeouts, and local upload source. Selecting remote mode changes composition, not +business behavior. A client transport implements the same client-facing command +interface as the native application facade; it does not duplicate validation, +indexing, search, or artifact policy. + +The distributable Python package has a lightweight client/core installation and +explicit local/server extras. Installing the thin client must not install PyTorch, +model providers, Chroma server components, or CUDA packages. Platform lock files +resolve the local worker dependencies independently from the control plane and thin +client. + +## 22. Deployment topology + +Default Coolify stack: + +```text +api-mcp + ├── FastAPI routes + ├── private tusd hook callback + └── MCP Streamable HTTP mount + +worker-cpu + └── DBOS CPU queue, stable executor ID, concurrency 1 + +postgres + └── DBOS durable workflow state + +chroma + └── private vector-search server with its own volume + +tusd + └── public /uploads/ route and HTTP hook caller + +single-node media/artifact/quarantine volumes +``` + +Optional: + +- S3-compatible media/artifact storage, required for the HA profile +- GPU worker supplementing the CPU worker +- internal CPU-default Ollama `slm` profile +- external OIDC provider configuration + +Postgres, Chroma, and hook endpoints remain private. Only API/MCP and the tus upload +route are published. Every service has a meaningful Compose healthcheck. Required +secrets use `${VAR:?required}` rather than insecure defaults. Public routing cannot +reach the tusd callback path; tests exercise that denial independently of the +private-network hook flow. + +Release artifacts are: + +- the native `vidxp` CLI/client package for supported macOS, Linux, and Windows +- an explicit local-worker extra and platform lock for native CPU indexing +- `compose.local.yaml`: an optional containerized Linux evaluation/development + profile; it is not required for native macOS use +- `compose.coolify.yaml`: immutable prebuilt API/MCP, worker, Postgres, Chroma and + tusd images with no build contexts; the same file is valid with ordinary Compose +- `compose.gpu.yaml`: supplemental GPU worker override +- optional `slm` profile + +Use immutable release tags or digests. API/MCP and workers share an application +release but use purpose-specific image targets. API/MCP carries no heavy model or +CUDA dependencies; workers and the optional `slm` service do. + +Deployment v1 is one API/MCP replica and one node. Named media/artifact/quarantine +volumes are single-node only. Multiple API/worker replicas are a future gate that +requires Postgres leases, Chroma server, S3 media/artifacts, shared snapshot/catalog +transaction semantics, and load/failover tests. + +The one-click template consumes published tags/digests and must pass a real Coolify +deployment, upgrade, rollback, persistent-data and healthcheck test. + +## 23. Code to retain, move, or remove + +Baseline facts that the rebuild must not mistake for target behavior: + +- project metadata currently requires Python `>=3.10,<3.14`; the target baseline is + the current stable Python release with the supported matrix defined in section 5.1 +- the current Dockerfile/Compose produce one CPU-only Streamlit image/service +- server storage currently uses embedded `chromadb.PersistentClient` +- model loaders use per-module `lru_cache`, not one runtime/resource scheduler +- WhisperX compute type is hardcoded to `float32` +- repository configuration mixes deployment device choice into persistent identity +- the capability registry statically imports every built-in +- `CapabilityDefinition` currently carries CLI and implementation callables +- `clip-anytorch` requires the obsolete `setuptools<81` compatibility pin + +Retain and adapt: + +- capability-specific model/config logic +- frame/audio processing +- FFmpeg/ffprobe helpers +- manifest/checkpoint concepts +- Chroma storage initially +- existing search operations +- CLI rendering and Typer declarations + +Move behind contracts: + +- Chroma access +- repository layout +- media probing and persistence +- model loading/caching +- indexing execution +- actor rendering +- capability registration + +Remove instead of preserving: + +- adapter-owned job state machines +- adapter-owned media stores +- Streamlit process/global persistence policy +- MCP-to-FastAPI internal HTTP calls +- generated OpenAPI-to-MCP mirroring +- environment mutation as application configuration +- module-level application singletons +- progress files as index validity +- destructive in-place active-index replacement +- raw server paths in remote contracts +- caller-selected output paths +- duplicated transport-specific business models +- hardcoded central capability imports as the extension mechanism + +## 24. Verification strategy + +### 24.1 Contract tests + +The same command fixture must produce equivalent CLI JSON, API JSON and MCP +structured results after removing transport-only fields. + +### 24.2 Index durability + +Test: + +- failure before generation commit +- cancellation at each indexing stage +- process death and recovery +- search during replacement +- incremental add/re-index/delete +- atomic active-snapshot switch +- incomplete generation cleanup + +The prior snapshot and affected media generation must remain searchable in every +pre-commit failure. + +### 24.3 Job behavior + +Run the same workflow contract against: + +- SQLite local mode +- Postgres multi-process mode +- queued cancellation +- active cooperative cancellation +- worker restart +- idempotent retry +- CPU and GPU queue routing +- UI/client exit while a local worker continues +- cooperative cancellation latency at every runner checkpoint +- blocking-step cancellation behavior +- stable-executor restart recovery +- release-version drain and recovery + +### 24.4 Media + +Test: + +- resumed upload +- documented bearer-URL behavior and log/referrer redaction +- checksum/deduplication +- invalid extension with real video +- valid extension with invalid content +- interrupted and expired uploads +- duplicate/out-of-order hook delivery +- missed-finish-hook recovery sweep +- abandoned intent/quarantine cleanup +- completion remaining unpublished until durable ffprobe succeeds +- import-root escape +- retained external source disappearance + +### 24.5 MCP + +Validate with the official client over: + +- Streamable HTTP from a separate process +- stdio +- structured output schemas +- union/optional schema fidelity +- auth metadata and audience rejection +- protocol cancellation +- cancellation through the deployed non-buffering proxy +- durable job polling after session/client restart +- bounded actor/search responses +- host/origin rejection and required MCP proxy headers + +### 24.6 Capabilities and artifacts + +Capability discovery tests: + +- no installed plugin +- allowlisted plugin +- disabled plugin is not imported +- duplicate built-in/name +- incompatible contract version +- import failure +- deterministic behavior on supported Python 3.11 through 3.14 + +Artifact backend tests cover authorization, path/symlink containment, TTL upper +bounds, URL log redaction, GET/HEAD/range headers, MIME/disposition, expired URL +refresh, and browser CORS. + +### 24.7 Platforms and installation profiles + +Run clean-environment native tests for the latest stable Python on: + +- Apple Silicon macOS +- Linux x86-64 +- Windows x86-64 + +The Apple Silicon gate covers native package installation, FFmpeg/ffprobe, +separate-process DBOS worker recovery, embedded Chroma persistence, scene +CPU/MPS routing, transcription CPU routing, actor ONNX CPU routing, cancellation, +and clean uninstall. It also runs an end-to-end remote-client flow against the +Compose server: resumable media upload, indexing, polling, search, and artifact +retrieval. + +The thin-client environment must import and execute without model, Chroma server, +CUDA, or local-worker dependencies. The local-worker environment must run without +CUDA libraries. Package metadata, platform locks, and release images are tested +from an empty cache so undeclared system or developer-machine dependencies cannot +mask failures. + +### 24.8 CPU/GPU + +Before model downloads: + +- build CPU and GPU targets +- run clean-image `pip check` +- generate SBOM and license/model manifests +- prove the CPU image contains no CUDA runtime +- inspect installed PyTorch/CUDA compatibility +- run CUDA availability/readiness probes +- validate Compose GPU reservation +- validate worker queue routing with a synthetic job +- prove actor work routes to CPU +- validate OOM, cancellation and worker-restart behavior without model downloads + +After CPU functionality is complete: + +- download one pinned model set +- run selected scene and transcription provider smoke tests on a supported GPU +- compare golden outputs against CPU +- record VRAM, precision, throughput and fallback behavior + +## 25. Implementation sequence and gates + +### Phase 1: contracts and composition + +- settings and application factory +- shared identifiers/models/errors +- repository layout +- capability registry cleanup +- lightweight client/core, local-worker, and server dependency groups +- current-Python package metadata and platform lock generation + +Gate: no adapter imports concrete storage/models, cross-adapter schema tests pass, +and the thin-client environment installs and imports without ML/server dependencies. + +### Phase 2: current CPU provider baseline + +- replace obsolete scene, dialogue, and actor provider dependencies +- pin licensed model revisions and record index identities +- model runtime and resource scheduler +- embedded Chroma adapter on the latest stable compatible release +- native CPU packaging for Apple Silicon macOS, Linux x86-64, and Windows x86-64 + +Gate: clean environments on the latest stable Python pass dependency, import, +license, and representative CPU capability smoke tests on all supported platforms. +Apple Silicon also passes any enabled MPS parity test; unsupported acceleration +remains explicit and does not block its CPU path. + +### Phase 3: durable index foundation + +- immutable generations +- authoritative manifests +- immutable multi-media snapshots +- active-snapshot pointer +- repository leases + +Gate: incremental add/re-index/delete works and injected failure/cancellation never +damages the active snapshot. + +### Phase 4: media and artifacts + +- media catalog/store +- local import +- artifact catalog/store +- source playback and snippet contract + +Gate: application operations use IDs, not public filesystem paths. + +### Phase 5: workflow engine + +- DBOS job service +- SQLite local mode +- Postgres worker mode +- typed progress/cancellation + +Gate: restart, cancel and retry tests pass without adapter-owned job state. + +### Phase 6: thin API + +- FastAPI adapter +- auth profiles +- readiness +- small-upload compatibility +- artifact delivery + +Gate: API process performs no indexing/model work and owns no business persistence. + +### Phase 7: remote ingestion and deployment + +- tusd integration +- Coolify Compose +- immutable image targets + +Gate: interrupted multi-part upload resumes and completed media can be indexed by ID. +The gate also covers bearer upload-URL redaction, duplicate/out-of-order hooks, +missed-finish recovery, abandoned-upload retention, and the ffprobe publication +boundary. + +### Phase 8: MCP + +- official SDK v2 server +- Streamable HTTP and stdio +- curated typed tools +- remote auth + +Gate: separate-process remote client completes media-id indexing, polling and search; +schemas and structured results pass conformance checks. + +### Phase 9: multimodal and SLM query + +- fused search +- typed query planning +- evidence-grounded answer synthesis +- deterministic fallback + +Gate: every answer cites retrievable media intervals and invalid plans cannot escape +the operation grammar. + +### Phase 10: GPU + +- GPU worker image/profile +- runtime validation +- pinned model smoke tests +- resource limits + +Gate: GPU readiness and execution are explicit; no silent CPU fallback. +Clean-image dependency/SBOM checks, CPU-without-CUDA proof, selected scene and +transcription provider smoke tests, CPU actor routing, OOM handling, cancellation, +and worker restart must all pass. + +### Phase 11: desktop + +- desktop adapter +- platform repository lifecycle +- packaging and update strategy + +Gate: desktop uses the same commands/jobs/media/indexes without UI-owned business +logic. + +## 26. Decision validation ledger + +Validated on 2026-07-28: + +- **Official MCP SDK 2.0:** accepted. A separate-process Streamable HTTP probe and a + stdio probe passed tool discovery/calls, Pydantic discriminated unions, optionals, + constraints, `outputSchema`, and `structuredContent`. Required corrections for + mount lifespan, root metadata path, token audience validation, safe errors, + streaming cancellation, host/origin policy, and the MCP-specific body limit are + incorporated above. +- **DBOS 2.28:** accepted as durable queue/workflow state, not as process isolation. + A Windows/Python 3.13 probe exercised SQLite queue, progress, stable-executor + crash recovery, and a separate local worker process. Postgres 16 API-client plus + distinct CPU/GPU worker processes and queue routing were exercised. These probes + validate behavior, not the target Python floor or release lock. Active synchronous + cancellation was proven non-preemptive; the separate worker/cooperative semantics + above are therefore mandatory. +- **Current control-plane dependency baseline:** compatible at audit time. + `fastapi==0.140.13`, `mcp==2.0.0`, `dbos==2.28.0`, + `pydantic-settings==2.14.2`, and + `pydantic-ai-slim[openai]==2.19.0` resolved together on Python 3.13. These are + observations, not permanent pins. Implementation re-resolves the latest stable + releases on the latest Python 3.14 maintenance release and records only justified + compatibility exceptions. Release images lock the resulting direct and + transitive artifacts. +- **MCP licensing:** accepted. The audited MCP stack is permissively licensed. +- **DBOS licensing:** accepted with distribution compliance work. DBOS is MIT, but + mandatory psycopg/psycopg-binary dependencies are LGPL-3.0-only. Release artifacts + must carry required notices and satisfy binary redistribution/source obligations. +- **tusd:** accepted with the capability-URL and hook semantics documented above. + HTTP hooks are duplicate/out-of-order capable; the durable importer and sweep own + correctness. +- **Artifacts:** accepted. Starlette and S3-compatible range delivery provide the + required primitives; the authorization, containment, TTL, CORS, and bearer-URL + rules above remain mandatory. +- **Pydantic AI/Ollama:** accepted as the local SLM provider boundary. Typed native + output is supported. The exact model remains deliberately blocked by the + no-large-model-download constraint until Phase 9's measured gate. +- **Python and ML compatibility:** the current Python feature release is the + platform baseline. Stable PyTorch, CTranslate2, ONNX Runtime, and OpenCV releases + publish current-Python artifacts for the primary platform set, subject to the + clean-environment runtime gates above. The current stable WhisperX release blocks + that Python baseline and constrains an older Torch family, so it is rejected as + the default transcription provider rather than allowed to hold the platform back. + `faster-whisper`/CTranslate2 is the first provider candidate and must still pass + the Python 3.14, Apple Silicon, transcript, timestamp, and license gates. +- **GPU package matrix:** intentionally deferred. The previously proposed Python + 3.11/PyTorch 2.8/WhisperX/CUDA 12.8 matrix is rejected as a target. Phase 10 + resolves the latest mutually compatible stable stack from the then-current CPU + baseline and accepts it only through clean-image, CUDA, model, output-parity, + cancellation, and recovery tests. No model weights or CUDA image were downloaded + during this audit. +- **Chroma deployment:** embedded `PersistentClient` is accepted only locally. + Coolify uses a private Chroma server and client/server adapter. +- **Capability entry points:** accepted as standard trusted-code discovery with the + exact group, allowlist, version and collision rules above. + +Before a release image is published, audit locked direct/transitive packages, +container bases, system libraries, model weights/revisions/quantizations, automatic +alignment/VAD downloads, notices, and redistribution terms. Emit an SBOM and model +manifest for every image. Package metadata alone is not treated as model-license +evidence. From 7d728c37cf730106ae21c9fe67e181e1734dce3f Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 00:08:56 +0500 Subject: [PATCH 02/46] feat: centralize application and modernize CPU providers --- .github/workflows/ci.yml | 34 +- .github/workflows/release-to-pypi.yml | 4 +- .github/workflows/release-to-test-pypi.yml | 2 +- Dockerfile | 18 +- changes/platform.feature.md | 1 + docs/architecture/platform.md | 44 +- pyproject.toml | 16 +- src/vidxp/application.py | 329 ++++++------- src/vidxp/application_models.py | 171 +++++++ src/vidxp/benchmarks/hirest.py | 2 +- src/vidxp/capabilities/__init__.py | 14 +- src/vidxp/capabilities/actor/config.py | 4 +- src/vidxp/capabilities/actor/definition.py | 71 ++- src/vidxp/capabilities/actor/indexing.py | 61 ++- src/vidxp/capabilities/actor/models.py | 90 ++++ src/vidxp/capabilities/actor/requirements.txt | 6 +- src/vidxp/capabilities/contracts.py | 74 +-- src/vidxp/capabilities/dialogue/config.py | 16 +- src/vidxp/capabilities/dialogue/definition.py | 80 +-- src/vidxp/capabilities/dialogue/indexing.py | 114 ++--- src/vidxp/capabilities/dialogue/models.py | 88 ++-- src/vidxp/capabilities/dialogue/operations.py | 25 +- .../capabilities/dialogue/requirements.txt | 5 +- src/vidxp/capabilities/registry.py | 417 ++++++++++------ src/vidxp/capabilities/scene/config.py | 9 +- src/vidxp/capabilities/scene/definition.py | 44 +- src/vidxp/capabilities/scene/indexing.py | 37 +- src/vidxp/capabilities/scene/models.py | 40 +- src/vidxp/capabilities/scene/operations.py | 29 +- src/vidxp/capabilities/scene/requirements.txt | 12 +- src/vidxp/capabilities/visual.py | 19 +- src/vidxp/cli.py | 39 +- src/vidxp/cli_commands/index.py | 21 +- src/vidxp/cli_commands/runtime.py | 45 +- src/vidxp/cli_commands/search.py | 15 +- src/vidxp/cli_support.py | 21 +- src/vidxp/composition.py | 25 + src/vidxp/core/contracts.py | 6 +- src/vidxp/core/manifest.py | 41 +- src/vidxp/core/runner.py | 89 +++- src/vidxp/core/video.py | 28 -- src/vidxp/frontend.py | 97 ++-- src/vidxp/index_worker.py | 42 +- src/vidxp/infrastructure/__init__.py | 1 + src/vidxp/infrastructure/local_index.py | 69 +++ src/vidxp/ports.py | 34 ++ src/vidxp/repository_layout.py | 59 +++ src/vidxp/requirements/frontend.txt | 2 +- src/vidxp/requirements/storage.txt | 2 +- src/vidxp/runtime.py | 136 ++++++ src/vidxp/settings.py | 65 +++ tests/test_application.py | 414 +++++++--------- tests/test_benchmark_cli.py | 2 +- tests/test_capabilities.py | 188 ++++--- tests/test_cli.py | 461 ++++-------------- tests/test_frontend.py | 336 +++++-------- tests/test_index_worker.py | 23 +- tests/test_indexing.py | 457 ++++++----------- tests/test_models.py | 135 ++--- tests/test_packaging.py | 15 +- tests/test_runner.py | 16 +- tests/test_search.py | 11 + 62 files changed, 2597 insertions(+), 2174 deletions(-) create mode 100644 changes/platform.feature.md create mode 100644 src/vidxp/application_models.py create mode 100644 src/vidxp/capabilities/actor/models.py create mode 100644 src/vidxp/composition.py create mode 100644 src/vidxp/infrastructure/__init__.py create mode 100644 src/vidxp/infrastructure/local_index.py create mode 100644 src/vidxp/ports.py create mode 100644 src/vidxp/repository_layout.py create mode 100644 src/vidxp/runtime.py create mode 100644 src/vidxp/settings.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13f799d..a10b564 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,10 @@ permissions: jobs: validate: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v7 with: @@ -67,7 +71,7 @@ jobs: - uses: actions/setup-python@v7 if: steps.scope.outputs.needs_python == 'true' with: - python-version: "3.11" + python-version: ${{ matrix.python-version }} cache: pip - name: Install repository tooling @@ -77,7 +81,7 @@ jobs: python -m pip install -r utils/build-requirements.txt - name: Require a changelog fragment - if: steps.scope.outputs.require_changelog == 'true' + if: steps.scope.outputs.require_changelog == 'true' && matrix.python-version == '3.14' run: towncrier check --compare-with "${{ github.event.pull_request.base.sha }}" - name: Install test surface @@ -85,7 +89,7 @@ jobs: run: python -m pip install ".[scene,frontend,benchmarks]" - name: Lint - if: steps.scope.outputs.run_suite == 'true' + if: steps.scope.outputs.run_suite == 'true' && matrix.python-version == '3.14' run: ruff check src tests - name: Test @@ -95,11 +99,11 @@ jobs: PYTHONPATH: src - name: Build wheel and source distribution - if: steps.scope.outputs.run_suite == 'true' + if: steps.scope.outputs.run_suite == 'true' && matrix.python-version == '3.14' run: python -m build - name: Verify minimal wheel - if: steps.scope.outputs.run_suite == 'true' + if: steps.scope.outputs.run_suite == 'true' && matrix.python-version == '3.14' shell: bash run: | python -m venv .wheel-smoke @@ -111,9 +115,11 @@ jobs: from subprocess import check_output import vidxp - from vidxp.capabilities.registry import capability_names + from vidxp.capabilities.registry import create_capability_registry - assert capability_names() == ("dialogue", "scene", "actor") + assert create_capability_registry().names() == ( + "dialogue", "scene", "actor" + ) help_text = check_output( [".wheel-smoke/bin/vidxp", "--help"], text=True, @@ -121,28 +127,26 @@ jobs: assert "benchmark" not in help_text for module in ( "chromadb", - "clip", "cv2", - "face_recognition", + "faster_whisper", "sentence_transformers", - "srt", "streamlit", "torch", - "whisperx", + "transformers", ): assert find_spec(module) is None, module PY - name: Validate Compose configuration - if: steps.scope.outputs.run_container == 'true' + if: steps.scope.outputs.run_container == 'true' && matrix.python-version == '3.14' run: docker compose config --quiet - name: Build container - if: steps.scope.outputs.run_container == 'true' + if: steps.scope.outputs.run_container == 'true' && matrix.python-version == '3.14' run: docker build --tag vidxp:ci . - name: Smoke container - if: steps.scope.outputs.run_container == 'true' + if: steps.scope.outputs.run_container == 'true' && matrix.python-version == '3.14' shell: bash run: | docker run --detach --name vidxp-ci \ @@ -151,7 +155,7 @@ jobs: docker exec vidxp-ci vidxp --version docker exec vidxp-ci python -c \ - 'import clip, face_recognition, whisperx' + 'import cv2, faster_whisper, sentence_transformers, transformers' docker exec vidxp-ci sh -c \ 'test ! -d "$HOME/.cache/huggingface" && test ! -d "$HOME/.cache/clip"' diff --git a/.github/workflows/release-to-pypi.yml b/.github/workflows/release-to-pypi.yml index 8eef5de..29e637a 100644 --- a/.github/workflows/release-to-pypi.yml +++ b/.github/workflows/release-to-pypi.yml @@ -30,7 +30,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v7 with: - python-version: "3.11" + python-version: "3.14" - name: Install dependencies run: | @@ -144,7 +144,7 @@ jobs: docker exec vidxp-release vidxp --version docker exec vidxp-release python -c \ - 'import clip, face_recognition, whisperx' + 'import cv2, faster_whisper, sentence_transformers, transformers' docker exec vidxp-release sh -c \ 'test ! -d "$HOME/.cache/huggingface" && test ! -d "$HOME/.cache/clip"' diff --git a/.github/workflows/release-to-test-pypi.yml b/.github/workflows/release-to-test-pypi.yml index f9c55e6..f7440c8 100644 --- a/.github/workflows/release-to-test-pypi.yml +++ b/.github/workflows/release-to-test-pypi.yml @@ -38,7 +38,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v7 with: - python-version: "3.11" + python-version: "3.14" - name: Install dependencies run: | diff --git a/Dockerfile b/Dockerfile index e74c2f4..875ae2e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,12 @@ # syntax=docker/dockerfile:1 -FROM python:3.11-slim-bookworm AS builder +FROM python:3.14-slim-trixie AS builder WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ - cmake \ git \ - pkg-config \ - libopenblas-dev \ - liblapack-dev \ - libjpeg62-turbo-dev \ - libpng-dev \ && rm -rf /var/lib/apt/lists/* RUN python -m venv /opt/vidxp @@ -44,7 +38,7 @@ RUN --mount=type=cache,target=/root/.cache/pip \ python -m pip install . \ && python -m pip check -FROM python:3.11-slim-bookworm AS runtime +FROM python:3.14-slim-trixie AS runtime LABEL org.opencontainers.image.title="VidXP" \ org.opencontainers.image.description="Local-first video indexing and search" \ @@ -54,15 +48,7 @@ LABEL org.opencontainers.image.title="VidXP" \ RUN apt-get update && apt-get install -y --no-install-recommends \ ffmpeg \ libglib2.0-0 \ - libgl1 \ libgomp1 \ - libjpeg62-turbo \ - liblapack3 \ - libopenblas0 \ - libpng16-16 \ - libsm6 \ - libxext6 \ - libxrender1 \ && rm -rf /var/lib/apt/lists/* \ && groupadd --system vidxp \ && useradd --system --gid vidxp --home-dir /var/lib/vidxp vidxp \ diff --git a/changes/platform.feature.md b/changes/platform.feature.md new file mode 100644 index 0000000..da2f35d --- /dev/null +++ b/changes/platform.feature.md @@ -0,0 +1 @@ +Centralize CLI and UI behavior behind shared application contracts and replace legacy CPU model providers with pinned SigLIP2, Qwen3 Embedding, faster-whisper, YuNet, and SFace integrations. diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md index 096b278..391311f 100644 --- a/docs/architecture/platform.md +++ b/docs/architecture/platform.md @@ -591,23 +591,32 @@ composition root and is sorted deterministically. ### 14.1 Collector dependency decisions -- Scene: remove `clip-anytorch` and its `setuptools<81` compatibility pin. Use the - latest stable Hugging Face Transformers vision-text implementation as the default - provider. Benchmark current stable SigLIP-family and other eligible retrieval - checkpoints against the existing scene benchmark; use `open_clip_torch` only when - it produces a material measured advantage. Only checkpoints with verified - redistribution terms are eligible. The winner and immutable checkpoint revision - define a new index schema/model identity. +- Scene: remove `clip-anytorch` and its `setuptools<81` compatibility pin. Use + Transformers with `google/siglip2-base-patch16-224` at immutable revision + `75de2d55ec2d0b4efc50b3e9ad70dba96a7b2fa2`. The 2025 SigLIP 2 paper reports + retrieval improvements over SigLIP across model scales, and MIEB independently + places SigLIP-family models among strong multimodal retrieval encoders. That + evidence is recent and matches the image/text retrieval task closely enough that + downloading several large candidates for another selection benchmark is not + justified in this delivery phase. VidXP's existing scene benchmark remains the + regression gate. - Dialogue: replace WhisperX as the default provider because its stable release blocks current Python and constrains an older Torch family. The first replacement is latest stable `faster-whisper`/CTranslate2 using batched transcription, VAD, and - word timestamps. Its transcript/timestamp output is tested against the existing + word timestamps. The default is + `mobiuslabsgmbh/faster-whisper-large-v3-turbo` at immutable revision + `0a363e9161cbc7ed1431c9597a8ceaf0c4f78fcf`. Its transcript/timestamp output is + tested against the existing dialogue contract. Forced alignment is an optional provider behind a separate contract; it cannot hold base transcription or Python back. Sentence embeddings - use the latest stable sentence-transformers stack with a pinned, licensed model - revision. + use Qwen3-Embedding-0.6B at immutable revision + `97b0c614be4d77ee51c0cef4e5f07c00f9eb65b3`; its published multilingual MTEB + retrieval results materially exceed the older multilingual E5 baseline and its + Apache-2.0 license permits the intended deployment. - Actor: replace `face_recognition`/dlib with OpenCV Zoo YuNet plus SFace through - ONNX Runtime. YuNet model files are MIT and SFace model files are Apache-2.0. + OpenCV's maintained DNN APIs. Model files are retrieved with `pooch`, pinned to + OpenCV Zoo commit `47534e27c9851bb1128ccc0102f1145e27f23f98`, and verified + against recorded SHA-256 digests before use. Migration requires clustering/search parity, a new index-schema/model identity, and CPU performance gates. Actor remains CPU-routed until the ONNX GPU provider passes the same gates. @@ -1351,8 +1360,17 @@ Validated on 2026-07-28: clean-environment runtime gates above. The current stable WhisperX release blocks that Python baseline and constrains an older Torch family, so it is rejected as the default transcription provider rather than allowed to hold the platform back. - `faster-whisper`/CTranslate2 is the first provider candidate and must still pass - the Python 3.14, Apple Silicon, transcript, timestamp, and license gates. + `faster-whisper`/CTranslate2 is accepted as the CPU transcription provider and + must still pass the Python 3.14, Apple Silicon, transcript, timestamp, and license + release gates. +- **CPU model selection:** accepted without a new multi-model download run. + SigLIP 2 and MIEB provide recent task-matched evidence for the scene encoder; + Qwen3-Embedding publishes stronger multilingual retrieval results than the older + available baselines; faster-whisper publishes CPU/GPU speed and memory comparisons + against Whisper implementations; and OpenCV Zoo publishes the selected detector + and recognizer with evaluation data. VidXP runs regression and compatibility + smoke tests now; a new comparison benchmark is required only when newer evidence + conflicts, deployment measurements regress, or a provider change is proposed. - **GPU package matrix:** intentionally deferred. The previously proposed Python 3.11/PyTorch 2.8/WhisperX/CUDA 12.8 matrix is rejected as a target. Phase 10 resolves the latest mutually compatible stable stack from the then-current CPU diff --git a/pyproject.toml b/pyproject.toml index cfc7678..32b2c6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ authors = [ ] description = "VidXP - Video indexing and search by dialogue, scene, and actor" readme = "README.md" -requires-python = ">=3.10,<3.14" +requires-python = ">=3.11,<3.15" license = "MIT" license-files = ["LICENSE"] keywords = ["video", "search", "indexing", "cli", "streamlit"] @@ -26,19 +26,21 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Multimedia :: Video", "Topic :: Software Development :: Libraries :: Python Modules", ] dynamic = ["optional-dependencies"] dependencies = [ - "filelock>=3.13", - "packaging>=24", - "pydantic>=2.8,<3", - "rich", + "filelock>=3.32,<4", + "packaging>=26.2,<27", + "platformdirs>=4.11,<5", + "pydantic>=2.13.4,<3", + "pydantic-settings>=2.14.2,<3", + "rich>=15,<16", "typer>=0.27,<1", ] @@ -88,7 +90,7 @@ frontend = { file = ["src/vidxp/requirements/frontend.txt"] } benchmarks = { file = ["src/vidxp/benchmarks/requirements.txt"] } [tool.ruff] -target-version = "py310" +target-version = "py311" [tool.ruff.lint] select = ["E4", "E7", "E9", "F"] diff --git a/src/vidxp/application.py b/src/vidxp/application.py index 57223d0..f8fc4b2 100644 --- a/src/vidxp/application.py +++ b/src/vidxp/application.py @@ -1,37 +1,37 @@ from __future__ import annotations -from dataclasses import replace from pathlib import Path -from typing import Any, Iterable, Mapping, cast +from typing import Any, Mapping, cast from pydantic import BaseModel -from vidxp.capabilities.contracts import ( - CapabilityContext, - PreparationContext, - capability_install_hint, -) -from vidxp.capabilities.registry import ( - capability_names, - collection_names, - dependency_checks, - get_capability, - index_capability_names, - preparable_capability_names, - validate_capability_options, - validate_capability_names, +from vidxp.application_models import ( + ApplicationError, + CreateIndexCommand, + DependencyCheckCommand, + DependencyCheckResult, + DependencyUnavailableError, + ErrorCategory, + IndexResult, + IndexStatus, + PrepareModelsCommand, + PrepareModelsResult, + SearchCommand, ) from vidxp.capabilities.actor.schemas import ( ActorClusterSummary, ActorDetection, ActorRenderResult, ) -from vidxp.capabilities.schemas import SearchResult -from vidxp.core.contracts import ( - CancellationToken, - IndexConfig, - IndexSchemaError, +from vidxp.capabilities.contracts import ( + CapabilityContext, + PreparationContext, + capability_install_hint, ) +from vidxp.capabilities.registry import CapabilityRegistry +from vidxp.capabilities.schemas import SearchResult +from vidxp.core.contracts import CancellationToken, IndexConfig, IndexSchemaError +from vidxp.core.indexing_common import ProgressCallback from vidxp.core.manifest import ( CHECKPOINT_DIRECTORY, COMPLETION_FILE, @@ -39,179 +39,167 @@ MANIFEST_FILE, TIMINGS_FILE, ) -from vidxp.core.runner import ( - ProgressCallback, - index_video, - indexing_in_progress, - local_config_from_status, -) -from vidxp.core.storage import IndexStorage from vidxp.index_state import ( INDEX_STATUS_FILE, INDEX_STATUS_SCHEMA, IndexingInProgressError, - read_index_status, - require_ready_index, ) +from vidxp.ports import IndexBackend +from vidxp.repository_layout import RepositoryLayout +from vidxp.runtime import ModelRuntime -class VidXPService: - """Reusable application boundary for CLI, HTTP, and other adapters.""" +class VidXPApplication: + """The transport-neutral command and query boundary.""" def __init__( self, - index_directory: str | Path = "chroma_data", *, - device: str | None = None, + layout: RepositoryLayout, + registry: CapabilityRegistry, + runtime: ModelRuntime, + index_backend: IndexBackend, ) -> None: - self.index_directory = Path(index_directory) - self.device = device + self.layout = layout + self.registry = registry + self.runtime = runtime + self.index_backend = index_backend - def index_status(self) -> dict[str, Any]: - status = read_index_status(self.index_directory) - if status is not None: - payload = dict(status) - else: - payload = { + @property + def index_directory(self) -> Path: + return self.layout.local_index + + @property + def device(self) -> str: + return self.runtime.backends.torch_device + + def index_status(self) -> IndexStatus: + stored = self.index_backend.status(self.index_directory) + payload = ( + dict(stored) + if stored is not None + else { "schema_version": INDEX_STATUS_SCHEMA, "state": "missing", "stage": "status", "message": "No local video index was found.", } - payload["index_directory"] = str(self.index_directory) - return payload + ) + payload.update( + { + "repository_root": self.layout.root, + "index_directory": self.index_directory, + } + ) + return IndexStatus.model_validate(payload) def active_config(self) -> tuple[IndexConfig, dict[str, Any]]: - status = require_ready_index(self.index_directory) - config = local_config_from_status( - status, - storage_directory=self.index_directory, + return self.index_backend.active_config( + self.index_directory, + device=self.device, ) - if self.device is not None: - config = replace(config, device=self.device) - return config, status def create_index( self, - video_path: str | Path, + command: CreateIndexCommand, *, - modalities: Iterable[str] | None = None, - frame_stride: int = 1, - capability_options: Mapping[ - str, - Mapping[str, Any], - ] | None = None, progress_callback: ProgressCallback | None = None, cancellation: CancellationToken | None = None, - source_name: str | None = None, - ) -> dict[str, Any]: - selected = self._validate_modalities( - index_capability_names() if modalities is None else modalities - ) + ) -> IndexResult: + selected = self.registry.validate_names(command.modalities) non_indexable = [ name for name in selected - if get_capability(name).indexer is None + if self.registry.get(name).collection_name is None ] if non_indexable: raise ValueError( "These capabilities do not support indexing: " + ", ".join(non_indexable) ) - options: dict[str, Any] = { - "enabled_modalities": selected, - "frame_stride": frame_stride, - "storage_directory": self.index_directory, - "collection_names": collection_names(selected), - "capability_options": validate_capability_options( + self.layout.ensure_local_directories() + config = IndexConfig.local( + enabled_modalities=selected, + frame_stride=command.frame_stride, + storage_directory=self.index_directory, + collection_names=self.registry.collection_names(selected), + capability_options=self.registry.validate_options( selected, - capability_options, + command.capability_options, ), - } - if self.device is not None: - options["device"] = self.device - config = IndexConfig.local(**options) - return index_video( - str(video_path), - progress_callback=progress_callback, - source_name=source_name, - config=config, - cancellation=cancellation, + device=self.device, + ) + return IndexResult( + summary=self.index_backend.create( + command.path, + config=config, + progress=progress_callback, + cancellation=cancellation, + source_name=command.source_name, + ) ) def indexing_in_progress(self) -> bool: - options: dict[str, Any] = { - "storage_directory": self.index_directory, - } - if self.device is not None: - options["device"] = self.device - return indexing_in_progress(IndexConfig.local(**options)) + return self.index_backend.indexing_in_progress( + self._base_config() + ) def check_dependencies( self, - modalities: Iterable[str] | None = None, - ) -> dict[str, Any]: - selected = self._validate_modalities( - capability_names() if modalities is None else modalities + command: DependencyCheckCommand, + ) -> DependencyCheckResult: + selected = self.registry.validate_names(command.modalities) + checks = self.registry.dependency_checks(selected) + return DependencyCheckResult( + ok=all(check["ok"] for check in checks), + modalities=selected, + checks=checks, ) - checks = list(dependency_checks(selected)) - return { - "ok": all(check["ok"] for check in checks), - "modalities": list(selected), - "checks": checks, - } def prepare_models( self, - modalities: Iterable[str] | None = None, + command: PrepareModelsCommand, *, - capability_options: Mapping[ - str, - Mapping[str, Any], - ] | None = None, progress_callback: ProgressCallback | None = None, - ) -> dict[str, Any]: - selected = self._validate_modalities( - preparable_capability_names() - if modalities is None - else modalities + ) -> PrepareModelsResult: + selected = self.registry.validate_names(command.modalities) + options = self.registry.validate_options( + selected, + command.capability_options, ) - options = validate_capability_options(selected, capability_options) - checks = dependency_checks(selected) + checks = self.registry.dependency_checks(selected) failures = [check for check in checks if not check["ok"]] if failures: details = "; ".join( - f"{check['name']}: {check['error']}" - for check in failures - ) - extras = ",".join( - get_capability(name).extra for name in selected + f"{check['name']}: {check['error']}" for check in failures ) - raise RuntimeError( - f"{details}. {capability_install_hint(extras)}" + extras = ",".join(self.registry.get(name).extra for name in selected) + raise ApplicationError( + "capability_dependencies_unavailable", + ErrorCategory.unavailable, + f"{details}. {capability_install_hint(extras)}", + details={"failures": failures}, ) - prepared = [] + prepared: list[str] = [] for name in selected: - capability = get_capability(name) - prepare = capability.prepare - if prepare is not None: + executor = self.registry.executor(name) + if executor.prepare is not None: prepared.extend( - prepare( + executor.prepare( PreparationContext( - device=self.device or "cpu", - settings=capability.config_model.model_validate( - options[name] - ), + runtime=self.runtime, + settings=self.registry.get(name) + .config_model.model_validate(options[name]), ), progress_callback, ) ) - return { - "prepared": prepared, - "modalities": list(selected), - "device": self.device or "cpu", - } + return PrepareModelsResult( + prepared=tuple(prepared), + modalities=selected, + runtime=self.runtime.describe(), + ) def execute( self, @@ -219,49 +207,45 @@ def execute( operation: str, payload: BaseModel | Mapping[str, Any], ) -> BaseModel: - """Validate and execute a registered capability operation.""" - - definition = get_capability(capability) + definition = self.registry.get(capability) try: - selected_operation = definition.operations[operation] + contract = definition.operations[operation] + handler = self.registry.executor(capability).operations[operation] except KeyError as exc: available = ", ".join(definition.operations) or "none" raise ValueError( f"Capability {capability!r} has no operation {operation!r}. " f"Available operations: {available}." ) from exc + config = None - if selected_operation.requires_index: + if contract.requires_index: config, _ = self.active_config() if capability not in config.enabled_modalities: raise ValueError( f"The {capability} capability is not present in this index." ) + request = contract.input_model.model_validate(payload) try: - return selected_operation.invoke( - CapabilityContext(config=config), - payload, + response = handler( + CapabilityContext(config=config, runtime=self.runtime), + request, ) except ModuleNotFoundError as exc: dependency = exc.name or "optional dependency" - raise RuntimeError( - f"{dependency} is unavailable. " - + capability_install_hint(definition.extra) + raise DependencyUnavailableError( + dependency, + capability_install_hint(definition.extra), ) from exc + return contract.output_model.model_validate(response) - def search( - self, - modality: str, - query: str, - *, - top_k: int = 10, - ) -> SearchResult: + def search(self, command: SearchCommand) -> SearchResult: return cast( SearchResult, self.execute( - modality, + command.modality, "search", - {"query": query, "top_k": top_k}, + {"query": command.query, "top_k": command.top_k}, ), ) @@ -269,10 +253,7 @@ def actor_clusters(self) -> tuple[ActorClusterSummary, ...]: result = self.execute("actor", "clusters", {}) return tuple(result.clusters) - def actor_detections( - self, - cluster_id: str, - ) -> list[ActorDetection]: + def actor_detections(self, cluster_id: str) -> list[ActorDetection]: result = self.execute( "actor", "detections", @@ -302,35 +283,26 @@ def render_actor( def clear_index(self) -> bool: if not self.index_directory.exists(): return False - base_config = IndexConfig.local( - storage_directory=self.index_directory, - enabled_modalities=index_capability_names(), - collection_names=collection_names(), - ) - if indexing_in_progress(base_config): + base_config = self._base_config() + if self.index_backend.indexing_in_progress(base_config): raise IndexingInProgressError( f"Indexing is active for {self.index_directory}." ) - - status = read_index_status(self.index_directory) + status = self.index_backend.status(self.index_directory) if status is not None and status.get("state") == "ready": try: - config = local_config_from_status( - status, - storage_directory=self.index_directory, - ) + config, _ = self.active_config() except (IndexSchemaError, KeyError, TypeError, ValueError): config = base_config else: config = base_config try: - with IndexStorage(config) as storage: - storage.clear() + self.index_backend.clear(config) except ModuleNotFoundError as exc: dependency = exc.name or "optional storage dependency" - raise RuntimeError( - f"{dependency} is unavailable. " - + capability_install_hint("storage") + raise DependencyUnavailableError( + dependency, + capability_install_hint("storage"), ) from exc for name in ( @@ -351,8 +323,13 @@ def clear_index(self) -> bool: pass return True - @staticmethod - def _validate_modalities( - modalities: Iterable[str], - ) -> tuple[str, ...]: - return validate_capability_names(modalities) + def _base_config(self) -> IndexConfig: + return IndexConfig.local( + storage_directory=self.index_directory, + enabled_modalities=self.registry.index_names(), + collection_names=self.registry.collection_names(), + device=self.device, + ) + + +VidXPService = VidXPApplication diff --git a/src/vidxp/application_models.py b/src/vidxp/application_models.py new file mode 100644 index 0000000..ebeb2c2 --- /dev/null +++ b/src/vidxp/application_models.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from enum import StrEnum +from pathlib import Path +from typing import Annotated, Any, Generic, Mapping, TypeAlias, TypeVar + +from pydantic import BaseModel, ConfigDict, Field, StringConstraints + + +Identifier: TypeAlias = Annotated[ + str, + StringConstraints( + strip_whitespace=True, + min_length=1, + max_length=255, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$", + ), +] +RepositoryId: TypeAlias = Identifier +MediaId: TypeAlias = Identifier +VideoId: TypeAlias = Identifier +IndexGenerationId: TypeAlias = Identifier +IndexSnapshotId: TypeAlias = Identifier +JobId: TypeAlias = Identifier +ArtifactId: TypeAlias = Identifier +T = TypeVar("T") + + +class ApplicationModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class Page(ApplicationModel, Generic[T]): + items: tuple[T, ...] = () + total: int = Field(ge=0) + next_cursor: str | None = None + + +class RuntimeProfile(ApplicationModel): + requested: str + torch_device: str + transcription_device: str + actor_device: str = "cpu" + mps_available: bool = False + cuda_available: bool = False + + +class ErrorCategory(StrEnum): + validation = "validation" + authentication = "authentication" + authorization = "authorization" + not_found = "not_found" + conflict = "conflict" + unavailable = "unavailable" + resource_limit = "resource_limit" + cancelled = "cancelled" + internal = "internal" + + +class ErrorDetail(ApplicationModel): + code: str = Field(min_length=1) + category: ErrorCategory + message: str = Field(min_length=1) + details: Mapping[str, Any] = Field(default_factory=dict) + retryable: bool = False + correlation_id: str | None = None + + +class ApplicationError(RuntimeError): + def __init__( + self, + code: str, + category: ErrorCategory, + message: str, + *, + details: Mapping[str, Any] | None = None, + retryable: bool = False, + correlation_id: str | None = None, + ) -> None: + self.detail = ErrorDetail( + code=code, + category=category, + message=message, + details=details or {}, + retryable=retryable, + correlation_id=correlation_id, + ) + super().__init__(message) + + @property + def code(self) -> str: + return self.detail.code + + @property + def category(self) -> ErrorCategory: + return self.detail.category + + @property + def retryable(self) -> bool: + return self.detail.retryable + + def to_dict(self) -> dict[str, Any]: + return self.detail.model_dump(mode="json") + + +class DependencyUnavailableError(ApplicationError): + def __init__(self, dependency: str, install_hint: str) -> None: + super().__init__( + "dependency_unavailable", + ErrorCategory.unavailable, + f"{dependency} is unavailable. {install_hint}", + details={"dependency": dependency}, + ) + + +class CreateIndexCommand(ApplicationModel): + path: Path + modalities: tuple[str, ...] + frame_stride: int = Field(default=1, gt=0) + capability_options: Mapping[str, Mapping[str, Any]] = Field( + default_factory=dict + ) + source_name: str | None = Field(default=None, min_length=1) + + +class IndexResult(ApplicationModel): + summary: Mapping[str, Any] + + +class IndexStatus(ApplicationModel): + schema_version: int = Field(ge=1) + state: str = Field(min_length=1) + stage: str = Field(min_length=1) + message: str = Field(min_length=1) + repository_root: Path + index_directory: Path + updated_at: str | None = None + current: int | None = Field(default=None, ge=0) + total: int | None = Field(default=None, ge=0) + video: Mapping[str, Any] | None = None + summary: Mapping[str, Any] | None = None + error: str | None = None + + +class SearchCommand(ApplicationModel): + modality: str = Field(min_length=1) + query: str = Field(min_length=1) + top_k: int = Field(default=10, gt=0, le=100) + + +class PrepareModelsCommand(ApplicationModel): + modalities: tuple[str, ...] + capability_options: Mapping[str, Mapping[str, Any]] = Field( + default_factory=dict + ) + + +class DependencyCheckCommand(ApplicationModel): + modalities: tuple[str, ...] + + +class DependencyCheckResult(ApplicationModel): + ok: bool + modalities: tuple[str, ...] + checks: tuple[Mapping[str, Any], ...] + + +class PrepareModelsResult(ApplicationModel): + prepared: tuple[str, ...] + modalities: tuple[str, ...] + runtime: Mapping[str, Any] diff --git a/src/vidxp/benchmarks/hirest.py b/src/vidxp/benchmarks/hirest.py index ce499a3..3e917a9 100644 --- a/src/vidxp/benchmarks/hirest.py +++ b/src/vidxp/benchmarks/hirest.py @@ -489,7 +489,7 @@ def run_hirest( "duration_relative_window_mean_second_score" ), "temporal_window_fraction": temporal_window_fraction, - "whisperx_used": False, + "transcription_provider": "supplied-transcript", "video_decode_used": False, } if split == "test": diff --git a/src/vidxp/capabilities/__init__.py b/src/vidxp/capabilities/__init__.py index 381c275..5902c1f 100644 --- a/src/vidxp/capabilities/__init__.py +++ b/src/vidxp/capabilities/__init__.py @@ -1,15 +1,11 @@ -"""Built-in VidXP capabilities and their explicit registry.""" +"""Capability contracts and registry construction.""" from vidxp.capabilities.registry import ( - CAPABILITIES, - capability_names, - get_capability, - index_capability_names, + CapabilityRegistry, + create_capability_registry, ) __all__ = [ - "CAPABILITIES", - "capability_names", - "get_capability", - "index_capability_names", + "CapabilityRegistry", + "create_capability_registry", ] diff --git a/src/vidxp/capabilities/actor/config.py b/src/vidxp/capabilities/actor/config.py index 8756fd0..9a11ae7 100644 --- a/src/vidxp/capabilities/actor/config.py +++ b/src/vidxp/capabilities/actor/config.py @@ -8,8 +8,8 @@ class ActorConfig(CapabilityConfig): batch_size: int = Field(default=16, gt=0) - match_threshold: float = Field(default=0.55, gt=0, lt=1) - num_jitters: int = Field(default=2, gt=0) + match_threshold: float = Field(default=0.363, gt=0, lt=1) + detection_threshold: float = Field(default=0.9, gt=0, lt=1) minimum_detections: int = Field(default=4, gt=0) diff --git a/src/vidxp/capabilities/actor/definition.py b/src/vidxp/capabilities/actor/definition.py index 690005d..c1142ae 100644 --- a/src/vidxp/capabilities/actor/definition.py +++ b/src/vidxp/capabilities/actor/definition.py @@ -4,6 +4,14 @@ from vidxp.capabilities.actor.config import ActorConfig, actor_config from vidxp.capabilities.actor.indexing import VISUAL_PROCESSOR +from vidxp.capabilities.actor.models import ( + OPENCV_ZOO_REVISION, + SFACE_FILE, + SFACE_SHA256, + YUNET_FILE, + YUNET_SHA256, + get_actor_models, +) from vidxp.capabilities.actor.operations import ( clusters_operation, detections_operation, @@ -11,7 +19,10 @@ ) from vidxp.capabilities.contracts import ( CapabilityDefinition, + CapabilityExecutor, + CapabilityPlugin, OperationDefinition, + PreparationContext, ) from vidxp.capabilities.actor.schemas import ( ActorClustersInput, @@ -23,6 +34,23 @@ ) from vidxp.capabilities.visual import index_capabilities from vidxp.core.contracts import IndexConfig, VideoSource +from vidxp.core.indexing_common import ProgressCallback + + +def prepare_models( + context: PreparationContext, + progress: ProgressCallback | None, +) -> tuple[str, ...]: + if progress is not None: + progress( + { + "state": "preparing", + "stage": "actor_models", + "message": "Preparing OpenCV Zoo YuNet and SFace models.", + } + ) + get_actor_models(context.runtime) + return (YUNET_FILE, SFACE_FILE) def model_manifest( config: IndexConfig, @@ -31,47 +59,58 @@ def model_manifest( settings = actor_config(config) return { "actor": { - "library": "face_recognition", + "provider": "opencv-zoo", + "revision": OPENCV_ZOO_REVISION, + "detector": {"file": YUNET_FILE, "sha256": YUNET_SHA256}, + "recognizer": {"file": SFACE_FILE, "sha256": SFACE_SHA256}, "match_threshold": settings.match_threshold, - "num_jitters": settings.num_jitters, + "detection_threshold": settings.detection_threshold, "minimum_detections": settings.minimum_detections, } } -def cli_app(): - from vidxp.capabilities.actor.cli import app - - return app - - DEFINITION = CapabilityDefinition( name="actor", description="Index, inspect, and render actor clusters.", extra="actor", config_model=ActorConfig, collection_name="actor", - indexer=index_capabilities, - index_processor=VISUAL_PROCESSOR, index_stage="visual_indexing", - model_manifest=model_manifest, + execution_group="visual", + prepares_models=True, operations={ "clusters": OperationDefinition( input_model=ActorClustersInput, output_model=ActorClustersOutput, - handler=clusters_operation, ), "detections": OperationDefinition( input_model=ActorDetectionsInput, output_model=ActorDetectionsOutput, - handler=detections_operation, ), "render": OperationDefinition( input_model=ActorRenderInput, output_model=ActorRenderResult, - handler=render_operation, ), }, - cli_name="actors", - cli_factory=cli_app, +) + + +def create_executor() -> CapabilityExecutor: + return CapabilityExecutor( + indexer=index_capabilities, + index_processor=VISUAL_PROCESSOR, + operations={ + "clusters": clusters_operation, + "detections": detections_operation, + "render": render_operation, + }, + model_manifest=model_manifest, + prepare=prepare_models, + ) + + +PLUGIN = CapabilityPlugin( + definition=DEFINITION, + executor_factory=create_executor, ) diff --git a/src/vidxp/capabilities/actor/indexing.py b/src/vidxp/capabilities/actor/indexing.py index 7328d10..66743a3 100644 --- a/src/vidxp/capabilities/actor/indexing.py +++ b/src/vidxp/capabilities/actor/indexing.py @@ -4,6 +4,7 @@ from typing import Any from vidxp.capabilities.actor.config import actor_config +from vidxp.capabilities.actor.models import ActorModels, get_actor_models from vidxp.core.contracts import ( CancellationToken, IndexConfig, @@ -13,10 +14,12 @@ ) from vidxp.core.indexing_common import ProgressCallback from vidxp.core.storage import IndexStorage +from vidxp.runtime import ModelRuntime @dataclass class ActorIndexState: + models: ActorModels known_encodings: list[Any] = field(default_factory=list) known_ids: list[str] = field(default_factory=list) histories: dict[str, list[Any]] = field(default_factory=dict) @@ -24,17 +27,14 @@ class ActorIndexState: processed_frames: int = 0 -def _best_face_match( - face_recognition, - known_encodings, - encoding, - threshold, -): +def _best_face_match(known_encodings, encoding, threshold): + import numpy as np + if not known_encodings: return None - distances = face_recognition.face_distance(known_encodings, encoding) - match = int(distances.argmin()) - return match if distances[match] < threshold else None + similarities = np.asarray(known_encodings) @ encoding + match = int(similarities.argmax()) + return match if similarities[match] >= threshold else None def _actor_records( @@ -78,7 +78,7 @@ def process_actor_samples( storage: IndexStorage, cancellation: CancellationToken, ) -> None: - import face_recognition + import cv2 import numpy as np settings = actor_config(config) @@ -87,17 +87,25 @@ def process_actor_samples( detections = [] for sample in group: cancellation.raise_if_cancelled() - locations = face_recognition.face_locations(sample.frame) - encodings = face_recognition.face_encodings( - sample.frame, - locations, - num_jitters=settings.num_jitters, + frame = cv2.cvtColor(sample.frame, cv2.COLOR_RGB2BGR) + height, width = frame.shape[:2] + state.models.detector.setInputSize((width, height)) + state.models.detector.setScoreThreshold( + settings.detection_threshold ) - for ordinal, (encoding, location) in enumerate( - zip(encodings, locations) - ): + _, faces = state.models.detector.detect(frame) + for ordinal, face in enumerate(faces if faces is not None else ()): + aligned = state.models.recognizer.alignCrop(frame, face) + encoding = ( + state.models.recognizer.feature(aligned) + .flatten() + .astype("float32") + ) + norm = float(np.linalg.norm(encoding)) + if norm == 0: + continue + encoding /= norm match = _best_face_match( - face_recognition, state.known_encodings, encoding, settings.match_threshold, @@ -113,7 +121,10 @@ def process_actor_samples( history.append(encoding) if len(history) > 5: history.pop(0) - state.known_encodings[match] = np.mean(history, axis=0) + centroid = np.mean(history, axis=0) + state.known_encodings[match] = ( + centroid / np.linalg.norm(centroid) + ) state.cluster_sizes[cluster_id] = ( state.cluster_sizes.get(cluster_id, 0) + 1 ) @@ -125,7 +136,12 @@ def process_actor_samples( "cluster_id": cluster_id, "frame_index": sample.frame_index, "timestamp": sample.timestamp, - "bbox": tuple(int(value) for value in location), + "bbox": ( + max(0, int(face[1])), + min(width, int(face[0] + face[2])), + min(height, int(face[1] + face[3])), + max(0, int(face[0])), + ), } ) state.processed_frames += 1 @@ -170,9 +186,10 @@ def batch_size(self, config: IndexConfig) -> int: def prepare( self, config: IndexConfig, + runtime: ModelRuntime, progress: ProgressCallback | None, ) -> ActorIndexState: - return ActorIndexState() + return ActorIndexState(models=get_actor_models(runtime)) def process( self, diff --git a/src/vidxp/capabilities/actor/models.py b/src/vidxp/capabilities/actor/models.py new file mode 100644 index 0000000..556dce1 --- /dev/null +++ b/src/vidxp/capabilities/actor/models.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from vidxp.runtime import ModelKey, ModelRuntime + + +OPENCV_ZOO_REVISION = "47534e27c9851bb1128ccc0102f1145e27f23f98" +YUNET_FILE = "face_detection_yunet_2026may.onnx" +YUNET_SHA256 = "ebafce4e3c118d6554634be5c27ab333b4c047a9a8c3faf1d7cf93101c22f0f0" +SFACE_FILE = "face_recognition_sface_2021dec.onnx" +SFACE_SHA256 = "0ba9fbfa01b5270c96627c4ef784da859931e02f04419c829e83484087c34e79" +_RAW_ROOT = ( + "https://raw.githubusercontent.com/opencv/opencv_zoo/" + f"{OPENCV_ZOO_REVISION}/models" +) + + +@dataclass(frozen=True) +class ActorModels: + detector: Any + recognizer: Any + + +def _model_path( + runtime: ModelRuntime, + *, + relative_url: str, + filename: str, + sha256: str, +) -> str: + import pooch + + destination = runtime.settings.model_cache / "opencv-zoo" + if not runtime.settings.allow_model_downloads: + path = destination / filename + if not path.is_file(): + raise FileNotFoundError( + f"Model {filename} is not cached and downloads are disabled." + ) + if pooch.file_hash(path, alg="sha256") != sha256: + raise RuntimeError(f"Cached model checksum failed: {filename}.") + return str(path) + return pooch.retrieve( + url=f"{_RAW_ROOT}/{relative_url}/{filename}", + known_hash=f"sha256:{sha256}", + fname=filename, + path=destination, + progressbar=False, + ) + + +def get_actor_models(runtime: ModelRuntime) -> ActorModels: + key = ModelKey( + "actor", + "opencv-zoo", + "yunet+sface", + OPENCV_ZOO_REVISION, + "cpu", + ) + + def load() -> ActorModels: + import cv2 + + detector_path = _model_path( + runtime, + relative_url="face_detection_yunet", + filename=YUNET_FILE, + sha256=YUNET_SHA256, + ) + recognizer_path = _model_path( + runtime, + relative_url="face_recognition_sface", + filename=SFACE_FILE, + sha256=SFACE_SHA256, + ) + return ActorModels( + detector=cv2.FaceDetectorYN.create( + detector_path, + "", + (320, 320), + score_threshold=0.9, + nms_threshold=0.3, + top_k=5000, + ), + recognizer=cv2.FaceRecognizerSF.create(recognizer_path, ""), + ) + + return runtime.get_or_load(key, load) diff --git a/src/vidxp/capabilities/actor/requirements.txt b/src/vidxp/capabilities/actor/requirements.txt index 383f8ac..a737e1f 100644 --- a/src/vidxp/capabilities/actor/requirements.txt +++ b/src/vidxp/capabilities/actor/requirements.txt @@ -1,3 +1,3 @@ -face-recognition -numpy>=2.1,<3 -opencv-python +numpy>=2.3,<3 +opencv-python-headless>=5.0.0.93,<6 +pooch>=1.9,<2 diff --git a/src/vidxp/capabilities/contracts.py b/src/vidxp/capabilities/contracts.py index d022cde..62a3e67 100644 --- a/src/vidxp/capabilities/contracts.py +++ b/src/vidxp/capabilities/contracts.py @@ -15,6 +15,10 @@ from vidxp.core.contracts import IndexConfig, VideoSource from vidxp.core.indexing_common import ProgressCallback +from vidxp.runtime import ModelRuntime + + +CAPABILITY_CONTRACT_VERSION = 1 class _ContractModel(BaseModel): @@ -76,6 +80,7 @@ class CapabilityContext(_ContractModel): """Runtime context shared by transport-neutral capability operations.""" config: IndexConfig | None + runtime: ModelRuntime def require_config(self) -> IndexConfig: if self.config is None: @@ -86,7 +91,7 @@ def require_config(self) -> IndexConfig: class PreparationContext(_ContractModel): """Runtime values supplied to one capability's preparation hook.""" - device: str = Field(min_length=1) + runtime: ModelRuntime settings: CapabilityConfig @@ -94,11 +99,10 @@ class PreparationContext(_ContractModel): class OperationDefinition(_ContractModel): - """Validated input, output, and implementation for one operation.""" + """Transport-neutral input and output metadata for one operation.""" input_model: type[BaseModel] output_model: type[BaseModel] - handler: OperationHandler requires_index: bool = True @field_validator("input_model", "output_model") @@ -111,16 +115,6 @@ def _require_model( raise ValueError("Operation schemas must be Pydantic models.") return value - def invoke( - self, - context: CapabilityContext, - payload: BaseModel | Mapping[str, Any], - ) -> BaseModel: - request = self.input_model.model_validate(payload) - result = self.handler(context, request) - return self.output_model.model_validate(result) - - class CapabilityIndexResult(_ContractModel): """Summary and timing data returned by an indexing handler.""" @@ -141,27 +135,21 @@ class CapabilityIndexResult(_ContractModel): [IndexConfig, tuple[VideoSource, ...]], Mapping[str, Any], ] -CLIFactory = Callable[[], Any] +ExecutorFactory = Callable[[], "CapabilityExecutor"] class CapabilityDefinition(_ContractModel): - """Everything the application needs to run one named capability.""" + """Domain metadata for one named capability.""" name: str = Field(min_length=1) description: str = Field(min_length=1) extra: str = Field(min_length=1) config_model: type[CapabilityConfig] = CapabilityConfig - runtime_checks: tuple[RuntimeCheck, ...] = () collection_name: str | None = None - indexer: IndexHandler | None = None - index_processor: Any | None = None index_stage: str | None = None + execution_group: str | None = None operations: Mapping[str, OperationDefinition] = Field(default_factory=dict) - requirement_filter: RequirementFilter | None = None - prepare: PrepareHandler | None = None - model_manifest: ModelManifest | None = None - cli_name: str | None = None - cli_factory: CLIFactory | None = None + prepares_models: bool = False @field_validator("config_model") @classmethod @@ -187,29 +175,45 @@ def _freeze_operations( return MappingProxyType(dict(value)) @model_validator(mode="after") - def _require_complete_integrations(self) -> CapabilityDefinition: + def _require_complete_metadata(self) -> CapabilityDefinition: indexing_fields = ( self.collection_name, - self.indexer, self.index_stage, + self.execution_group, ) if any(value is not None for value in indexing_fields) and not all( value is not None for value in indexing_fields ): raise ValueError( "Indexable capabilities must declare collection names, " - "an indexer, and an index stage together." + "an index stage, and an execution group together." ) - if (self.cli_name is None) != (self.cli_factory is None): + if self.collection_name is None and not self.operations: raise ValueError( - "cli_name and cli_factory must either both be set or both be unset." - ) - if self.indexer is None and not self.operations: - raise ValueError( - "A capability must provide an indexer or at least one operation." + "A capability must support indexing or at least one operation." ) return self + +class CapabilityExecutor(_ContractModel): + """Infrastructure hooks bound to one capability definition.""" + + indexer: IndexHandler | None = None + index_processor: Any | None = None + operations: Mapping[str, OperationHandler] = Field(default_factory=dict) + runtime_checks: tuple[RuntimeCheck, ...] = () + requirement_filter: RequirementFilter | None = None + prepare: PrepareHandler | None = None + model_manifest: ModelManifest | None = None + + @field_validator("operations") + @classmethod + def _freeze_operation_handlers( + cls, + value: Mapping[str, OperationHandler], + ) -> Mapping[str, OperationHandler]: + return MappingProxyType(dict(value)) + def source_requirements( self, source: VideoSource, @@ -220,5 +224,11 @@ def source_requirements( return self.requirement_filter(source, requirements) +class CapabilityPlugin(_ContractModel): + definition: CapabilityDefinition + executor_factory: ExecutorFactory + contract_version: int = CAPABILITY_CONTRACT_VERSION + + def capability_install_hint(name: str) -> str: return f'Install the capability with: pip install "vidxp[{name}]"' diff --git a/src/vidxp/capabilities/dialogue/config.py b/src/vidxp/capabilities/dialogue/config.py index 3999801..34e4f00 100644 --- a/src/vidxp/capabilities/dialogue/config.py +++ b/src/vidxp/capabilities/dialogue/config.py @@ -12,11 +12,21 @@ class DialogueConfig(CapabilityConfig): transcription_batch_size: int = Field(default=16, gt=0) normalize_embeddings: bool = True sentence_model: str = Field( - default="sentence-transformers/all-MiniLM-L6-v2", + default="Qwen/Qwen3-Embedding-0.6B", + min_length=1, + ) + sentence_revision: str = Field( + default="97b0c614be4d77ee51c0cef4e5f07c00f9eb65b3", + min_length=1, + ) + whisper_model: str = Field( + default="mobiuslabsgmbh/faster-whisper-large-v3-turbo", + min_length=1, + ) + whisper_revision: str = Field( + default="0a363e9161cbc7ed1431c9597a8ceaf0c4f78fcf", min_length=1, ) - whisper_model: str = Field(default="large-v2", min_length=1) - alignment_language: str | None = Field(default=None, min_length=1) def dialogue_config(config: IndexConfig) -> DialogueConfig: diff --git a/src/vidxp/capabilities/dialogue/definition.py b/src/vidxp/capabilities/dialogue/definition.py index f7682e5..d68d3a2 100644 --- a/src/vidxp/capabilities/dialogue/definition.py +++ b/src/vidxp/capabilities/dialogue/definition.py @@ -7,16 +7,13 @@ from vidxp.capabilities.contracts import ( CapabilityDefinition, + CapabilityExecutor, + CapabilityPlugin, OperationDefinition, PreparationContext, - RuntimeCheck, ) from vidxp.capabilities.dialogue.config import DialogueConfig, dialogue_config -from vidxp.capabilities.dialogue.models import ( - get_alignment_model, - get_embedder, - get_whisper_model, -) +from vidxp.capabilities.dialogue.models import get_embedder, get_whisper_model from vidxp.capabilities.dialogue.operations import ( index_capability, search_operation, @@ -24,16 +21,6 @@ from vidxp.capabilities.schemas import SearchInput, SearchResult from vidxp.core.contracts import IndexConfig, VideoSource from vidxp.core.indexing_common import ProgressCallback -from vidxp.core.video import ffmpeg_binary - - -FFMPEG = RuntimeCheck( - label="FFmpeg", - check=ffmpeg_binary, - applies_to=lambda source: source.transcript is None, -) - - def filter_requirements_for_source( source: VideoSource, requirements: tuple[Requirement, ...], @@ -69,23 +56,22 @@ def report(stage: str, message: str) -> None: "dialogue_model", f"Preparing dialogue model: {settings.sentence_model}", ) - get_embedder(settings.sentence_model, context.device) + get_embedder( + context.runtime, + settings.sentence_model, + settings.sentence_revision, + ) prepared.append(settings.sentence_model) report( "transcription_model", - f"Preparing transcription model: WhisperX {settings.whisper_model}", + f"Preparing transcription model: faster-whisper {settings.whisper_model}", + ) + get_whisper_model( + context.runtime, + settings.whisper_model, + settings.whisper_revision, ) - get_whisper_model(settings.whisper_model, context.device) prepared.append(settings.whisper_model) - if settings.alignment_language: - report( - "alignment_model", - f"Preparing the {settings.alignment_language} alignment model.", - ) - get_alignment_model(settings.alignment_language, context.device) - prepared.append( - f"whisperx-alignment:{settings.alignment_language}" - ) return tuple(prepared) @@ -94,9 +80,19 @@ def model_manifest( sources: tuple[VideoSource, ...], ) -> Mapping[str, Any]: settings = dialogue_config(config) - result: dict[str, Any] = {"dialogue": settings.sentence_model} + result: dict[str, Any] = { + "dialogue": { + "provider": "sentence-transformers", + "model": settings.sentence_model, + "revision": settings.sentence_revision, + } + } if any(source.transcript is None for source in sources): - result["transcription"] = settings.whisper_model + result["transcription"] = { + "provider": "faster-whisper", + "model": settings.whisper_model, + "revision": settings.whisper_revision, + } return result @@ -106,17 +102,29 @@ def model_manifest( extra="dialogue", config_model=DialogueConfig, collection_name="dialogue", - indexer=index_capability, index_stage="dialogue_indexing", - runtime_checks=(FFMPEG,), - requirement_filter=filter_requirements_for_source, - prepare=prepare_models, - model_manifest=model_manifest, + execution_group="dialogue", + prepares_models=True, operations={ "search": OperationDefinition( input_model=SearchInput, output_model=SearchResult, - handler=search_operation, ) }, ) + + +def create_executor() -> CapabilityExecutor: + return CapabilityExecutor( + indexer=index_capability, + operations={"search": search_operation}, + requirement_filter=filter_requirements_for_source, + prepare=prepare_models, + model_manifest=model_manifest, + ) + + +PLUGIN = CapabilityPlugin( + definition=DEFINITION, + executor_factory=create_executor, +) diff --git a/src/vidxp/capabilities/dialogue/indexing.py b/src/vidxp/capabilities/dialogue/indexing.py index 4e5201c..fb5c1cf 100644 --- a/src/vidxp/capabilities/dialogue/indexing.py +++ b/src/vidxp/capabilities/dialogue/indexing.py @@ -1,16 +1,11 @@ from __future__ import annotations -import hashlib from dataclasses import dataclass from pathlib import Path from typing import Any, Mapping, Sequence from vidxp.capabilities.dialogue.config import dialogue_config -from vidxp.capabilities.dialogue.models import ( - get_alignment_model, - get_embedder, - get_whisper_model, -) +from vidxp.capabilities.dialogue.models import get_embedder, get_whisper_model from vidxp.core.contracts import ( CancellationToken, IndexConfig, @@ -20,7 +15,7 @@ ) from vidxp.core.indexing_common import ProgressCallback, report_progress from vidxp.core.storage import IndexStorage -from vidxp.core.video import extract_audio +from vidxp.runtime import ModelRuntime @dataclass(frozen=True) @@ -118,73 +113,57 @@ def transcribe_video( input_path: str | Path, *, config: IndexConfig, - work_directory: str | Path, cancellation: CancellationToken, + runtime: ModelRuntime, progress: ProgressCallback | None, ) -> tuple[list[Mapping[str, Any]], str]: - import whisperx + from faster_whisper import BatchedInferencePipeline settings = dialogue_config(config) cancellation.raise_if_cancelled() - audio_name = hashlib.sha256( - str(config.video_id).encode("utf-8") - ).hexdigest() - audio_path = Path(work_directory) / f"{audio_name}.wav" report_progress( progress, - "extracting_audio", - "Extracting audio from the video.", + "preparing_transcription_model", + f"Preparing transcription model: faster-whisper {settings.whisper_model}.", ) - extract_audio(input_path, audio_path) - try: - cancellation.raise_if_cancelled() - report_progress( - progress, - "preparing_transcription_model", - f"Preparing transcription model: WhisperX {settings.whisper_model}.", - ) - whisper_model = get_whisper_model( - settings.whisper_model, - config.device, - ) - audio = whisperx.load_audio(str(audio_path)) - report_progress( - progress, - "transcribing_audio", - "Transcribing the video audio.", - ) - transcription = whisper_model.transcribe( - audio, - batch_size=settings.transcription_batch_size, - ) - language = str(transcription["language"]) - + whisper_model = get_whisper_model( + runtime, + settings.whisper_model, + settings.whisper_revision, + ) + report_progress( + progress, + "transcribing_audio", + "Transcribing and timestamping the video audio.", + ) + segments, info = BatchedInferencePipeline( + model=whisper_model + ).transcribe( + str(input_path), + batch_size=settings.transcription_batch_size, + word_timestamps=True, + vad_filter=True, + ) + result = [] + for segment in segments: cancellation.raise_if_cancelled() - report_progress( - progress, - "preparing_alignment_model", - f"Preparing the {language} alignment model.", + result.append( + { + "text": segment.text, + "start": segment.start, + "end": segment.end, + "words": [ + { + "word": word.word, + "start": word.start, + "end": word.end, + } + for word in (segment.words or ()) + if word.start is not None and word.end is not None + ], + } ) - alignment_model, alignment_metadata = get_alignment_model( - language, - config.device, - ) - report_progress( - progress, - "aligning_audio", - "Aligning transcript timestamps.", - ) - aligned = whisperx.align( - transcription["segments"], - alignment_model, - alignment_metadata, - audio, - config.device, - return_char_alignments=False, - ) - return list(aligned["segments"]), language - finally: - audio_path.unlink(missing_ok=True) + return result, str(info.language) def _dialogue_records( @@ -223,6 +202,7 @@ def index_dialogue( config: IndexConfig, storage: IndexStorage, cancellation: CancellationToken, + runtime: ModelRuntime, progress: ProgressCallback | None = None, ) -> dict[str, Any]: if config.video_id is None: @@ -240,8 +220,8 @@ def index_dialogue( segments, language = transcribe_video( source.path, config=config, - work_directory=config.run_directory / "work", cancellation=cancellation, + runtime=runtime, progress=progress, ) @@ -259,7 +239,11 @@ def index_dialogue( 0, len(phrases), ) - encoder = get_embedder(settings.sentence_model, config.device) + encoder = get_embedder( + runtime, + settings.sentence_model, + settings.sentence_revision, + ) report_progress( progress, "dialogue_indexing", diff --git a/src/vidxp/capabilities/dialogue/models.py b/src/vidxp/capabilities/dialogue/models.py index 449d50a..72011f5 100644 --- a/src/vidxp/capabilities/dialogue/models.py +++ b/src/vidxp/capabilities/dialogue/models.py @@ -1,30 +1,62 @@ from __future__ import annotations -from functools import lru_cache - - -@lru_cache -def get_embedder(model_name: str, device: str): - from sentence_transformers import SentenceTransformer - - return SentenceTransformer(model_name, device=device) - - -@lru_cache -def get_whisper_model(model_name: str, device: str): - import whisperx - - return whisperx.load_model(model_name, device, compute_type="float32") - - -@lru_cache -def get_alignment_model(language: str, device: str): - import whisperx - - return whisperx.load_align_model(language_code=language, device=device) - - -def clear_model_cache() -> None: - get_embedder.cache_clear() - get_whisper_model.cache_clear() - get_alignment_model.cache_clear() +from typing import Any + +from vidxp.runtime import ModelKey, ModelRuntime + + +def get_embedder( + runtime: ModelRuntime, + model_id: str, + revision: str, +) -> Any: + device = runtime.device_for("dialogue.embedding") + key = ModelKey("dialogue", "sentence-transformers", model_id, revision, device) + + def load() -> Any: + from sentence_transformers import SentenceTransformer + + return SentenceTransformer( + model_id, + device=device, + cache_folder=str(runtime.settings.model_cache), + revision=revision, + local_files_only=not runtime.settings.allow_model_downloads, + ) + + return runtime.get_or_load(key, load) + + +def get_whisper_model( + runtime: ModelRuntime, + model_id: str, + revision: str, +) -> Any: + device = runtime.device_for("dialogue.transcription") + compute_type = "float16" if device.startswith("cuda") else "int8" + key = ModelKey( + "dialogue", + "faster-whisper", + model_id, + revision, + f"{device}:{compute_type}", + ) + + def load() -> Any: + from faster_whisper import WhisperModel + + return WhisperModel( + model_id, + device=device.split(":", 1)[0], + device_index=( + int(device.split(":", 1)[1]) + if ":" in device + else 0 + ), + compute_type=compute_type, + download_root=str(runtime.settings.model_cache), + local_files_only=not runtime.settings.allow_model_downloads, + revision=revision, + ) + + return runtime.get_or_load(key, load) diff --git a/src/vidxp/capabilities/dialogue/operations.py b/src/vidxp/capabilities/dialogue/operations.py index 9fd98e3..6036fbe 100644 --- a/src/vidxp/capabilities/dialogue/operations.py +++ b/src/vidxp/capabilities/dialogue/operations.py @@ -6,6 +6,7 @@ CapabilityContext, CapabilityIndexResult, ) +from vidxp.capabilities.registry import CapabilityRegistry from vidxp.capabilities.dialogue.config import dialogue_config from vidxp.capabilities.dialogue.indexing import index_dialogue from vidxp.capabilities.dialogue.models import get_embedder @@ -18,6 +19,7 @@ ) from vidxp.core.indexing_common import ProgressCallback from vidxp.core.storage import IndexStorage +from vidxp.runtime import ModelRuntime REQUIRED_METADATA = frozenset( @@ -42,6 +44,8 @@ def index_capability( config: IndexConfig, storage: IndexStorage, cancellation: CancellationToken, + registry: CapabilityRegistry, + runtime: ModelRuntime, progress: ProgressCallback | None = None, modalities: tuple[str, ...] = ("dialogue",), ) -> CapabilityIndexResult: @@ -54,15 +58,28 @@ def index_capability( storage=storage, cancellation=cancellation, progress=progress, + runtime=runtime, ) ) -def dialogue_embedding(query: str, config: IndexConfig) -> list[float]: +def dialogue_embedding( + query: str, + config: IndexConfig, + runtime: ModelRuntime, +) -> list[float]: settings = dialogue_config(config) - encoder = get_embedder(settings.sentence_model, config.device) + encoder = get_embedder( + runtime, + settings.sentence_model, + settings.sentence_revision, + ) encoded = encoder.encode( [query], + prompt=( + "Instruct: Retrieve spoken video passages relevant to the query\n" + "Query: " + ), convert_to_numpy=True, normalize_embeddings=settings.normalize_embeddings, ) @@ -73,6 +90,7 @@ def search_dialogue( query: str, *, config: IndexConfig, + runtime: ModelRuntime, top_k: int = 10, video_id: str | None = None, query_id: str | None = None, @@ -87,7 +105,7 @@ def search_dialogue( return search_embeddings( cleaned, "dialogue", - dialogue_embedding(cleaned, config), + dialogue_embedding(cleaned, config, runtime), config=config, required_metadata=REQUIRED_METADATA, top_k=top_k, @@ -108,4 +126,5 @@ def search_operation( config=config, top_k=request.top_k, video_id=config.video_id, + runtime=context.runtime, ) diff --git a/src/vidxp/capabilities/dialogue/requirements.txt b/src/vidxp/capabilities/dialogue/requirements.txt index c97c911..b8b3993 100644 --- a/src/vidxp/capabilities/dialogue/requirements.txt +++ b/src/vidxp/capabilities/dialogue/requirements.txt @@ -1,3 +1,2 @@ -moviepy==1.0.3 -sentence-transformers>=3.4,<4 -whisperx>=3.8.6,<3.9 +faster-whisper>=1.2.1,<2 +sentence-transformers>=5.6.1,<6 diff --git a/src/vidxp/capabilities/registry.py b/src/vidxp/capabilities/registry.py index 2a7a633..f83a1fd 100644 --- a/src/vidxp/capabilities/registry.py +++ b/src/vidxp/capabilities/registry.py @@ -1,19 +1,21 @@ from __future__ import annotations +from importlib.metadata import EntryPoint, entry_points +from threading import RLock from types import MappingProxyType from typing import Any, Iterable, Mapping from packaging.requirements import Requirement from packaging.utils import canonicalize_name -from vidxp.capabilities.actor.definition import DEFINITION as ACTOR from vidxp.capabilities.contracts import ( + CAPABILITY_CONTRACT_VERSION, CapabilityDefinition, + CapabilityExecutor, + CapabilityPlugin, RuntimeCheck, capability_install_hint, ) -from vidxp.capabilities.dialogue.definition import DEFINITION as DIALOGUE -from vidxp.capabilities.scene.definition import DEFINITION as SCENE from vidxp.core.contracts import VideoSource from vidxp.dependencies import ( active_requirements, @@ -23,187 +25,280 @@ ) -_BUILT_INS = (DIALOGUE, SCENE, ACTOR) -CAPABILITIES = MappingProxyType( - {capability.name: capability for capability in _BUILT_INS} -) +ENTRY_POINT_GROUP = "vidxp.capabilities" -if len(CAPABILITIES) != len(_BUILT_INS): - raise RuntimeError("Capability names must be unique.") +class CapabilityRegistry: + """Validated capability metadata with lazily constructed executors.""" -def capability_names() -> tuple[str, ...]: - return tuple(CAPABILITIES) + def __init__(self, plugins: Iterable[CapabilityPlugin]) -> None: + indexed: dict[str, CapabilityPlugin] = {} + for plugin in plugins: + if plugin.contract_version != CAPABILITY_CONTRACT_VERSION: + raise RuntimeError( + f"Capability {plugin.definition.name!r} targets contract " + f"{plugin.contract_version}; VidXP requires " + f"{CAPABILITY_CONTRACT_VERSION}." + ) + name = plugin.definition.name + if name in indexed: + raise RuntimeError(f"Duplicate capability name: {name!r}.") + indexed[name] = plugin + self._plugins = MappingProxyType(indexed) + self._executors: dict[str, CapabilityExecutor] = {} + self._executor_lock = RLock() + @property + def definitions(self) -> Mapping[str, CapabilityDefinition]: + return MappingProxyType( + {name: plugin.definition for name, plugin in self._plugins.items()} + ) -def index_capability_names() -> tuple[str, ...]: - return tuple( - name - for name, capability in CAPABILITIES.items() - if capability.indexer is not None - ) + def names(self) -> tuple[str, ...]: + return tuple(self._plugins) + def index_names(self) -> tuple[str, ...]: + return tuple( + name + for name, definition in self.definitions.items() + if definition.collection_name is not None + ) -def preparable_capability_names() -> tuple[str, ...]: - return tuple( - name - for name, capability in CAPABILITIES.items() - if capability.prepare is not None - ) + def preparable_names(self) -> tuple[str, ...]: + return tuple( + name + for name, definition in self.definitions.items() + if definition.prepares_models + ) + def get(self, name: str) -> CapabilityDefinition: + try: + return self._plugins[name].definition + except KeyError as exc: + available = ", ".join(self.names()) + raise ValueError( + f"Unknown capability {name!r}. " + f"Available capabilities: {available}." + ) from exc -def get_capability(name: str) -> CapabilityDefinition: - try: - return CAPABILITIES[name] - except KeyError as exc: - available = ", ".join(capability_names()) - raise ValueError( - f"Unknown capability {name!r}. Available capabilities: {available}." - ) from exc - - -def validate_capability_names(names: Iterable[str]) -> tuple[str, ...]: - selected = tuple(dict.fromkeys(str(name).strip() for name in names)) - if not selected: - raise ValueError("At least one capability is required.") - for name in selected: - get_capability(name) - return selected - - -def collection_names( - names: Iterable[str] | None = None, -) -> dict[str, str]: - selected = ( - index_capability_names() - if names is None - else validate_capability_names(names) - ) - return { - name: get_capability(name).collection_name - for name in selected - if get_capability(name).indexer is not None - } - - -def validate_capability_options( - names: Iterable[str], - options: Mapping[str, Mapping[str, Any]] | None, -) -> dict[str, dict[str, Any]]: - selected = validate_capability_names(names) - supplied = dict(options or {}) - unknown = sorted(set(supplied) - set(selected)) - if unknown: - raise ValueError( - "Options were supplied for disabled capabilities: " - + ", ".join(unknown) - ) - return { - name: get_capability(name) - .config_model.model_validate(supplied.get(name, {})) - .model_dump(mode="python") - for name in selected - } + def executor(self, name: str) -> CapabilityExecutor: + definition = self.get(name) + with self._executor_lock: + if name not in self._executors: + executor = self._plugins[name].executor_factory() + operation_names = set(definition.operations) + if set(executor.operations) != operation_names: + raise RuntimeError( + f"Capability {name!r} operation handlers do not match " + "its declared operations." + ) + if (executor.indexer is not None) != ( + definition.collection_name is not None + ): + raise RuntimeError( + f"Capability {name!r} indexing metadata and executor " + "do not match." + ) + if (executor.prepare is not None) != definition.prepares_models: + raise RuntimeError( + f"Capability {name!r} preparation metadata and executor " + "do not match." + ) + self._executors[name] = executor + return self._executors[name] + def validate_names(self, names: Iterable[str]) -> tuple[str, ...]: + selected = tuple(dict.fromkeys(str(name).strip() for name in names)) + if not selected: + raise ValueError("At least one capability is required.") + for name in selected: + self.get(name) + return selected -def requirements_for( - names: Iterable[str], - *, - source: VideoSource | None = None, -) -> tuple[Requirement, ...]: - selected = tuple( - get_capability(name) - for name in validate_capability_names(names) - ) - requirements = list( - active_requirements( - packaged_requirements( - "vidxp", - "requirements/storage.txt", + def collection_names( + self, + names: Iterable[str] | None = None, + ) -> dict[str, str]: + selected = self.index_names() if names is None else self.validate_names(names) + return { + name: collection_name + for name in selected + if (collection_name := self.get(name).collection_name) is not None + } + + def validate_options( + self, + names: Iterable[str], + options: Mapping[str, Mapping[str, Any]] | None, + ) -> dict[str, dict[str, Any]]: + selected = self.validate_names(names) + supplied = dict(options or {}) + unknown = sorted(set(supplied) - set(selected)) + if unknown: + raise ValueError( + "Options were supplied for disabled capabilities: " + + ", ".join(unknown) ) + return { + name: self.get(name) + .config_model.model_validate(supplied.get(name, {})) + .model_dump(mode="python") + for name in selected + } + + def requirements_for( + self, + names: Iterable[str], + *, + source: VideoSource | None = None, + ) -> tuple[Requirement, ...]: + selected = self.validate_names(names) + requirements = list( + active_requirements( + packaged_requirements("vidxp", "requirements/storage.txt") + ) + if any(self.get(name).collection_name for name in selected) + else () ) - if any(capability.indexer is not None for capability in selected) - else () - ) - for capability in selected: - capability_requirements = active_requirements( - packaged_requirements( - f"vidxp.capabilities.{capability.name}" + for name in selected: + capability_requirements = active_requirements( + packaged_requirements(f"vidxp.capabilities.{name}") ) + executor = self.executor(name) + requirements.extend( + capability_requirements + if source is None + else executor.source_requirements( + source, + capability_requirements, + ) + ) + unique = {str(requirement): requirement for requirement in requirements} + return tuple(unique.values()) + + def runtime_checks_for( + self, + names: Iterable[str], + *, + source: VideoSource | None = None, + ) -> tuple[RuntimeCheck, ...]: + checks = ( + check + for name in self.validate_names(names) + for check in self.executor(name).runtime_checks + if check.applies(source) ) - requirements.extend( - capability_requirements - if source is None - else capability.source_requirements( - source, - capability_requirements, + return tuple({check.label: check for check in checks}.values()) + + def dependency_checks( + self, + names: Iterable[str], + ) -> tuple[dict[str, Any], ...]: + return tuple( + inspect_requirement(requirement) + for requirement in self.requirements_for(names) + ) + tuple( + check.inspect() + for check in self.runtime_checks_for(names) + ) + + def require_dependencies( + self, + names: Iterable[str], + *, + source: VideoSource, + ) -> None: + selected = self.validate_names(names) + failures = [ + result + for requirement in self.requirements_for(selected, source=source) + if not (result := inspect_requirement(requirement))["ok"] + ] + failures.extend( + result + for check in self.runtime_checks_for(selected, source=source) + if not (result := check.inspect())["ok"] + ) + if failures: + details = "; ".join( + f"{failure['name']}: {failure['error']}" + for failure in failures + ) + extras = ",".join(self.get(name).extra for name in selected) + raise RuntimeError( + f"Capability dependencies are unavailable: {details}. " + + capability_install_hint(extras) ) + + def runtime_distributions(self) -> tuple[str, ...]: + distributions = { + canonicalize_name(requirement.name) + for requirement in installed_base_requirements() + } + distributions.update( + canonicalize_name(requirement.name) + for requirement in self.requirements_for(self.names()) ) - unique = {str(requirement): requirement for requirement in requirements} - return tuple(unique.values()) + return tuple(sorted(distributions, key=str.lower)) -def runtime_checks_for( - names: Iterable[str], - *, - source: VideoSource | None = None, -) -> tuple[RuntimeCheck, ...]: - checks = ( - check - for name in validate_capability_names(names) - for check in get_capability(name).runtime_checks - if check.applies(source) - ) - return tuple({check.label: check for check in checks}.values()) +def _builtin_plugins() -> tuple[CapabilityPlugin, ...]: + from vidxp.capabilities.actor.definition import PLUGIN as actor + from vidxp.capabilities.dialogue.definition import PLUGIN as dialogue + from vidxp.capabilities.scene.definition import PLUGIN as scene + + return dialogue, scene, actor -def dependency_checks(names: Iterable[str]) -> tuple[dict, ...]: +def _external_entry_points(allowlist: tuple[str, ...]) -> tuple[EntryPoint, ...]: + allowed = {canonicalize_name(value) for value in allowlist} + candidates = entry_points(group=ENTRY_POINT_GROUP) return tuple( - inspect_requirement(requirement) - for requirement in requirements_for(names) - ) + tuple( - check.inspect() - for check in runtime_checks_for(names) + sorted( + ( + entry_point + for entry_point in candidates + if canonicalize_name(entry_point.name) in allowed + or ( + entry_point.dist is not None + and canonicalize_name(entry_point.dist.name) in allowed + ) + ), + key=lambda item: ( + canonicalize_name( + item.dist.name if item.dist is not None else "" + ), + canonicalize_name(item.name), + ), + ) ) -def require_dependencies( - names: Iterable[str], +def create_capability_registry( *, - source: VideoSource, -) -> None: - selected = validate_capability_names(names) - failures = [ - result - for requirement in requirements_for(selected, source=source) - if not (result := inspect_requirement(requirement))["ok"] - ] - failures.extend( - result - for check in runtime_checks_for(selected, source=source) - if not (result := check.inspect())["ok"] - ) - if failures: - details = "; ".join( - f"{failure['name']}: {failure['error']}" - for failure in failures - ) - extras = ",".join( - get_capability(name).extra for name in selected - ) - raise RuntimeError( - f"Capability dependencies are unavailable: {details}. " - + capability_install_hint(extras) - ) - - -def runtime_distributions() -> tuple[str, ...]: - distributions = { - canonicalize_name(requirement.name) - for requirement in installed_base_requirements() - } - distributions.update( - canonicalize_name(requirement.name) - for requirement in requirements_for(capability_names()) - ) - return tuple(sorted(distributions, key=str.lower)) + external: bool = False, + allowlist: tuple[str, ...] = (), +) -> CapabilityRegistry: + plugins = list(_builtin_plugins()) + if external: + for entry_point in _external_entry_points(allowlist): + try: + loaded = entry_point.load() + plugin = ( + loaded() + if callable(loaded) + and not isinstance(loaded, CapabilityPlugin) + else loaded + ) + except Exception as exc: + raise RuntimeError( + f"Could not load capability entry point " + f"{entry_point.name!r}." + ) from exc + if not isinstance(plugin, CapabilityPlugin): + raise RuntimeError( + f"Capability entry point {entry_point.name!r} did not " + "return a CapabilityPlugin." + ) + plugins.append(plugin) + return CapabilityRegistry(plugins) diff --git a/src/vidxp/capabilities/scene/config.py b/src/vidxp/capabilities/scene/config.py index 87b2e34..fb38c26 100644 --- a/src/vidxp/capabilities/scene/config.py +++ b/src/vidxp/capabilities/scene/config.py @@ -8,7 +8,14 @@ class SceneConfig(CapabilityConfig): batch_size: int = Field(default=32, gt=0) - model: str = Field(default="ViT-B/32", min_length=1) + model: str = Field( + default="google/siglip2-base-patch16-224", + min_length=1, + ) + revision: str = Field( + default="75de2d55ec2d0b4efc50b3e9ad70dba96a7b2fa2", + min_length=1, + ) def scene_config(config: IndexConfig) -> SceneConfig: diff --git a/src/vidxp/capabilities/scene/definition.py b/src/vidxp/capabilities/scene/definition.py index 6deaf99..69fe0d4 100644 --- a/src/vidxp/capabilities/scene/definition.py +++ b/src/vidxp/capabilities/scene/definition.py @@ -4,12 +4,14 @@ from vidxp.capabilities.contracts import ( CapabilityDefinition, + CapabilityExecutor, + CapabilityPlugin, OperationDefinition, PreparationContext, ) from vidxp.capabilities.scene.config import SceneConfig, scene_config from vidxp.capabilities.scene.indexing import VISUAL_PROCESSOR -from vidxp.capabilities.scene.models import get_clip_model +from vidxp.capabilities.scene.models import get_scene_model from vidxp.capabilities.scene.operations import search_operation from vidxp.capabilities.schemas import SearchInput, SearchResult from vidxp.capabilities.visual import index_capabilities @@ -27,11 +29,15 @@ def prepare_models( "state": "preparing", "stage": "scene_model", "message": ( - f"Preparing scene model: CLIP {settings.model}" + f"Preparing scene model: SigLIP2 {settings.model}" ), } ) - get_clip_model(settings.model, context.device) + get_scene_model( + context.runtime, + settings.model, + settings.revision, + ) return (settings.model,) @@ -39,7 +45,14 @@ def model_manifest( config: IndexConfig, _sources: tuple[VideoSource, ...], ) -> Mapping[str, Any]: - return {"scene": scene_config(config).model} + settings = scene_config(config) + return { + "scene": { + "provider": "transformers", + "model": settings.model, + "revision": settings.revision, + } + } DEFINITION = CapabilityDefinition( @@ -48,16 +61,29 @@ def model_manifest( extra="scene", config_model=SceneConfig, collection_name="scene", - indexer=index_capabilities, - index_processor=VISUAL_PROCESSOR, index_stage="visual_indexing", - prepare=prepare_models, - model_manifest=model_manifest, + execution_group="visual", + prepares_models=True, operations={ "search": OperationDefinition( input_model=SearchInput, output_model=SearchResult, - handler=search_operation, ) }, ) + + +def create_executor() -> CapabilityExecutor: + return CapabilityExecutor( + indexer=index_capabilities, + index_processor=VISUAL_PROCESSOR, + operations={"search": search_operation}, + prepare=prepare_models, + model_manifest=model_manifest, + ) + + +PLUGIN = CapabilityPlugin( + definition=DEFINITION, + executor_factory=create_executor, +) diff --git a/src/vidxp/capabilities/scene/indexing.py b/src/vidxp/capabilities/scene/indexing.py index 33cb1a0..e501b00 100644 --- a/src/vidxp/capabilities/scene/indexing.py +++ b/src/vidxp/capabilities/scene/indexing.py @@ -4,7 +4,7 @@ from typing import Any from vidxp.capabilities.scene.config import scene_config -from vidxp.capabilities.scene.models import get_clip_model +from vidxp.capabilities.scene.models import SceneModel, get_scene_model from vidxp.core.contracts import ( CancellationToken, IndexConfig, @@ -14,25 +14,27 @@ ) from vidxp.core.indexing_common import ProgressCallback, report_progress from vidxp.core.storage import IndexStorage +from vidxp.runtime import ModelRuntime @dataclass class SceneIndexState: - model: Any - preprocess: Any + provider: SceneModel stored_frames: int = 0 -def encode_scene_batch(samples, model, preprocess, device): +def encode_scene_batch(samples, provider: SceneModel): import torch from PIL import Image - images = torch.stack( - [preprocess(Image.fromarray(sample.frame)) for sample in samples] - ).to(device) - with torch.no_grad(): - features = model.encode_image(images) - features /= features.norm(dim=-1, keepdim=True) + inputs = provider.processor( + images=[Image.fromarray(sample.frame) for sample in samples], + return_tensors="pt", + ) + inputs = {name: value.to(provider.device) for name, value in inputs.items()} + with torch.inference_mode(): + features = provider.model.get_image_features(**inputs) + features = torch.nn.functional.normalize(features, dim=-1) return features.cpu().numpy().tolist() @@ -88,9 +90,7 @@ def process_scene_samples( cancellation.raise_if_cancelled() vectors = encode_scene_batch( group, - state.model, - state.preprocess, - config.device, + state.provider, ) state.stored_frames += storage.upsert( "scene", @@ -107,16 +107,21 @@ def batch_size(self, config: IndexConfig) -> int: def prepare( self, config: IndexConfig, + runtime: ModelRuntime, progress: ProgressCallback | None, ) -> SceneIndexState: settings = scene_config(config) report_progress( progress, "preparing_scene_model", - f"Preparing scene model: CLIP {settings.model}.", + f"Preparing scene model: SigLIP2 {settings.model}.", ) - model, preprocess = get_clip_model(settings.model, config.device) - return SceneIndexState(model, preprocess) + provider = get_scene_model( + runtime, + settings.model, + settings.revision, + ) + return SceneIndexState(provider) def process( self, diff --git a/src/vidxp/capabilities/scene/models.py b/src/vidxp/capabilities/scene/models.py index 380f9f1..9714abd 100644 --- a/src/vidxp/capabilities/scene/models.py +++ b/src/vidxp/capabilities/scene/models.py @@ -1,14 +1,40 @@ from __future__ import annotations -from functools import lru_cache +from dataclasses import dataclass +from typing import Any +from vidxp.runtime import ModelKey, ModelRuntime -@lru_cache -def get_clip_model(model_name: str, device: str): - import clip - return clip.load(model_name, device=device) +@dataclass(frozen=True) +class SceneModel: + model: Any + processor: Any + device: str -def clear_model_cache() -> None: - get_clip_model.cache_clear() +def get_scene_model( + runtime: ModelRuntime, + model_id: str, + revision: str, +) -> SceneModel: + device = runtime.device_for("scene") + key = ModelKey("scene", "transformers", model_id, revision, device) + + def load() -> SceneModel: + from transformers import AutoModel, AutoProcessor + + common = { + "cache_dir": str(runtime.settings.model_cache), + "revision": revision, + "local_files_only": not runtime.settings.allow_model_downloads, + } + model = AutoModel.from_pretrained(model_id, **common).to(device) + model.eval() + return SceneModel( + model=model, + processor=AutoProcessor.from_pretrained(model_id, **common), + device=device, + ) + + return runtime.get_or_load(key, load) diff --git a/src/vidxp/capabilities/scene/operations.py b/src/vidxp/capabilities/scene/operations.py index be04be7..b01047d 100644 --- a/src/vidxp/capabilities/scene/operations.py +++ b/src/vidxp/capabilities/scene/operations.py @@ -4,11 +4,12 @@ from vidxp.capabilities.contracts import CapabilityContext from vidxp.capabilities.scene.config import scene_config -from vidxp.capabilities.scene.models import get_clip_model +from vidxp.capabilities.scene.models import get_scene_model from vidxp.capabilities.schemas import SearchInput, SearchResult from vidxp.capabilities.search import search_embeddings from vidxp.core.contracts import IndexConfig from vidxp.core.storage import IndexStorage +from vidxp.runtime import ModelRuntime REQUIRED_METADATA = frozenset( @@ -29,16 +30,24 @@ ) -def scene_embedding(query: str, config: IndexConfig) -> list[float]: - import clip +def scene_embedding( + query: str, + config: IndexConfig, + runtime: ModelRuntime, +) -> list[float]: import torch settings = scene_config(config) - model, _ = get_clip_model(settings.model, config.device) - tokens = clip.tokenize([query]).to(config.device) - with torch.no_grad(): - features = model.encode_text(tokens) - features /= features.norm(dim=-1, keepdim=True) + provider = get_scene_model(runtime, settings.model, settings.revision) + inputs = provider.processor( + text=[query], + padding="max_length", + return_tensors="pt", + ) + inputs = {name: value.to(provider.device) for name, value in inputs.items()} + with torch.inference_mode(): + features = provider.model.get_text_features(**inputs) + features = torch.nn.functional.normalize(features, dim=-1) return features.cpu().numpy().tolist()[0] @@ -46,6 +55,7 @@ def search_scene( query: str, *, config: IndexConfig, + runtime: ModelRuntime, top_k: int = 10, video_id: str | None = None, query_id: str | None = None, @@ -60,7 +70,7 @@ def search_scene( return search_embeddings( cleaned, "scene", - scene_embedding(cleaned, config), + scene_embedding(cleaned, config, runtime), config=config, required_metadata=REQUIRED_METADATA, top_k=top_k, @@ -81,4 +91,5 @@ def search_operation( config=config, top_k=request.top_k, video_id=config.video_id, + runtime=context.runtime, ) diff --git a/src/vidxp/capabilities/scene/requirements.txt b/src/vidxp/capabilities/scene/requirements.txt index 213eddc..44473b3 100644 --- a/src/vidxp/capabilities/scene/requirements.txt +++ b/src/vidxp/capabilities/scene/requirements.txt @@ -1,7 +1,5 @@ -clip-anytorch==2.6.0 -numpy>=2.1,<3 -opencv-python -Pillow>=7.0.0 -# clip-anytorch still imports pkg_resources, removed in setuptools 81. -setuptools<81 -torch +numpy>=2.3,<3 +opencv-python-headless>=5.0.0.93,<6 +Pillow>=12.3,<13 +torch>=2.13,<3 +transformers>=5.14.1,<6 diff --git a/src/vidxp/capabilities/visual.py b/src/vidxp/capabilities/visual.py index b7ef539..a4b27c5 100644 --- a/src/vidxp/capabilities/visual.py +++ b/src/vidxp/capabilities/visual.py @@ -5,6 +5,7 @@ from typing import Any, Protocol, Sequence from vidxp.capabilities.contracts import CapabilityIndexResult +from vidxp.capabilities.registry import CapabilityRegistry from vidxp.core.contracts import ( CancellationToken, IndexConfig, @@ -12,6 +13,7 @@ ) from vidxp.core.indexing_common import ProgressCallback, report_progress from vidxp.core.storage import IndexStorage +from vidxp.runtime import ModelRuntime from vidxp.core.video import ( FrameSample, FrameStreamStats, @@ -26,6 +28,7 @@ def batch_size(self, config: IndexConfig) -> int: ... def prepare( self, config: IndexConfig, + runtime: ModelRuntime, progress: ProgressCallback | None, ) -> Any: ... @@ -73,20 +76,20 @@ def _participants( names: Sequence[str], *, config: IndexConfig, + registry: CapabilityRegistry, + runtime: ModelRuntime, progress: ProgressCallback | None, timings: dict[str, float], ) -> list[_Participant]: - from vidxp.capabilities.registry import get_capability - participants = [] for name in names: - processor = get_capability(name).index_processor + processor = registry.executor(name).index_processor if processor is None: raise ValueError( f"Capability {name!r} does not provide a visual processor." ) started = perf_counter() - state = processor.prepare(config, progress) + state = processor.prepare(config, runtime, progress) timings[name] = perf_counter() - started participants.append(_Participant(name, processor, state)) return participants @@ -185,6 +188,8 @@ def index_visuals( config: IndexConfig, storage: IndexStorage, cancellation: CancellationToken, + registry: CapabilityRegistry, + runtime: ModelRuntime, progress: ProgressCallback | None = None, modalities: Sequence[str] | None = None, ) -> CapabilityIndexResult: @@ -210,6 +215,8 @@ def index_visuals( participants = _participants( selected, config=config, + registry=registry, + runtime=runtime, progress=progress, timings=timings, ) @@ -261,6 +268,8 @@ def index_capabilities( config: IndexConfig, storage: IndexStorage, cancellation: CancellationToken, + registry: CapabilityRegistry, + runtime: ModelRuntime, progress: ProgressCallback | None = None, modalities: Sequence[str] | None = None, ) -> CapabilityIndexResult: @@ -269,6 +278,8 @@ def index_capabilities( config=config, storage=storage, cancellation=cancellation, + registry=registry, + runtime=runtime, progress=progress, modalities=modalities, ) diff --git a/src/vidxp/cli.py b/src/vidxp/cli.py index 7135302..ebe828e 100644 --- a/src/vidxp/cli.py +++ b/src/vidxp/cli.py @@ -9,14 +9,15 @@ import typer from vidxp import __version__ -from vidxp.application import VidXPService +from vidxp.application_models import ApplicationError from vidxp.capabilities.actor.results import ActorClusterNotFoundError -from vidxp.capabilities.registry import CAPABILITIES +from vidxp.capabilities.actor.cli import app as actor_app from vidxp.cli_commands.index import app as index_app from vidxp.cli_commands.repositories import app as repositories_app from vidxp.cli_commands.runtime import doctor, prepare, ui from vidxp.cli_commands.search import app as search_app from vidxp.cli_support import CLIState, OutputFormat +from vidxp.composition import create_application from vidxp.core.contracts import IndexSchemaError from vidxp.dependencies import requirements_available from vidxp.index_state import ( @@ -24,6 +25,7 @@ IndexNotReadyError, ) from vidxp.repositories import resolve_repository +from vidxp.settings import VidXPSettings app = typer.Typer( @@ -33,6 +35,7 @@ app.add_typer(index_app, name="index") app.add_typer(search_app, name="search") app.add_typer(repositories_app, name="repositories") +app.add_typer(actor_app, name="actors") def _load_benchmark_app(): @@ -45,12 +48,6 @@ def _load_benchmark_app(): if _benchmark_app := _load_benchmark_app(): app.add_typer(_benchmark_app, name="benchmark") -for _capability in CAPABILITIES.values(): - if _capability.cli_factory is not None: - app.add_typer( - _capability.cli_factory(), - name=_capability.cli_name, - ) app.command()(doctor) app.command()(prepare) app.command()(ui) @@ -126,9 +123,11 @@ def app_options( device=device, ) ctx.obj = CLIState( - service=VidXPService( - repository.index_directory, - device=repository.device, + service=create_application( + VidXPSettings( + repository_root=repository.index_directory, + runtime_backend=repository.device or "auto", + ) ), registry=registry, repository=repository, @@ -161,15 +160,20 @@ def _exit_code(exc: Exception) -> int: def _emit_error(exc: Exception, *, json_output: bool) -> None: message = _error_message(exc) if json_output: + error = ( + exc.to_dict() + if isinstance(exc, ApplicationError) + else { + "type": type(exc).__name__, + "message": message, + "exit_code": _exit_code(exc), + } + ) typer.echo( json.dumps( { "ok": False, - "error": { - "type": type(exc).__name__, - "message": message, - "exit_code": _exit_code(exc), - }, + "error": error, }, ensure_ascii=False, indent=2, @@ -192,6 +196,9 @@ def main() -> None: _emit_error(exc, json_output=_wants_json()) raise SystemExit(1) from exc except Exception as exc: + if isinstance(exc, ApplicationError): + _emit_error(exc, json_output=_wants_json()) + raise SystemExit(_exit_code(exc)) from exc is_command_error = hasattr(exc, "exit_code") and hasattr( exc, "format_message", diff --git a/src/vidxp/cli_commands/index.py b/src/vidxp/cli_commands/index.py index f552757..02a1971 100644 --- a/src/vidxp/cli_commands/index.py +++ b/src/vidxp/cli_commands/index.py @@ -5,6 +5,7 @@ import typer +from vidxp.application_models import CreateIndexCommand from vidxp.cli_support import ( CLIState, IndexProgress, @@ -33,13 +34,16 @@ def create_index( not state.quiet and state.output_format == OutputFormat.rich ) with IndexProgress(show_progress) as progress: - summary = state.service.create_index( - path, - modalities=modalities, - frame_stride=frame_stride, - capability_options=capability_options, + result = state.service.create_index( + CreateIndexCommand( + path=path, + modalities=tuple(modalities), + frame_stride=frame_stride, + capability_options=capability_options, + ), progress_callback=progress.update, ) + summary = dict(result.summary) if state.output_format == OutputFormat.json: emit_json(summary) else: @@ -97,7 +101,10 @@ def index_create( create_index( state, path, - modalities=selected_modalities(modalities), + modalities=selected_modalities( + modalities, + state.service.registry, + ), frame_stride=frame_stride, capability_options=parse_capability_options(capability_options), ) @@ -115,7 +122,7 @@ def index_status( state = state_from_context(ctx) emit_status( - state.service.index_status(), + state.service.index_status().model_dump(mode="json"), output_format=effective_output_format(state, json_output), ) diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py index 94a97e6..6532c2c 100644 --- a/src/vidxp/cli_commands/runtime.py +++ b/src/vidxp/cli_commands/runtime.py @@ -5,7 +5,11 @@ import typer -from vidxp.capabilities.registry import CAPABILITIES, capability_names +from vidxp.application_models import ( + DependencyCheckCommand, + PrepareModelsCommand, +) +from vidxp.capabilities.registry import create_capability_registry from vidxp.cli_support import ( OutputFormat, effective_output_format, @@ -15,11 +19,10 @@ state_from_context, ) -ALL_CAPABILITIES = ",".join(capability_names()) +_COMMAND_REGISTRY = create_capability_registry() +ALL_CAPABILITIES = ",".join(_COMMAND_REGISTRY.names()) PREPARABLE_CAPABILITIES = ",".join( - name - for name, capability in CAPABILITIES.items() - if capability.prepare is not None + _COMMAND_REGISTRY.preparable_names() ) @@ -40,13 +43,16 @@ def doctor( ) -> None: """Validate selected indexing dependencies without downloading models.""" - selected = parse_modalities(modalities) state = state_from_context(ctx) - result = state.service.check_dependencies(selected) + selected = parse_modalities(modalities, state.service.registry) + result = state.service.check_dependencies( + DependencyCheckCommand(modalities=selected) + ) + payload = result.model_dump(mode="json") if effective_output_format(state, json_output) == OutputFormat.json: - emit_json(result) + emit_json(payload) else: - for check in result["checks"]: + for check in payload["checks"]: if check["ok"]: detail = f": {check['path']}" if check.get("path") else "" typer.secho( @@ -58,7 +64,7 @@ def doctor( f"FAILED {check['name']}: {check['error']}", fg=typer.colors.RED, ) - if not result["ok"]: + if not result.ok: raise typer.Exit(1) if effective_output_format(state, json_output) == OutputFormat.rich: typer.secho( @@ -95,11 +101,13 @@ def prepare( ) -> None: """Download and cache selected runtime models before indexing.""" - selected = parse_modalities(modalities) state = state_from_context(ctx) + selected = parse_modalities(modalities, state.service.registry) result = state.service.prepare_models( - selected, - capability_options=parse_capability_options(capability_options), + PrepareModelsCommand( + modalities=selected, + capability_options=parse_capability_options(capability_options), + ), progress_callback=( None if state.quiet @@ -109,7 +117,7 @@ def prepare( ), ) if effective_output_format(state, json_output) == OutputFormat.json: - emit_json(result) + emit_json(result.model_dump(mode="json")) else: typer.secho( "Selected VidXP runtime models are prepared.", @@ -139,7 +147,7 @@ def ui( state = state_from_context(ctx) os.environ["VIDXP_CONFIG_FILE"] = str(state.registry.path) os.environ["VIDXP_REPOSITORY"] = state.repository.name - os.environ["VIDXP_INDEX_DIR"] = str(state.service.index_directory) + os.environ["VIDXP_INDEX_DIR"] = str(state.service.layout.root) if state.service.device is None: os.environ.pop("VIDXP_DEVICE", None) else: @@ -155,13 +163,6 @@ def ui( ) from exc raise - frontend.SERVICE = state.service - frontend.SAVED_VIDEO_PATH = ( - state.service.index_directory / "source-video.mp4" - ) - frontend.ACTOR_OUTPUT_PATH = ( - state.service.index_directory / "actor-result.mp4" - ) streamlit_arguments = [] if host is not None: streamlit_arguments.append(f"--server.address={host}") diff --git a/src/vidxp/cli_commands/search.py b/src/vidxp/cli_commands/search.py index d2c9f90..15f6a8d 100644 --- a/src/vidxp/cli_commands/search.py +++ b/src/vidxp/cli_commands/search.py @@ -4,7 +4,8 @@ import typer -from vidxp.capabilities.registry import CAPABILITIES +from vidxp.application_models import SearchCommand +from vidxp.capabilities.registry import create_capability_registry from vidxp.capabilities.schemas import SearchResult from vidxp.cli_support import ( CLIState, @@ -25,7 +26,13 @@ def run_search( top_k: int, json_output: bool, ) -> SearchResult: - result = state.service.search(capability, query, top_k=top_k) + result = state.service.search( + SearchCommand( + modality=capability, + query=query, + top_k=top_k, + ) + ) emit_search( result, output_format=effective_output_format(state, json_output), @@ -63,10 +70,10 @@ def command( ) command.__name__ = f"search_{capability}" - command.__doc__ = CAPABILITIES[capability].description + command.__doc__ = create_capability_registry().get(capability).description return command -for _name, _capability in CAPABILITIES.items(): +for _name, _capability in create_capability_registry().definitions.items(): if "search" in _capability.operations: app.command(_name)(_search_command(_name)) diff --git a/src/vidxp/cli_support.py b/src/vidxp/cli_support.py index 67947f9..56ef440 100644 --- a/src/vidxp/cli_support.py +++ b/src/vidxp/cli_support.py @@ -16,11 +16,8 @@ ) from rich.table import Table -from vidxp.application import VidXPService -from vidxp.capabilities.registry import ( - index_capability_names, - validate_capability_names, -) +from vidxp.application import VidXPApplication +from vidxp.capabilities.registry import CapabilityRegistry from vidxp.capabilities.schemas import SearchResult from vidxp.repositories import RepositoryConfig, RepositoryRegistry @@ -32,7 +29,7 @@ class OutputFormat(str, Enum): @dataclass class CLIState: - service: VidXPService + service: VidXPApplication registry: RepositoryRegistry repository: RepositoryConfig output_format: OutputFormat = OutputFormat.rich @@ -166,23 +163,27 @@ def update(self, event: dict[str, Any]) -> None: def selected_modalities( values: Iterable[str] | None, + registry: CapabilityRegistry, ) -> tuple[str, ...]: if values is None: - return index_capability_names() + return registry.index_names() try: - return validate_capability_names(values) + return registry.validate_names(values) except ValueError as exc: raise typer.BadParameter(str(exc)) from exc -def parse_modalities(value: str) -> tuple[str, ...]: +def parse_modalities( + value: str, + registry: CapabilityRegistry, +) -> tuple[str, ...]: selected = tuple( item.strip().lower() for item in value.split(",") if item.strip() ) try: - return validate_capability_names(selected) + return registry.validate_names(selected) except ValueError as exc: raise typer.BadParameter(str(exc)) from exc diff --git a/src/vidxp/composition.py b/src/vidxp/composition.py new file mode 100644 index 0000000..c8adb7b --- /dev/null +++ b/src/vidxp/composition.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from vidxp.application import VidXPApplication +from vidxp.capabilities.registry import create_capability_registry +from vidxp.infrastructure.local_index import LocalIndexBackend +from vidxp.runtime import ModelRuntime +from vidxp.settings import VidXPSettings + + +def create_application( + settings: VidXPSettings | None = None, +) -> VidXPApplication: + active_settings = settings or VidXPSettings() + registry = create_capability_registry( + external=active_settings.external_capabilities, + allowlist=active_settings.capability_allowlist, + ) + runtime = ModelRuntime(active_settings) + backend = LocalIndexBackend(registry, runtime) + return VidXPApplication( + layout=active_settings.layout, + registry=registry, + runtime=runtime, + index_backend=backend, + ) diff --git a/src/vidxp/core/contracts.py b/src/vidxp/core/contracts.py index cece2ce..ac3d296 100644 --- a/src/vidxp/core/contracts.py +++ b/src/vidxp/core/contracts.py @@ -11,7 +11,7 @@ from urllib.parse import quote -INDEX_SCHEMA_VERSION = 3 +INDEX_SCHEMA_VERSION = 4 MANIFEST_SCHEMA_VERSION = 1 @@ -176,9 +176,7 @@ def local(cls, **changes: Any) -> "IndexConfig": "storage_directory": "chroma_data", } if "enabled_modalities" not in changes: - from vidxp.capabilities.registry import index_capability_names - - defaults["enabled_modalities"] = index_capability_names() + defaults["enabled_modalities"] = ("dialogue", "scene", "actor") defaults.update(changes) return cls(**defaults) diff --git a/src/vidxp/core/manifest.py b/src/vidxp/core/manifest.py index d2a35f0..cff3c03 100644 --- a/src/vidxp/core/manifest.py +++ b/src/vidxp/core/manifest.py @@ -12,8 +12,8 @@ from typing import Any, Mapping from vidxp.capabilities.registry import ( - get_capability, - runtime_distributions, + CapabilityRegistry, + create_capability_registry, ) from vidxp.core.contracts import ( INDEX_SCHEMA_VERSION, @@ -21,6 +21,7 @@ IndexConfig, VideoSource, ) +from vidxp.runtime import ModelRuntime MANIFEST_FILE = "manifest.json" @@ -153,9 +154,12 @@ def implementation_digest() -> str: return digest.hexdigest() -def dependency_versions() -> dict[str, str | None]: +def dependency_versions( + registry: CapabilityRegistry | None = None, +) -> dict[str, str | None]: + active_registry = registry or create_capability_registry() versions: dict[str, str | None] = {} - for package in runtime_distributions(): + for package in active_registry.runtime_distributions(): try: versions[package] = version(package) except PackageNotFoundError: @@ -163,7 +167,9 @@ def dependency_versions() -> dict[str, str | None]: return versions -def execution_state() -> dict[str, Any]: +def execution_state( + registry: CapabilityRegistry | None = None, +) -> dict[str, Any]: try: package_version = version("vidxp") except PackageNotFoundError: @@ -174,7 +180,7 @@ def execution_state() -> dict[str, Any]: "package_version": package_version, "python": sys.version, "platform": platform.platform(), - "dependencies": dependency_versions(), + "dependencies": dependency_versions(registry), } @@ -193,8 +199,16 @@ def execution_fingerprint(state: Mapping[str, Any]) -> str: class ManifestStore: - def __init__(self, config: IndexConfig): + def __init__( + self, + config: IndexConfig, + *, + registry: CapabilityRegistry | None = None, + runtime: ModelRuntime | None = None, + ): self.config = config + self.registry = registry or create_capability_registry() + self.runtime = runtime self.run_directory = config.run_directory self.manifest_path = self.run_directory / MANIFEST_FILE self.timings_path = self.run_directory / TIMINGS_FILE @@ -214,10 +228,17 @@ def _model_manifest( tuple[str, VideoSource, str, Mapping[str, str]] ], ) -> dict[str, Any]: - models: dict[str, Any] = {"device": self.config.device} + models: dict[str, Any] = { + "device": self.config.device, + "runtime": ( + self.runtime.describe() + if self.runtime is not None + else {"torch_device": self.config.device} + ), + } source_values = tuple(source for _, source, _, _ in sources) for name in self.config.enabled_modalities: - manifest = get_capability(name).model_manifest + manifest = self.registry.executor(name).model_manifest if manifest is not None: models.update(manifest(self.config, source_values)) return models @@ -230,7 +251,7 @@ def initialize( ) -> dict[str, Any]: self.run_directory.mkdir(parents=True, exist_ok=True) config_fingerprint = self.config.fingerprint() - current_execution = execution_state() + current_execution = execution_state(self.registry) current_execution_fingerprint = execution_fingerprint( current_execution ) diff --git a/src/vidxp/core/runner.py b/src/vidxp/core/runner.py index 896652a..6b15462 100644 --- a/src/vidxp/core/runner.py +++ b/src/vidxp/core/runner.py @@ -8,8 +8,8 @@ from filelock import FileLock, Timeout from vidxp.capabilities.registry import ( - get_capability, - require_dependencies, + CapabilityRegistry, + create_capability_registry, ) from vidxp.core.contracts import ( INDEX_SCHEMA_VERSION, @@ -30,12 +30,25 @@ IndexingInProgressError, write_index_status, ) +from vidxp.runtime import ModelRuntime +from vidxp.settings import VidXPSettings ProgressCallback = Callable[[dict[str, Any]], None] _INDEXING_LOCK = Lock() +def require_dependencies( + names: tuple[str, ...], + *, + source: VideoSource, + registry: CapabilityRegistry, +) -> None: + """Single testable dependency gate for an injected registry.""" + + registry.require_dependencies(names, source=source) + + class _RunLock: def __init__(self, run_directory: Path): self.path = run_directory / ".indexing.lock" @@ -144,15 +157,18 @@ def _run_capability_group( manifest: ManifestStore, cancellation: CancellationToken, progress_callback: ProgressCallback | None, + registry: CapabilityRegistry, + runtime: ModelRuntime, ) -> dict[str, Any]: - definitions = tuple(get_capability(name) for name in names) - indexer = definitions[0].indexer + definitions = tuple(registry.get(name) for name in names) + indexers = tuple(registry.executor(name).indexer for name in names) + indexer = indexers[0] index_stage = definitions[0].index_stage if indexer is None or index_stage is None: raise ValueError( f"Capability {names[0]!r} does not support indexing." ) - if any(definition.indexer is not indexer for definition in definitions): + if any(candidate is not indexer for candidate in indexers): raise RuntimeError("Grouped capabilities must share one indexer.") if any( definition.index_stage != index_stage @@ -192,6 +208,8 @@ def stage_progress(event: dict[str, Any]) -> None: cancellation=cancellation, progress=stage_progress, modalities=names, + registry=registry, + runtime=runtime, ) except BaseException: if active_substage is not None and active_substage != index_stage: @@ -241,23 +259,27 @@ def stage_progress(event: dict[str, Any]) -> None: return dict(result.summary) -def _index_groups(names: tuple[str, ...]) -> tuple[tuple[str, ...], ...]: +def _index_groups( + names: tuple[str, ...], + registry: CapabilityRegistry, +) -> tuple[tuple[str, ...], ...]: groups: list[list[str]] = [] - handlers = [] + execution_groups: list[str] = [] for name in names: - handler = get_capability(name).indexer - if handler is None: + definition = registry.get(name) + if registry.executor(name).indexer is None: raise ValueError( f"Capability {name!r} does not support indexing." ) - try: - group_index = next( - index - for index, existing in enumerate(handlers) - if existing is handler + execution_group = definition.execution_group + if execution_group is None: + raise RuntimeError( + f"Capability {name!r} has no execution group." ) - except StopIteration: - handlers.append(handler) + try: + group_index = execution_groups.index(execution_group) + except ValueError: + execution_groups.append(execution_group) groups.append([name]) else: groups[group_index].append(name) @@ -272,11 +294,13 @@ def _run_enabled_modalities( cancellation: CancellationToken, progress_callback: ProgressCallback | None, set_stage: Callable[[str], None], + registry: CapabilityRegistry, + runtime: ModelRuntime, ) -> dict[str, Any]: summary: dict[str, Any] = {} - for names in _index_groups(config.enabled_modalities): + for names in _index_groups(config.enabled_modalities, registry): cancellation.raise_if_cancelled() - set_stage(get_capability(names[0]).index_stage) + set_stage(str(registry.get(names[0]).index_stage)) summary.update( _run_capability_group( names, @@ -286,6 +310,8 @@ def _run_enabled_modalities( manifest, cancellation, progress_callback, + registry, + runtime, ) ) return summary @@ -302,6 +328,8 @@ def _process_video( progress_callback: ProgressCallback | None, *, fail_fast: bool, + registry: CapabilityRegistry, + runtime: ModelRuntime, ) -> None: video_config = config.for_video(video_id) manifest.start_video(video_id) @@ -320,6 +348,7 @@ def set_stage(value: str) -> None: require_dependencies( config.enabled_modalities, source=source, + registry=registry, ) stage = "preparing_storage" for modality in config.enabled_modalities: @@ -334,6 +363,8 @@ def set_stage(value: str) -> None: cancellation, progress_callback, set_stage, + registry, + runtime, ) ) manifest.complete_video( @@ -382,15 +413,25 @@ def _run_index_unlocked( fail_fast: bool = True, storage: IndexStorage | None = None, manifest_store: ManifestStore | None = None, + registry: CapabilityRegistry | None = None, + runtime: ModelRuntime | None = None, ) -> dict[str, Any]: if not sources: raise ValueError("At least one video or transcript source is required.") cancellation = cancellation or CancellationToken() + registry = registry or create_capability_registry() + runtime = runtime or ModelRuntime( + VidXPSettings(runtime_backend=config.device) + ) resolved = _resolve_sources(sources, config) owns_storage = storage is None store = storage or IndexStorage(config) try: - manifest = manifest_store or ManifestStore(config) + manifest = manifest_store or ManifestStore( + config, + registry=registry, + runtime=runtime, + ) if reset: store.clear() manifest.initialize(resolved, reset=reset) @@ -422,6 +463,8 @@ def _run_index_unlocked( cancellation, progress_callback, fail_fast=fail_fast, + registry=registry, + runtime=runtime, ) return manifest.complete_run(index_size_bytes=store.size_bytes()) @@ -441,6 +484,8 @@ def run_index( fail_fast: bool = True, storage: IndexStorage | None = None, manifest_store: ManifestStore | None = None, + registry: CapabilityRegistry | None = None, + runtime: ModelRuntime | None = None, ) -> dict[str, Any]: with _RunLock(config.run_directory): return _run_index_unlocked( @@ -453,6 +498,8 @@ def run_index( fail_fast=fail_fast, storage=storage, manifest_store=manifest_store, + registry=registry, + runtime=runtime, ) @@ -473,6 +520,8 @@ def index_video( *, config: IndexConfig | None = None, cancellation: CancellationToken | None = None, + registry: CapabilityRegistry | None = None, + runtime: ModelRuntime | None = None, ) -> dict[str, Any]: input_path = Path(path) if not input_path.is_file(): @@ -534,6 +583,8 @@ def report(event: dict[str, Any]) -> None: active_config, progress_callback=report, cancellation=cancellation, + registry=registry, + runtime=runtime, resume=False, reset=True, fail_fast=True, diff --git a/src/vidxp/core/video.py b/src/vidxp/core/video.py index 0f6e3b7..074ee6a 100644 --- a/src/vidxp/core/video.py +++ b/src/vidxp/core/video.py @@ -2,7 +2,6 @@ from dataclasses import dataclass from pathlib import Path -from shutil import which from typing import Iterator from vidxp.core.contracts import CancellationToken @@ -30,21 +29,6 @@ class FrameSample: frame: object -def ffmpeg_binary() -> str: - from moviepy.config import get_setting - - configured = get_setting("FFMPEG_BINARY") - configured_path = Path(str(configured)) - resolved = ( - str(configured_path.resolve()) - if configured_path.is_file() - else which(str(configured)) - ) - if not resolved: - raise RuntimeError(f"FFmpeg executable was not found: {configured}") - return resolved - - def probe_video(path: str | Path) -> VideoInfo: import cv2 @@ -121,18 +105,6 @@ def iter_frame_batches( video.release() -def extract_audio(input_path: str | Path, output_path: str | Path) -> Path: - from moviepy.editor import VideoFileClip - - destination = Path(output_path) - destination.parent.mkdir(parents=True, exist_ok=True) - with VideoFileClip(str(input_path)) as source_video: - if source_video.audio is None: - raise ValueError("The selected video does not contain an audio track.") - source_video.audio.write_audiofile(str(destination), logger=None) - return destination - - def render_actor_video( input_path: str | Path, output_path: str | Path, diff --git a/src/vidxp/frontend.py b/src/vidxp/frontend.py index 84a26e2..91c74c1 100644 --- a/src/vidxp/frontend.py +++ b/src/vidxp/frontend.py @@ -1,12 +1,14 @@ import hashlib import sys +from functools import lru_cache from pathlib import Path from typing import Sequence import streamlit as st -from vidxp.application import VidXPService -from vidxp.capabilities.registry import index_capability_names +from vidxp.application import VidXPApplication +from vidxp.application_models import DependencyCheckCommand, SearchCommand +from vidxp.composition import create_application from vidxp.index_state import IndexNotReadyError from vidxp.index_worker import ( cancel_indexing, @@ -14,19 +16,20 @@ start_indexing, ) from vidxp.repositories import resolve_repository +from vidxp.settings import VidXPSettings -def _configured_service() -> VidXPService: +@lru_cache(maxsize=1) +def _configured_service() -> VidXPApplication: _, repository = resolve_repository() - return VidXPService( - repository.index_directory, - device=repository.device, + return create_application( + VidXPSettings( + repository_root=repository.index_directory, + runtime_backend=repository.device or "auto", + ) ) -SERVICE = _configured_service() -SAVED_VIDEO_PATH = SERVICE.index_directory / "source-video.mp4" -ACTOR_OUTPUT_PATH = SERVICE.index_directory / "actor-result.mp4" INDEX_REQUESTED_KEY = "_vidxp_index_requested" INDEX_ERROR_KEY = "_vidxp_index_error" SEARCH_RESULT_KEY = "_vidxp_search_result" @@ -40,10 +43,11 @@ def _video_hash(uploaded_video) -> str | None: def _is_search_ready(status, uploaded_video) -> bool: + saved_video_path = _configured_service().layout.media / "source-video.mp4" if not status or status.get("state") != "ready": return False if uploaded_video is None: - return SAVED_VIDEO_PATH.is_file() + return saved_video_path.is_file() return status.get("video", {}).get("sha256") == _video_hash(uploaded_video) @@ -124,29 +128,34 @@ def _request_cancellation(): def _available_index_modalities() -> tuple[str, ...]: + service = _configured_service() return tuple( name - for name in index_capability_names() - if SERVICE.check_dependencies((name,))["ok"] + for name in service.registry.index_names() + if service.check_dependencies( + DependencyCheckCommand(modalities=(name,)) + ).ok ) def _run_indexing(uploaded_video, status, modalities): + service = _configured_service() + saved_video_path = service.layout.media / "source-video.mp4" try: if uploaded_video is not None: - SAVED_VIDEO_PATH.parent.mkdir(parents=True, exist_ok=True) - SAVED_VIDEO_PATH.write_bytes(uploaded_video.getvalue()) + saved_video_path.parent.mkdir(parents=True, exist_ok=True) + saved_video_path.write_bytes(uploaded_video.getvalue()) source_name = uploaded_video.name else: source_name = ( - status.get("video", {}).get("source_name", SAVED_VIDEO_PATH.name) + status.get("video", {}).get("source_name", saved_video_path.name) if status - else SAVED_VIDEO_PATH.name + else saved_video_path.name ) start_indexing( - str(SAVED_VIDEO_PATH), + str(saved_video_path), source_name, - SERVICE, + service, modalities=modalities, ) except Exception as exc: @@ -159,32 +168,37 @@ def _run_indexing(uploaded_video, status, modalities): def _run_search(search_type, query): + service = _configured_service() + saved_video_path = service.layout.media / "source-video.mp4" + actor_output_path = service.layout.artifacts / "actor-result.mp4" try: - status = SERVICE.index_status() + status = service.index_status().model_dump(mode="json") if status.get("state") != "ready": raise IndexNotReadyError("The video index is not ready.") if search_type == "actor": - ACTOR_OUTPUT_PATH.unlink(missing_ok=True) - SERVICE.render_actor( + actor_output_path.unlink(missing_ok=True) + service.render_actor( query, - SAVED_VIDEO_PATH, - ACTOR_OUTPUT_PATH, + saved_video_path, + actor_output_path, ) if ( - not ACTOR_OUTPUT_PATH.is_file() - or ACTOR_OUTPUT_PATH.stat().st_size == 0 + not actor_output_path.is_file() + or actor_output_path.stat().st_size == 0 ): return {"error": "Actor result video could not be generated."} return { "type": search_type, "query": query, - "video_path": str(ACTOR_OUTPUT_PATH), + "video_path": str(actor_output_path), } - result = SERVICE.search( - search_type, - query, - top_k=1, + result = service.search( + SearchCommand( + modality=search_type, + query=query, + top_k=1, + ) ) if not result.hits: return {"error": f"No {search_type} match was found."} @@ -194,7 +208,7 @@ def _run_search(search_type, query): "query": query, "timestamp": hit.start, "hit": hit.to_dict(), - "video_path": str(SAVED_VIDEO_PATH), + "video_path": str(saved_video_path), } except IndexNotReadyError as exc: return {"error": str(exc)} @@ -230,10 +244,11 @@ def _render_search_result(result): def _select_video(busy): + saved_video_path = _configured_service().layout.media / "source-video.mp4" st.subheader("Video") upload_slot = st.empty() has_session_upload = st.session_state.get("video_upload") is not None - if busy and not has_session_upload and SAVED_VIDEO_PATH.is_file(): + if busy and not has_session_upload and saved_video_path.is_file(): uploaded_video = None st.caption("Indexing the saved video.") else: @@ -247,10 +262,10 @@ def _select_video(busy): if uploaded_video is not None: st.video(uploaded_video, width=560) - elif SAVED_VIDEO_PATH.is_file(): + elif saved_video_path.is_file(): if not busy: st.caption("Using the saved video.") - st.video(str(SAVED_VIDEO_PATH), width=560) + st.video(str(saved_video_path), width=560) return uploaded_video @@ -290,17 +305,19 @@ def _search_controls(ready, uploaded_video, available_modalities): def run(): + service = _configured_service() + saved_video_path = service.layout.media / "source-video.mp4" st.set_page_config(page_title="VidXP", page_icon="🎬", layout="wide") st.title("VidXP") st.caption("Index and search video by dialogue, scene, and actor.") - st.caption(f"Index repository: {SERVICE.index_directory}") + st.caption(f"Index repository: {service.layout.root}") - active = indexing_in_progress(SERVICE) + active = indexing_in_progress(service) if not active: st.session_state.pop(CANCEL_REQUESTED_KEY, None) requested = st.session_state.get(INDEX_REQUESTED_KEY, False) busy = active or requested - status = SERVICE.index_status() + status = service.index_status().model_dump(mode="json") installed_modalities = _available_index_modalities() video_column, workflow_column = st.columns( [0.95, 1.05], @@ -332,7 +349,7 @@ def run(): type="primary", disabled=busy or not selected_modalities - or (uploaded_video is None and not SAVED_VIDEO_PATH.is_file()), + or (uploaded_video is None and not saved_video_path.is_file()), help=( "Indexing is already running." if busy @@ -358,9 +375,9 @@ def run(): @st.fragment(run_every="1s") def poll_index_status(): - latest_active = indexing_in_progress(SERVICE) + latest_active = indexing_in_progress(service) _render_index_status( - SERVICE.index_status(), + service.index_status().model_dump(mode="json"), latest_active, uploaded_video, st.session_state.get(INDEX_ERROR_KEY), diff --git a/src/vidxp/index_worker.py b/src/vidxp/index_worker.py index d96a9f4..f21aa32 100644 --- a/src/vidxp/index_worker.py +++ b/src/vidxp/index_worker.py @@ -2,21 +2,26 @@ from multiprocessing.process import BaseProcess from threading import Lock -from vidxp.application import VidXPService +from vidxp.application import VidXPApplication +from vidxp.application_models import CreateIndexCommand +from vidxp.composition import create_application from vidxp.core.contracts import CancellationToken from vidxp.index_state import IndexingInProgressError from vidxp.repositories import resolve_repository +from vidxp.settings import VidXPSettings _process: BaseProcess | None = None _cancel_event = None _start_lock = Lock() -def _configured_service() -> VidXPService: +def _configured_service() -> VidXPApplication: _, repository = resolve_repository() - return VidXPService( - repository.index_directory, - device=repository.device, + return create_application( + VidXPSettings( + repository_root=repository.index_directory, + runtime_backend=repository.device or "auto", + ) ) @@ -24,19 +29,26 @@ def _run_indexing( path: str, source_name: str, cancel_event, - index_directory: str, - device: str | None, + repository_root: str, + runtime_backend: str, modalities: tuple[str, ...], ) -> None: - VidXPService(index_directory, device=device).create_index( - path, - modalities=modalities, - source_name=source_name, + create_application( + VidXPSettings( + repository_root=repository_root, + runtime_backend=runtime_backend, + ) + ).create_index( + CreateIndexCommand( + path=path, + modalities=modalities, + source_name=source_name, + ), cancellation=CancellationToken(cancel_event), ) -def indexing_in_progress(service: VidXPService | None = None) -> bool: +def indexing_in_progress(service: VidXPApplication | None = None) -> bool: active_service = service or _configured_service() return ( _process is not None and _process.is_alive() @@ -46,7 +58,7 @@ def indexing_in_progress(service: VidXPService | None = None) -> bool: def start_indexing( path: str, source_name: str, - service: VidXPService | None = None, + service: VidXPApplication | None = None, *, modalities: tuple[str, ...], ) -> None: @@ -65,8 +77,8 @@ def start_indexing( path, source_name, _cancel_event, - str(active_service.index_directory), - active_service.device, + str(active_service.layout.root), + active_service.runtime.backends.requested, modalities, ), name="vidxp-indexer", diff --git a/src/vidxp/infrastructure/__init__.py b/src/vidxp/infrastructure/__init__.py new file mode 100644 index 0000000..80f6700 --- /dev/null +++ b/src/vidxp/infrastructure/__init__.py @@ -0,0 +1 @@ +"""Concrete infrastructure adapters selected by the composition root.""" diff --git a/src/vidxp/infrastructure/local_index.py b/src/vidxp/infrastructure/local_index.py new file mode 100644 index 0000000..d86b55d --- /dev/null +++ b/src/vidxp/infrastructure/local_index.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path +from typing import Any + +from vidxp.capabilities.registry import CapabilityRegistry +from vidxp.core.contracts import CancellationToken, IndexConfig +from vidxp.core.indexing_common import ProgressCallback +from vidxp.core.runner import ( + index_video, + indexing_in_progress, + local_config_from_status, +) +from vidxp.core.storage import IndexStorage +from vidxp.index_state import read_index_status, require_ready_index +from vidxp.runtime import ModelRuntime + + +class LocalIndexBackend: + def __init__( + self, + registry: CapabilityRegistry, + runtime: ModelRuntime, + ) -> None: + self.registry = registry + self.runtime = runtime + + def status(self, index_directory: Path) -> dict[str, Any] | None: + return read_index_status(index_directory) + + def active_config( + self, + index_directory: Path, + *, + device: str, + ) -> tuple[IndexConfig, dict[str, Any]]: + status = require_ready_index(index_directory) + config = local_config_from_status( + status, + storage_directory=index_directory, + ) + return replace(config, device=device), status + + def create( + self, + path: Path, + *, + config: IndexConfig, + progress: ProgressCallback | None, + cancellation: CancellationToken | None, + source_name: str | None, + ) -> dict[str, Any]: + return index_video( + str(path), + progress_callback=progress, + source_name=source_name, + config=config, + cancellation=cancellation, + registry=self.registry, + runtime=self.runtime, + ) + + def indexing_in_progress(self, config: IndexConfig) -> bool: + return indexing_in_progress(config) + + def clear(self, config: IndexConfig) -> None: + with IndexStorage(config) as storage: + storage.clear() diff --git a/src/vidxp/ports.py b/src/vidxp/ports.py new file mode 100644 index 0000000..c5c2da6 --- /dev/null +++ b/src/vidxp/ports.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Protocol + +from vidxp.core.contracts import CancellationToken, IndexConfig +from vidxp.core.indexing_common import ProgressCallback + + +class IndexBackend(Protocol): + """Infrastructure operations needed by the application layer.""" + + def status(self, index_directory: Path) -> dict[str, Any] | None: ... + + def active_config( + self, + index_directory: Path, + *, + device: str, + ) -> tuple[IndexConfig, dict[str, Any]]: ... + + def create( + self, + path: Path, + *, + config: IndexConfig, + progress: ProgressCallback | None, + cancellation: CancellationToken | None, + source_name: str | None, + ) -> dict[str, Any]: ... + + def indexing_in_progress(self, config: IndexConfig) -> bool: ... + + def clear(self, config: IndexConfig) -> None: ... diff --git a/src/vidxp/repository_layout.py b/src/vidxp/repository_layout.py new file mode 100644 index 0000000..6694567 --- /dev/null +++ b/src/vidxp/repository_layout.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from pathlib import Path + +from pydantic import BaseModel, ConfigDict + + +class RepositoryLayout(BaseModel): + model_config = ConfigDict(frozen=True) + + root: Path + + @property + def descriptor(self) -> Path: + return self.root / "repository.json" + + @property + def media(self) -> Path: + return self.root / "media" + + @property + def indexes(self) -> Path: + return self.root / "indexes" + + @property + def local_index(self) -> Path: + """Single-index location until generation snapshots land in phase 3.""" + + return self.indexes / "current" + + @property + def generations(self) -> Path: + return self.indexes / "generations" + + @property + def snapshots(self) -> Path: + return self.indexes / "snapshots" + + @property + def active_snapshot(self) -> Path: + return self.indexes / "active-snapshot.json" + + @property + def artifacts(self) -> Path: + return self.root / "artifacts" + + @property + def local_workflows(self) -> Path: + return self.root / "local-workflows" + + def ensure_local_directories(self) -> None: + for path in ( + self.root, + self.media, + self.indexes, + self.artifacts, + self.local_workflows, + ): + path.mkdir(parents=True, exist_ok=True) diff --git a/src/vidxp/requirements/frontend.txt b/src/vidxp/requirements/frontend.txt index c8038a5..116dc32 100644 --- a/src/vidxp/requirements/frontend.txt +++ b/src/vidxp/requirements/frontend.txt @@ -1 +1 @@ -streamlit>=1.37 +streamlit>=1.60,<2 diff --git a/src/vidxp/requirements/storage.txt b/src/vidxp/requirements/storage.txt index 99812b1..ae0fed1 100644 --- a/src/vidxp/requirements/storage.txt +++ b/src/vidxp/requirements/storage.txt @@ -1 +1 @@ -chromadb +chromadb>=1.5.9,<2 diff --git a/src/vidxp/runtime.py b/src/vidxp/runtime.py new file mode 100644 index 0000000..1c57fe2 --- /dev/null +++ b/src/vidxp/runtime.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import platform +from collections import OrderedDict +from dataclasses import dataclass +from threading import RLock +from typing import Any, Callable + +from vidxp.application_models import RuntimeProfile +from vidxp.settings import VidXPSettings + + +def _torch_accelerators() -> tuple[bool, bool]: + try: + import torch + except ModuleNotFoundError: + return False, False + mps = bool( + getattr(torch.backends, "mps", None) + and torch.backends.mps.is_available() + ) + return mps, bool(torch.cuda.is_available()) + + +def resolve_backends(requested: str) -> RuntimeProfile: + mps_available, cuda_available = _torch_accelerators() + normalized = requested.lower() + if normalized == "auto": + if platform.system() == "Darwin" and mps_available: + torch_device = "mps" + else: + torch_device = "cpu" + elif normalized == "mps": + if not mps_available: + raise RuntimeError("MPS was requested but is unavailable.") + torch_device = "mps" + elif normalized.startswith("cuda"): + if not cuda_available: + raise RuntimeError("CUDA was requested but is unavailable.") + if ":" in normalized: + import torch + + index = int(normalized.split(":", 1)[1]) + if index >= torch.cuda.device_count(): + raise RuntimeError( + f"CUDA device {index} was requested but only " + f"{torch.cuda.device_count()} device(s) are available." + ) + torch_device = normalized + else: + torch_device = "cpu" + return RuntimeProfile( + requested=normalized, + torch_device=torch_device, + transcription_device=( + normalized if normalized.startswith("cuda") else "cpu" + ), + mps_available=mps_available, + cuda_available=cuda_available, + ) + + +@dataclass(frozen=True) +class ModelKey: + capability: str + provider: str + model_id: str + revision: str + device: str + + +class ModelRuntime: + """One injected model cache and backend resolver for all capabilities.""" + + def __init__(self, settings: VidXPSettings) -> None: + self.settings = settings + self.backends = resolve_backends(settings.runtime_backend) + self._resources: OrderedDict[ModelKey, Any] = OrderedDict() + self._lock = RLock() + + def device_for(self, capability: str) -> str: + if capability == "dialogue.transcription": + return self.backends.transcription_device + if capability == "actor": + return self.backends.actor_device + return self.backends.torch_device + + def get_or_load( + self, + key: ModelKey, + loader: Callable[[], Any], + ) -> Any: + with self._lock: + if key in self._resources: + resource = self._resources.pop(key) + self._resources[key] = resource + return resource + resource = loader() + self._resources[key] = resource + while len(self._resources) > self.settings.max_loaded_models: + self._resources.popitem(last=False) + return resource + + def clear(self) -> None: + with self._lock: + self._resources.clear() + try: + import torch + except ModuleNotFoundError: + return + if torch.cuda.is_available(): + torch.cuda.empty_cache() + if ( + getattr(torch.backends, "mps", None) + and torch.backends.mps.is_available() + ): + torch.mps.empty_cache() + + def describe(self) -> dict[str, Any]: + return { + "platform": platform.system().lower(), + "architecture": platform.machine().lower(), + **self.backends.model_dump(mode="json"), + "model_cache": str(self.settings.model_cache), + "allow_model_downloads": self.settings.allow_model_downloads, + "compute": { + "scene": "float32", + "dialogue_embedding": "float32", + "dialogue_transcription": ( + "float16" + if self.backends.transcription_device.startswith("cuda") + else "int8" + ), + "actor": "float32", + }, + } diff --git a/src/vidxp/settings.py b/src/vidxp/settings.py new file mode 100644 index 0000000..bfa4330 --- /dev/null +++ b/src/vidxp/settings.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import re +from enum import StrEnum +from pathlib import Path + +from platformdirs import user_cache_path +from pydantic import Field, field_validator, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from vidxp.repository_layout import RepositoryLayout + + +class ApplicationMode(StrEnum): + local = "local" + remote = "remote" + server = "server" + + +class VidXPSettings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="VIDXP_", + env_nested_delimiter="__", + extra="ignore", + frozen=True, + ) + + mode: ApplicationMode = ApplicationMode.local + repository_root: Path = Path("chroma_data") + runtime_backend: str = "auto" + model_cache: Path = Field( + default_factory=lambda: user_cache_path("vidxp") / "models" + ) + allow_model_downloads: bool = True + max_loaded_models: int = Field(default=3, gt=0, le=16) + external_capabilities: bool = False + capability_allowlist: tuple[str, ...] = () + + @field_validator("runtime_backend") + @classmethod + def _validate_runtime_backend(cls, value: str) -> str: + backend = value.strip().lower() + if not re.fullmatch(r"(auto|cpu|mps|cuda(?::[0-9]+)?)", backend): + raise ValueError( + "runtime_backend must be auto, cpu, mps, cuda, " + "or cuda:." + ) + return backend + + @field_validator("capability_allowlist") + @classmethod + def _clean_allowlist(cls, values: tuple[str, ...]) -> tuple[str, ...]: + return tuple(dict.fromkeys(value.strip() for value in values if value.strip())) + + @model_validator(mode="after") + def _require_explicit_server_backend(self) -> "VidXPSettings": + if self.mode == ApplicationMode.server and self.runtime_backend == "auto": + raise ValueError( + "Server mode requires an explicit cpu or cuda runtime backend." + ) + return self + + @property + def layout(self) -> RepositoryLayout: + return RepositoryLayout(root=self.repository_root.expanduser()) diff --git a/tests/test_application.py b/tests/test_application.py index 5ea2650..67ac8ab 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1,22 +1,87 @@ import unittest from pathlib import Path from tempfile import TemporaryDirectory -from unittest.mock import Mock, patch - -from vidxp.application import VidXPService +from unittest.mock import Mock + +from vidxp.application import VidXPApplication +from vidxp.application_models import ( + CreateIndexCommand, + DependencyCheckCommand, + IndexResult, + PrepareModelsCommand, + SearchCommand, +) from vidxp.capabilities.contracts import ( CapabilityDefinition, + CapabilityExecutor, + CapabilityPlugin, OperationDefinition, - PreparationContext, ) -from vidxp.capabilities.scene.config import SceneConfig +from vidxp.capabilities.registry import ( + CapabilityRegistry, + create_capability_registry, +) from vidxp.capabilities.schemas import SearchInput, SearchResult -from vidxp.core.contracts import IndexConfig +from vidxp.repository_layout import RepositoryLayout +from vidxp.runtime import ModelRuntime +from vidxp.settings import VidXPSettings + + +class ApplicationTests(unittest.TestCase): + def application( + self, + root: str | Path, + *, + registry: CapabilityRegistry | None = None, + backend: Mock | None = None, + ) -> tuple[VidXPApplication, Mock]: + settings = VidXPSettings( + repository_root=Path(root), + runtime_backend="cpu", + ) + runtime = ModelRuntime(settings) + active_backend = backend or Mock() + return ( + VidXPApplication( + layout=RepositoryLayout(root=Path(root)), + registry=registry or create_capability_registry(), + runtime=runtime, + index_backend=active_backend, + ), + active_backend, + ) + + def test_missing_index_has_shared_status_model(self): + application, backend = self.application("missing") + backend.status.return_value = None + status = application.index_status() -class ApplicationServiceTests(unittest.TestCase): - def test_execute_supports_validated_operation_without_an_index(self): - capability = CapabilityDefinition( + self.assertEqual(status.state, "missing") + self.assertEqual(status.schema_version, 1) + self.assertEqual(status.index_directory, Path("missing/indexes/current")) + + def test_create_index_builds_one_central_config(self): + with TemporaryDirectory() as directory: + application, backend = self.application(directory) + backend.create.return_value = {"scene_frames": 1} + + result = application.create_index( + CreateIndexCommand( + path=Path("video.mp4"), + modalities=("scene",), + frame_stride=5, + ) + ) + + self.assertEqual(result, IndexResult(summary={"scene_frames": 1})) + config = backend.create.call_args.kwargs["config"] + self.assertEqual(config.enabled_modalities, ("scene",)) + self.assertEqual(config.frame_stride, 5) + self.assertEqual(config.device, "cpu") + + def test_operation_definition_is_metadata_and_executor_owns_handler(self): + definition = CapabilityDefinition( name="export", description="Export results.", extra="export", @@ -24,276 +89,135 @@ def test_execute_supports_validated_operation_without_an_index(self): "run": OperationDefinition( input_model=SearchInput, output_model=SearchResult, - handler=lambda context, request: { - "query_id": "export:1", - "query": request.query, - "modality": "export", - "hits": (), - }, requires_index=False, ) }, ) - service = VidXPService() - with ( - patch( - "vidxp.application.get_capability", - return_value=capability, - ), - patch.object( - service, - "active_config", - side_effect=AssertionError("index should not be loaded"), + plugin = CapabilityPlugin( + definition=definition, + executor_factory=lambda: CapabilityExecutor( + operations={ + "run": lambda _context, request: { + "query_id": "export:1", + "query": request.query, + "modality": "export", + "hits": (), + } + } ), - ): - result = service.execute( - "export", - "run", - {"query": "result bundle"}, - ) - - self.assertEqual(result.query, "result bundle") - - def test_missing_index_has_a_stable_status_contract(self): - service = VidXPService("missing-index") - with patch( - "vidxp.application.read_index_status", - return_value=None, - ): - status = service.index_status() - - self.assertEqual(status["state"], "missing") - self.assertEqual(status["schema_version"], 1) - - def test_active_config_uses_selected_directory_and_device(self): - service = VidXPService("selected-index", device="cuda") - ready = {"state": "ready"} - stored_config = IndexConfig.local( - video_id="video-1", - storage_directory="selected-index", ) - with ( - patch( - "vidxp.application.require_ready_index", - return_value=ready, - ) as require, - patch( - "vidxp.application.local_config_from_status", - return_value=stored_config, - ) as restore, - ): - config, status = service.active_config() + application, _ = self.application( + "unused", + registry=CapabilityRegistry((plugin,)), + ) - require.assert_called_once_with(Path("selected-index")) - restore.assert_called_once_with( - ready, - storage_directory=Path("selected-index"), + result = application.execute( + "export", + "run", + {"query": "bundle"}, ) - self.assertEqual(config.device, "cuda") - self.assertIs(status, ready) - def test_search_is_a_thin_adapter_over_the_core(self): - service = VidXPService() + self.assertEqual(result.query, "bundle") + + def test_search_is_only_a_typed_projection_over_execute(self): + application, _ = self.application("unused") expected = SearchResult( query_id="scene:1", query="yellow taxi", modality="scene", ) - with patch.object( - service, - "execute", - return_value=expected, - ) as execute: - result = service.search("scene", "yellow taxi", top_k=7) + application.execute = Mock(return_value=expected) + + result = application.search( + SearchCommand( + modality="scene", + query="yellow taxi", + top_k=7, + ) + ) self.assertIs(result, expected) - execute.assert_called_once_with( + application.execute.assert_called_once_with( "scene", "search", {"query": "yellow taxi", "top_k": 7}, ) - def test_execute_translates_missing_optional_dependency(self): - def missing_dependency(_context, _request): - raise ModuleNotFoundError("No module named 'clip'", name="clip") - - operation = OperationDefinition( - input_model=SearchInput, - output_model=SearchResult, - handler=missing_dependency, - requires_index=False, - ) - capability = CapabilityDefinition( - name="scene", - description="Search visual scenes.", - extra="scene", - operations={"search": operation}, - ) - service = VidXPService() - with patch( - "vidxp.application.get_capability", - return_value=capability, - ): - with self.assertRaisesRegex( - RuntimeError, - r'clip is unavailable.*pip install "vidxp\[scene\]"', - ): - service.execute( - "scene", - "search", - {"query": "yellow taxi"}, - ) - - def test_create_index_centralizes_storage_and_runtime_configuration(self): - service = VidXPService("selected-index", device="cuda") - with patch( - "vidxp.application.index_video", - return_value={"scene_frames": 1}, - ) as index: - summary = service.create_index( - "video.mp4", - modalities=("scene",), - frame_stride=5, + def test_dependency_check_returns_shared_result_model(self): + registry = create_capability_registry() + registry.dependency_checks = Mock( + return_value=( + {"name": "transformers", "ok": False, "error": "missing"}, ) + ) + application, _ = self.application("unused", registry=registry) - self.assertEqual(summary, {"scene_frames": 1}) - config = index.call_args.kwargs["config"] - self.assertEqual(config.storage_directory, "selected-index") - self.assertEqual(config.device, "cuda") - self.assertEqual(config.enabled_modalities, ("scene",)) - self.assertEqual(config.frame_stride, 5) - - def test_dependency_checks_return_a_transport_neutral_contract(self): - service = VidXPService() - with patch( - "vidxp.application.dependency_checks", - return_value=( - {"name": "CLIP", "ok": False, "error": "missing"}, - ), - ): - result = service.check_dependencies(("scene",)) + result = application.check_dependencies( + DependencyCheckCommand(modalities=("scene",)) + ) - self.assertFalse(result["ok"]) - failed = [check for check in result["checks"] if not check["ok"]] - self.assertEqual(failed, [ - {"name": "CLIP", "ok": False, "error": "missing"} - ]) + self.assertFalse(result.ok) + self.assertEqual(result.modalities, ("scene",)) - def test_model_preparation_reports_progress_without_cli_dependencies(self): - service = VidXPService(device="cuda") - events = [] - prepare = Mock( - side_effect=lambda context, progress: ( - progress( - { - "state": "preparing", - "stage": "scene_model", - "message": "Preparing scene model", + def test_prepare_uses_injected_runtime_and_executor(self): + definition = CapabilityDefinition( + name="prepare-only", + description="Prepare a provider.", + extra="prepare-only", + operations={ + "noop": OperationDefinition( + input_model=SearchInput, + output_model=SearchResult, + requires_index=False, + ) + }, + prepares_models=True, + ) + prepare = Mock(return_value=("model",)) + plugin = CapabilityPlugin( + definition=definition, + executor_factory=lambda: CapabilityExecutor( + operations={ + "noop": lambda _context, request: { + "query_id": "noop:1", + "query": request.query, + "modality": "noop", + "hits": (), } - ), - (SceneConfig.model_validate(context.settings).model,), - )[1] + }, + prepare=prepare, + ), ) - capability = Mock( - prepare=prepare, - config_model=SceneConfig, + registry = CapabilityRegistry((plugin,)) + registry.dependency_checks = Mock(return_value=()) + application, _ = self.application("unused", registry=registry) + + result = application.prepare_models( + PrepareModelsCommand(modalities=("prepare-only",)) ) - with ( - patch( - "vidxp.application.dependency_checks", - return_value=(), - ), - patch( - "vidxp.application.get_capability", - return_value=capability, - ), - ): - result = service.prepare_models( - ("scene",), - progress_callback=events.append, - ) - prepare.assert_called_once() - context = prepare.call_args.args[0] - self.assertIsInstance(context, PreparationContext) - self.assertEqual(context.device, "cuda") - self.assertEqual(result["device"], "cuda") - self.assertEqual(events[0]["stage"], "scene_model") + self.assertEqual(result.prepared, ("model",)) + self.assertIs( + prepare.call_args.args[0].runtime, + application.runtime, + ) - def test_clear_removes_only_known_run_state_after_clearing_collections(self): + def test_clear_delegates_storage_and_removes_known_state_only(self): with TemporaryDirectory() as directory: - index_directory = Path(directory) / "index" - index_directory.mkdir() - for name in ( - "index_status.json", - "manifest.json", - "timings.jsonl", - "failures.jsonl", - "run.complete.json", - ): - (index_directory / name).write_text("{}", encoding="utf-8") - unrelated = index_directory / "keep.txt" + application, backend = self.application(directory) + index = application.index_directory + index.mkdir(parents=True) + (index / "manifest.json").write_text("{}", encoding="utf-8") + unrelated = index / "keep.txt" unrelated.write_text("keep", encoding="utf-8") - checkpoint_directory = index_directory / "checkpoints" - checkpoint_directory.mkdir() - (checkpoint_directory / "one.json").write_text( - "{}", - encoding="utf-8", - ) - - storage = Mock() - storage.__enter__ = Mock(return_value=storage) - storage.__exit__ = Mock(return_value=None) - service = VidXPService(index_directory) - with ( - patch( - "vidxp.application.indexing_in_progress", - return_value=False, - ), - patch( - "vidxp.application.read_index_status", - return_value=None, - ), - patch( - "vidxp.application.IndexStorage", - return_value=storage, - ), - ): - cleared = service.clear_index() + backend.indexing_in_progress.return_value = False + backend.status.return_value = None - self.assertTrue(cleared) - storage.clear.assert_called_once_with() - self.assertTrue(unrelated.is_file()) - self.assertFalse( - (index_directory / "index_status.json").exists() - ) - self.assertFalse(checkpoint_directory.exists()) + self.assertTrue(application.clear_index()) - def test_clear_translates_missing_storage_dependency(self): - with TemporaryDirectory() as directory: - service = VidXPService(directory) - with ( - patch( - "vidxp.application.indexing_in_progress", - return_value=False, - ), - patch( - "vidxp.application.read_index_status", - return_value=None, - ), - patch( - "vidxp.application.IndexStorage", - side_effect=ModuleNotFoundError( - "No module named 'chromadb'", - name="chromadb", - ), - ), - ): - with self.assertRaisesRegex( - RuntimeError, - r'chromadb is unavailable.*pip install "vidxp\[storage\]"', - ): - service.clear_index() + backend.clear.assert_called_once() + self.assertFalse((index / "manifest.json").exists()) + self.assertTrue(unrelated.exists()) if __name__ == "__main__": diff --git a/tests/test_benchmark_cli.py b/tests/test_benchmark_cli.py index 21abef4..35f8d5a 100644 --- a/tests/test_benchmark_cli.py +++ b/tests/test_benchmark_cli.py @@ -31,7 +31,7 @@ def test_benchmark_uses_global_device_and_json_output(self): with ( patch.object( cli, - "VidXPService", + "create_application", return_value=service, ), patch( diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 07847ec..51ab60b 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -2,23 +2,20 @@ from pydantic import BaseModel, ValidationError +from vidxp.capabilities.actor.config import ActorConfig from vidxp.capabilities.contracts import ( - CapabilityContext, CapabilityDefinition, + CapabilityExecutor, CapabilityIndexResult, CapabilityInput, CapabilityOutput, + CapabilityPlugin, OperationDefinition, ) -from vidxp.capabilities.actor.config import ActorConfig from vidxp.capabilities.dialogue.config import DialogueConfig from vidxp.capabilities.registry import ( - CAPABILITIES, - capability_names, - collection_names, - index_capability_names, - preparable_capability_names, - validate_capability_options, + CapabilityRegistry, + create_capability_registry, ) from vidxp.capabilities.scene.config import SceneConfig from vidxp.core.contracts import IndexConfig @@ -34,114 +31,101 @@ class ExampleOutput(CapabilityOutput): class CapabilityTests(unittest.TestCase): - def test_registry_is_explicit_and_drives_index_collections(self): + def setUp(self): + self.registry = create_capability_registry() + + def test_registry_drives_capability_metadata(self): self.assertEqual( - capability_names(), + self.registry.names(), ("dialogue", "scene", "actor"), ) + self.assertEqual(self.registry.index_names(), self.registry.names()) self.assertEqual( - index_capability_names(), - capability_names(), - ) - self.assertEqual( - preparable_capability_names(), - ("dialogue", "scene"), + self.registry.preparable_names(), + ("dialogue", "scene", "actor"), ) self.assertEqual( - collection_names(), + self.registry.collection_names(), { "dialogue": "dialogue", "scene": "scene", "actor": "actor", }, ) - self.assertEqual(collection_names(("scene",)), {"scene": "scene"}) - - def test_registered_operations_use_pydantic_contracts(self): - for capability in CAPABILITIES.values(): - self.assertIsInstance(capability, BaseModel) - for operation in capability.operations.values(): - self.assertIsInstance(operation, BaseModel) - self.assertTrue( - issubclass(operation.input_model, BaseModel) - ) - self.assertTrue( - issubclass(operation.output_model, BaseModel) - ) - def test_capability_contracts_are_frozen_and_schema_validated(self): + def test_registered_operations_are_schema_only_metadata(self): + self.assertNotIn("handler", OperationDefinition.model_fields) + for definition in self.registry.definitions.values(): + self.assertIsInstance(definition, BaseModel) + for operation in definition.operations.values(): + self.assertTrue(issubclass(operation.input_model, BaseModel)) + self.assertTrue(issubclass(operation.output_model, BaseModel)) + + def test_executor_handlers_match_declared_operations(self): + for name, definition in self.registry.definitions.items(): + self.assertEqual( + set(self.registry.executor(name).operations), + set(definition.operations), + ) + + def test_contracts_are_frozen_and_index_metadata_is_complete(self): with self.assertRaises(ValidationError): - CAPABILITIES["scene"].name = "changed" + self.registry.get("scene").name = "changed" with self.assertRaises(ValidationError): CapabilityDefinition( name="broken", description="Incomplete index integration.", extra="broken", collection_name="broken", - indexer=lambda **_: None, ) with self.assertRaises(ValidationError): - CapabilityIndexResult( - summary={}, - timings={"index": -1}, - ) + CapabilityIndexResult(summary={}, timings={"index": -1}) - def test_built_in_settings_are_capability_owned_and_validated(self): - self.assertIs(CAPABILITIES["dialogue"].config_model, DialogueConfig) - self.assertIs(CAPABILITIES["scene"].config_model, SceneConfig) - self.assertIs(CAPABILITIES["actor"].config_model, ActorConfig) + def test_built_in_settings_are_owned_and_validated(self): + self.assertIs( + self.registry.get("dialogue").config_model, + DialogueConfig, + ) + self.assertIs(self.registry.get("scene").config_model, SceneConfig) + self.assertIs(self.registry.get("actor").config_model, ActorConfig) - options = validate_capability_options( + options = self.registry.validate_options( ("scene",), {"scene": {"batch_size": 4, "model": "test-model"}}, ) - - self.assertEqual( - options["scene"], - {"batch_size": 4, "model": "test-model"}, - ) + self.assertEqual(options["scene"]["batch_size"], 4) + self.assertEqual(options["scene"]["model"], "test-model") with self.assertRaises(ValidationError): - validate_capability_options( + self.registry.validate_options( ("actor",), {"actor": {"match_threshold": 2}}, ) - def test_core_config_has_no_built_in_capability_fields(self): - fields = IndexConfig.__dataclass_fields__ - for name in ( - "sentence_model", - "whisper_model", - "clip_model", - "dialogue_batch_size", - "scene_batch_size", - "actor_batch_size", - "face_match_threshold", - ): - self.assertNotIn(name, fields) - - def test_operation_validates_both_input_and_output(self): - operation = OperationDefinition( - input_model=ExampleInput, - output_model=ExampleOutput, - handler=lambda _context, request: { - "doubled": request.value * 2 + def test_registry_rejects_executor_metadata_drift(self): + definition = CapabilityDefinition( + name="export", + description="Export.", + extra="export", + operations={ + "run": OperationDefinition( + input_model=ExampleInput, + output_model=ExampleOutput, + requires_index=False, + ) }, - requires_index=False, ) - - result = operation.invoke( - CapabilityContext(config=None), - {"value": 3}, - ) - - self.assertEqual(result, ExampleOutput(doubled=6)) - with self.assertRaises(ValidationError): - operation.invoke( - CapabilityContext(config=None), - {"value": 3, "unexpected": True}, + registry = CapabilityRegistry( + ( + CapabilityPlugin( + definition=definition, + executor_factory=lambda: CapabilityExecutor(), + ), ) + ) + with self.assertRaisesRegex(RuntimeError, "operation handlers"): + registry.executor("export") - def test_operation_only_capability_needs_no_dummy_indexer(self): + def test_operation_only_capability_needs_no_index_metadata(self): capability = CapabilityDefinition( name="export", description="Export results.", @@ -150,37 +134,41 @@ def test_operation_only_capability_needs_no_dummy_indexer(self): "run": OperationDefinition( input_model=ExampleInput, output_model=ExampleOutput, - handler=lambda _context, request: { - "doubled": request.value * 2 - }, requires_index=False, ) }, ) - - self.assertIsNone(capability.indexer) self.assertIsNone(capability.collection_name) + self.assertIsNone(capability.execution_group) - def test_shared_visual_handler_is_grouped_without_name_switches(self): + def test_visual_execution_group_is_explicit(self): self.assertEqual( - _index_groups(("dialogue", "scene", "actor")), + _index_groups( + ("dialogue", "scene", "actor"), + self.registry, + ), (("dialogue",), ("scene", "actor")), ) - self.assertIsNotNone(CAPABILITIES["scene"].index_processor) - self.assertIsNotNone(CAPABILITIES["actor"].index_processor) - self.assertIsNone(CAPABILITIES["dialogue"].index_processor) - - def test_capability_options_do_not_require_core_config_fields(self): - config = IndexConfig( - enabled_modalities=("ocr",), - collection_names={"ocr": "ocr"}, - capability_options={"ocr": {"language": "en"}}, + self.assertIsNotNone( + self.registry.executor("scene").index_processor ) - - self.assertEqual( - config.options_for("ocr"), - {"language": "en"}, + self.assertIsNotNone( + self.registry.executor("actor").index_processor ) + self.assertIsNone( + self.registry.executor("dialogue").index_processor + ) + + def test_core_config_has_no_provider_specific_fields(self): + fields = IndexConfig.__dataclass_fields__ + for name in ( + "sentence_model", + "whisper_model", + "clip_model", + "actor_batch_size", + "face_match_threshold", + ): + self.assertNotIn(name, fields) if __name__ == "__main__": diff --git a/tests/test_cli.py b/tests/test_cli.py index 9e996e7..7ed6368 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,9 +1,5 @@ import json -import os -import sys import unittest -from contextlib import redirect_stderr -from io import StringIO from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import Mock, patch @@ -11,195 +7,87 @@ from typer.testing import CliRunner from vidxp import cli -from vidxp.capabilities.actor.schemas import ( - ActorClusterSummary, - ActorDetection, - ActorRenderResult, +from vidxp.application_models import ( + CreateIndexCommand, + DependencyCheckResult, + IndexResult, + IndexStatus, + PrepareModelsResult, + SearchCommand, ) -from vidxp.capabilities.schemas import SearchHit, SearchResult -from vidxp.index_state import IndexNotReadyError - - -def result(modality, starts): - return SearchResult( - query_id="q", - query="example", - modality=modality, - hits=tuple( - SearchHit( - rank=index + 1, - video_id="video-1", - start=start, - end=start + 1, - score=-float(index), - raw_distance=float(index), - modality=modality, - source_id=f"source-{index}", - ) - for index, start in enumerate(starts) - ), - ) +from vidxp.capabilities.registry import create_capability_registry +from vidxp.capabilities.schemas import SearchResult +from vidxp.repositories import RepositoryConfig, RepositoryRegistry class CliTests(unittest.TestCase): def setUp(self): self.runner = CliRunner() - self.temporary_directory = TemporaryDirectory() - self.addCleanup(self.temporary_directory.cleanup) - self.config_file = ( - Path(self.temporary_directory.name) / "repositories.json" - ) self.service = Mock() - self.service.index_directory = Path("chroma_data") - self.service.device = None + self.service.registry = create_capability_registry() + self.service.index_directory = Path("repo/indexes/current") + self.service.layout.root = Path("repo") + self.service.runtime.backends.requested = "cpu" + self.registry = Mock(spec=RepositoryRegistry) + self.registry.path = Path("repositories.json") + self.repository = RepositoryConfig( + "default", + Path("repo"), + device="cpu", + configured=False, + ) def invoke(self, arguments): - with patch.object( - cli, - "VidXPService", - return_value=self.service, + with ( + patch.object( + cli, + "resolve_repository", + return_value=(self.registry, self.repository), + ), + patch.object( + cli, + "create_application", + return_value=self.service, + ), ): - return self.runner.invoke( - cli.app, - ["--config", str(self.config_file), *arguments], - ) + return self.runner.invoke(cli.app, arguments) def test_grouped_commands_are_exposed(self): - response = self.invoke(["--help"]) - - self.assertEqual(response.exit_code, 0) - for command in ( - "index", - "search", - "actors", - "repositories", - "benchmark", - "ui", - ): - self.assertIn(command, response.stdout) - - def test_removed_legacy_commands_are_rejected(self): - for command in ("videoindex", "dialogue", "scene", "actor"): - with self.subTest(command=command): - response = self.invoke([command]) - - self.assertEqual(response.exit_code, 2) - self.assertIn("No such command", response.output) + result = self.invoke(["--help"]) + self.assertEqual(result.exit_code, 0, result.output) + for command in ("index", "search", "actors", "doctor", "prepare"): + self.assertIn(command, result.output) - def test_search_returns_ranked_json_and_passes_top_k(self): - self.service.search.return_value = result( - "dialogue", - [7.5, 12.0], + def test_search_constructs_shared_command(self): + self.service.search.return_value = SearchResult( + query_id="scene:1", + query="yellow taxi", + modality="scene", ) - response = self.invoke( - [ - "search", - "dialogue", - "fresh bread", - "--top-k", - "2", - "--json", - ] + result = self.invoke( + ["search", "scene", "yellow taxi", "--top-k", "7", "--json"] ) - self.assertEqual(response.exit_code, 0, response.output) - payload = json.loads(response.stdout) - self.assertEqual( - [hit["start"] for hit in payload["hits"]], - [7.5, 12.0], - ) + self.assertEqual(result.exit_code, 0, result.output) self.service.search.assert_called_once_with( - "dialogue", - "fresh bread", - top_k=2, - ) - - def test_global_index_directory_and_device_configure_service(self): - with patch.object(cli, "VidXPService") as service_type: - service_type.return_value.index_status.return_value = { - "state": "missing", - } - response = self.runner.invoke( - cli.app, - [ - "--config", - str(self.config_file), - "--index-dir", - "custom-index", - "--device", - "cuda", - "index", - "status", - "--json", - ], + SearchCommand( + modality="scene", + query="yellow taxi", + top_k=7, ) - - self.assertEqual(response.exit_code, 0, response.output) - service_type.assert_called_once_with(Path("custom-index"), device="cuda") - - def test_repository_commands_persist_and_select_named_indexes(self): - index_directory = ( - Path(self.temporary_directory.name) / "team-index" - ) - added = self.invoke( - [ - "repositories", - "add", - "team", - "--index-dir", - str(index_directory), - "--device", - "cuda", - "--use", - "--json", - ] - ) - listed = self.invoke(["repositories", "list", "--json"]) - - self.assertEqual(added.exit_code, 0, added.output) - self.assertEqual(json.loads(added.stdout)["name"], "team") - payload = json.loads(listed.stdout) - self.assertEqual(payload["active_repository"], "team") - configured = { - item["name"]: item for item in payload["repositories"] - } - self.assertEqual( - configured["team"]["index_directory"], - str(index_directory.resolve()), ) + self.assertEqual(json.loads(result.output)["query"], "yellow taxi") - def test_repository_removal_leaves_index_data_untouched(self): - index_directory = ( - Path(self.temporary_directory.name) / "team-index" - ) - index_directory.mkdir() - marker = index_directory / "keep" - marker.write_text("data", encoding="utf-8") - self.invoke( - [ - "repositories", - "add", - "team", - "--index-dir", - str(index_directory), - ] - ) - - removed = self.invoke( - ["repositories", "remove", "team", "--yes", "--json"] - ) - - self.assertEqual(removed.exit_code, 0, removed.output) - self.assertFalse(json.loads(removed.stdout)["index_deleted"]) - self.assertTrue(marker.is_file()) - - def test_index_create_uses_repeated_typed_modalities(self): - self.service.create_index.return_value = {"scene_frames": 10} + def test_index_constructs_shared_command(self): with TemporaryDirectory() as directory: - video = Path(directory) / "sample.mp4" + video = Path(directory) / "video.mp4" video.write_bytes(b"video") - response = self.invoke( + self.service.create_index.return_value = IndexResult( + summary={"scene_frames": 1} + ) + + result = self.invoke( [ "--format", "json", @@ -210,217 +98,66 @@ def test_index_create_uses_repeated_typed_modalities(self): "scene", "--frame-stride", "5", - "--option", - "scene.batch_size=4", - "--option", - "scene.model=test-model", ] ) - self.assertEqual(response.exit_code, 0, response.output) - self.assertEqual(json.loads(response.stdout), {"scene_frames": 10}) - self.service.create_index.assert_called_once() - call = self.service.create_index.call_args - self.assertEqual(call.kwargs["modalities"], ("scene",)) - self.assertEqual(call.kwargs["frame_stride"], 5) - self.assertEqual( - call.kwargs["capability_options"], - {"scene": {"batch_size": 4, "model": "test-model"}}, - ) - - def test_index_status_reports_missing_index_as_json(self): - self.service.index_status.return_value = { - "state": "missing", - "message": "No local video index was found.", - } - - response = self.invoke(["index", "status", "--json"]) - - self.assertEqual(response.exit_code, 0, response.output) - self.assertEqual(json.loads(response.stdout)["state"], "missing") - - def test_index_clear_requires_explicit_confirmation_for_automation(self): - self.service.clear_index.return_value = True - - response = self.invoke(["index", "clear", "--yes", "--json"]) + self.assertEqual(result.exit_code, 0, result.output) + command = self.service.create_index.call_args.args[0] + self.assertIsInstance(command, CreateIndexCommand) + self.assertEqual(command.modalities, ("scene",)) + self.assertEqual(command.frame_stride, 5) - self.assertEqual(response.exit_code, 0, response.output) - self.assertTrue(json.loads(response.stdout)["cleared"]) - self.service.clear_index.assert_called_once_with() + def test_status_serializes_shared_model(self): + self.service.index_status.return_value = IndexStatus( + schema_version=1, + state="missing", + stage="status", + message="No index.", + repository_root=Path("repo"), + index_directory=Path("repo/indexes/current"), + ) - def test_doctor_and_prepare_use_the_reusable_service(self): - self.service.check_dependencies.return_value = { - "ok": True, - "modalities": ["scene"], - "checks": [{"name": "CLIP", "ok": True, "error": None}], - } - self.service.prepare_models.return_value = { - "prepared": ["ViT-B/32"], - "modalities": ["scene"], - "device": "cpu", - } + result = self.invoke(["index", "status", "--json"]) - checked = self.invoke( - ["doctor", "--modalities", "scene", "--json"] - ) - prepared = self.invoke([ - "prepare", - "--modalities", - "scene", - "--option", - "scene.model=test-model", - "--json", - ]) + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(json.loads(result.output)["state"], "missing") - self.assertTrue(json.loads(checked.stdout)["ok"]) - self.assertEqual( - json.loads(prepared.stdout)["prepared"], - ["ViT-B/32"], + def test_doctor_and_prepare_use_shared_models(self): + self.service.check_dependencies.return_value = DependencyCheckResult( + ok=True, + modalities=("scene",), + checks=(), ) - self.service.check_dependencies.assert_called_once_with(("scene",)) - self.service.prepare_models.assert_called_once() - self.assertEqual( - self.service.prepare_models.call_args.kwargs[ - "capability_options" - ], - {"scene": {"model": "test-model"}}, + self.service.prepare_models.return_value = PrepareModelsResult( + prepared=("scene-model",), + modalities=("scene",), + runtime={"torch_device": "cpu"}, ) - def test_ui_receives_the_selected_service_configuration(self): - self.service.index_directory = Path("selected-index") - self.service.device = "cuda" - from vidxp import frontend - - with ( - patch.dict(os.environ, {}, clear=False), - patch.object(frontend, "SERVICE"), - patch.object(frontend, "SAVED_VIDEO_PATH"), - patch.object(frontend, "ACTOR_OUTPUT_PATH"), - patch.object(frontend, "main") as launch, - ): - response = self.invoke( - ["ui", "--host", "0.0.0.0", "--port", "8501"] - ) - - self.assertEqual(response.exit_code, 0, response.output) - launch.assert_called_once_with( - ["--server.address=0.0.0.0", "--server.port=8501"] - ) - self.assertEqual(os.environ["VIDXP_DEVICE"], "cuda") - self.assertEqual( - os.environ["VIDXP_INDEX_DIR"], - "selected-index", - ) - - def test_actor_commands_expose_clusters_detections_and_rendering(self): - cluster = ActorClusterSummary( - cluster_id="3", - video_id="video-1", - detection_count=4, - first_timestamp=1.0, - last_timestamp=8.0, + checked = self.invoke( + ["doctor", "--modalities", "scene", "--json"] ) - self.service.actor_clusters.return_value = (cluster,) - self.service.actor_detections.return_value = [ - ActorDetection( - detection_id="d2", - cluster_id="3", - frame_index=2, - timestamp=1.5, - bbox=(1, 2, 3, 0), - dataset="local", - split="local", - run_id="default", - video_id="video-1", - modality="actor", - source_id="actor:d2", - ) - ] - self.service.render_actor.return_value = ActorRenderResult( - output_path=Path("actor.mp4"), - detection_count=4, + prepared = self.invoke( + ["prepare", "--modalities", "scene", "--json"] ) - listed = self.invoke(["actors", "list", "--json"]) - inspected = self.invoke(["actors", "inspect", "3", "--json"]) - with TemporaryDirectory() as directory: - source = Path(directory) / "source.mp4" - source.write_bytes(b"video") - rendered = self.invoke( - [ - "actors", - "render", - "3", - str(source), - "--output", - "actor.mp4", - "--json", - ] - ) - - self.assertEqual(json.loads(listed.stdout)["count"], 1) + self.assertEqual(checked.exit_code, 0, checked.output) + self.assertEqual(prepared.exit_code, 0, prepared.output) self.assertEqual( - json.loads(inspected.stdout)["detection_count"], - 1, + self.service.check_dependencies.call_args.args[0].modalities, + ("scene",), ) self.assertEqual( - json.loads(rendered.stdout)["output_path"], - "actor.mp4", - ) - - def test_benchmark_commands_are_exposed(self): - response = self.invoke(["benchmark", "--help"]) - - self.assertEqual(response.exit_code, 0) - self.assertIn("didemo", response.stdout) - self.assertIn("hirest", response.stdout) - - def test_version_options_report_installed_package_version(self): - for option in ("--version", "-V"): - with self.subTest(option=option): - response = self.invoke([option]) - - self.assertEqual(response.exit_code, 0) - self.assertEqual( - response.stdout.strip(), - f"VidXP {cli.__version__}", - ) - - def test_main_emits_uniform_json_for_runtime_errors(self): - self.service.search.side_effect = IndexNotReadyError( - "Index is not ready." + self.service.prepare_models.call_args.args[0].modalities, + ("scene",), ) - stderr = StringIO() - arguments = [ - "vidxp", - "--config", - str(self.config_file), - "--format", - "json", - "search", - "scene", - "yellow taxi", - ] - with ( - patch.object(sys, "argv", arguments), - patch.object( - cli, - "VidXPService", - return_value=self.service, - ), - redirect_stderr(stderr), - self.assertRaises(SystemExit) as raised, - ): - cli.main() - self.assertEqual(raised.exception.code, 1) - payload = json.loads(stderr.getvalue()) - self.assertFalse(payload["ok"]) - self.assertEqual( - payload["error"]["message"], - "Index is not ready.", + def test_invalid_capability_is_a_cli_parameter_error(self): + result = self.invoke( + ["doctor", "--modalities", "unknown", "--json"] ) - self.assertEqual(payload["error"]["exit_code"], 1) + self.assertNotEqual(result.exit_code, 0) + self.assertIn("Unknown capability", result.output) if __name__ == "__main__": diff --git a/tests/test_frontend.py b/tests/test_frontend.py index d0afd30..d8c18a4 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -1,257 +1,143 @@ import unittest from pathlib import Path from tempfile import TemporaryDirectory +from types import SimpleNamespace from unittest.mock import Mock, patch -from streamlit.testing.v1 import AppTest - from vidxp import frontend +from vidxp.application_models import DependencyCheckResult, IndexStatus +from vidxp.capabilities.registry import create_capability_registry from vidxp.capabilities.schemas import SearchHit, SearchResult -from vidxp.core.contracts import INDEX_SCHEMA_VERSION -from vidxp.index_state import IndexNotReadyError - - -READY_STATUS = { - "state": "ready", - "message": "Video indexing completed successfully.", - "summary": { - "index_schema_version": INDEX_SCHEMA_VERSION, - "dataset": "local", - "split": "local", - "run_id": "default", - "video_id": "video-1", - }, -} - - -def result_for(modality, timestamp): - return SearchResult( - query_id="query-1", - query="example", - modality=modality, - hits=( - SearchHit( - rank=1, - video_id="video-1", - start=timestamp, - end=timestamp + 1, - score=-0.1, - raw_distance=0.1, - modality=modality, - source_id=f"run:video-1:{modality}:1", - metadata={}, - ), - ), - ) -def frontend_harness(video_path, actor_output_path): - from pathlib import Path - from shutil import copyfile - from unittest.mock import Mock, patch - - from vidxp import frontend - from vidxp.capabilities.schemas import SearchHit, SearchResult - from vidxp.core.contracts import INDEX_SCHEMA_VERSION - - video_path = Path(video_path) - actor_output_path = Path(actor_output_path) - service = Mock() - service.check_dependencies.return_value = {"ok": True} - service.index_status.return_value = { - "state": "ready", - "message": "Video indexing completed successfully.", - "summary": { - "index_schema_version": INDEX_SCHEMA_VERSION, - "dataset": "local", - "split": "local", - "run_id": "default", - "video_id": "video-1", - }, - } - - def search_result(modality, timestamp): - return SearchResult( - query_id="q", - query="example", - modality=modality, - hits=( - SearchHit( - rank=1, - video_id="video-1", - start=timestamp, - end=timestamp + 1, - score=-0.1, - raw_distance=0.1, - modality=modality, - source_id=f"run:video-1:{modality}:1", - ), - ), - ) - - def search(modality, *_args, **_kwargs): - return search_result( - modality, - 17.25 if modality == "dialogue" else 42.5, - ) +class UploadedVideo: + name = "video.mp4" - def generate_actor_result(*_): - copyfile(video_path, actor_output_path) + def __init__(self, content: bytes): + self.content = content - service.search.side_effect = search - service.render_actor.side_effect = generate_actor_result - with ( - patch.object(frontend, "SERVICE", service), - patch.object(frontend, "SAVED_VIDEO_PATH", video_path), - patch.object(frontend, "ACTOR_OUTPUT_PATH", actor_output_path), - patch.object(frontend, "indexing_in_progress", return_value=False), - ): - frontend.run() + def getvalue(self): + return self.content -class FrontendSearchTests(unittest.TestCase): - def setUp(self): - self.service = Mock() - self.service.index_status.return_value = READY_STATUS +class FrontendTests(unittest.TestCase): + def tearDown(self): + frontend._configured_service.cache_clear() - def test_scene_search_uses_shared_service(self): - self.service.search.return_value = result_for("scene", 12.5) - with patch.object(frontend, "SERVICE", self.service): - result = frontend._run_search("scene", "yellow taxi") + def service(self, root: Path) -> Mock: + service = Mock() + service.layout.media = root / "media" + service.layout.artifacts = root / "artifacts" + service.layout.root = root + service.registry = create_capability_registry() + return service - self.assertEqual(result["timestamp"], 12.5) - self.assertEqual(result["hit"]["video_id"], "video-1") - self.service.search.assert_called_once_with( - "scene", - "yellow taxi", - top_k=1, + def test_service_is_composed_lazily_and_cached(self): + repository = SimpleNamespace( + index_directory=Path("repository"), + device="cpu", ) + application = Mock() + with ( + patch.object( + frontend, + "resolve_repository", + return_value=(Mock(), repository), + ), + patch.object( + frontend, + "create_application", + return_value=application, + ) as create, + ): + first = frontend._configured_service() + second = frontend._configured_service() - def test_dialogue_search_returns_a_renderable_result(self): - self.service.search.return_value = result_for("dialogue", 8) - with patch.object(frontend, "SERVICE", self.service): - result = frontend._run_search("dialogue", "fresh bread") - - self.assertEqual(result["type"], "dialogue") - self.assertEqual(result["timestamp"], 8.0) + self.assertIs(first, application) + self.assertIs(second, application) + create.assert_called_once() - def test_actor_search_uses_shared_service(self): + def test_uploaded_video_identity_controls_search_readiness(self): with TemporaryDirectory() as directory: - output_path = Path(directory) / "actor.mp4" - - def generate_result(*_): - output_path.write_bytes(b"video") - - self.service.render_actor.side_effect = generate_result - with ( - patch.object(frontend, "SERVICE", self.service), - patch.object(frontend, "ACTOR_OUTPUT_PATH", output_path), + service = self.service(Path(directory)) + with patch.object( + frontend, + "_configured_service", + return_value=service, ): - result = frontend._run_search("actor", "3") - - self.service.render_actor.assert_called_once() - self.assertEqual(result["type"], "actor") - - def test_search_error_is_returned_for_persistent_rendering(self): - self.service.index_status.side_effect = IndexNotReadyError( - "Index is not ready." - ) - with patch.object(frontend, "SERVICE", self.service): - result = frontend._run_search("scene", "yellow taxi") - - self.assertEqual(result, {"error": "Index is not ready."}) - - def test_starting_a_new_index_clears_the_previous_result(self): - state = { - frontend.SEARCH_RESULT_KEY: { - "type": "scene", - "timestamp": 12.5, - }, - frontend.INDEX_ERROR_KEY: "old error", - } - with patch.object(frontend.st, "session_state", state): - frontend._request_indexing() - - self.assertTrue(state[frontend.INDEX_REQUESTED_KEY]) - self.assertNotIn(frontend.SEARCH_RESULT_KEY, state) - self.assertNotIn(frontend.INDEX_ERROR_KEY, state) - - def test_available_index_modalities_excludes_missing_extras(self): - def dependency_status(modalities): - return {"ok": modalities == ("scene",)} - - self.service.check_dependencies.side_effect = dependency_status - with patch.object(frontend, "SERVICE", self.service): - available = frontend._available_index_modalities() - - self.assertEqual(available, ("scene",)) - - def test_indexing_passes_selected_modalities_to_the_worker(self): - uploaded_video = Mock() - uploaded_video.name = "source.mp4" - uploaded_video.getvalue.return_value = b"video" + uploaded = UploadedVideo(b"video") + matching = { + "state": "ready", + "video": {"sha256": frontend._video_hash(uploaded)}, + } + stale = { + "state": "ready", + "video": {"sha256": "different"}, + } + self.assertTrue(frontend._is_search_ready(matching, uploaded)) + self.assertFalse(frontend._is_search_ready(stale, uploaded)) + + def test_available_modalities_use_application_dependency_commands(self): with TemporaryDirectory() as directory: - saved_video = Path(directory) / "source-video.mp4" - with ( - patch.object(frontend, "SAVED_VIDEO_PATH", saved_video), - patch.object(frontend, "start_indexing") as start, - patch.object(frontend.st, "rerun"), - ): - frontend._run_indexing( - uploaded_video, - {}, - ("scene",), - ) + service = self.service(Path(directory)) - start.assert_called_once_with( - str(saved_video), - "source.mp4", - frontend.SERVICE, - modalities=("scene",), - ) + def check(command): + return DependencyCheckResult( + ok=command.modalities != ("actor",), + modalities=command.modalities, + checks=(), + ) - def test_cancellation_request_uses_the_worker_token(self): - state = {} - with ( - patch.object(frontend.st, "session_state", state), - patch.object(frontend, "cancel_indexing", return_value=True), - ): - frontend._request_cancellation() + service.check_dependencies.side_effect = check + with patch.object( + frontend, + "_configured_service", + return_value=service, + ): + available = frontend._available_index_modalities() - self.assertTrue(state[frontend.CANCEL_REQUESTED_KEY]) + self.assertEqual(available, ("dialogue", "scene")) - def test_search_results_render_and_survive_widget_reruns(self): + def test_search_uses_shared_command_and_returns_timestamp(self): with TemporaryDirectory() as directory: - video_path = Path(directory) / "video.mp4" - actor_output_path = Path(directory) / "actor.mp4" - video_path.write_bytes(b"video") - app = AppTest.from_function( - frontend_harness, - args=(str(video_path), str(actor_output_path)), - ).run() - - app.text_input[0].set_value("yellow taxi").run() - app.button[1].click().run() - self.assertEqual(list(app.exception), []) - self.assertEqual(len(app.get("video")), 2) - self.assertEqual( - app.session_state[frontend.SEARCH_RESULT_KEY]["type"], - "scene", + root = Path(directory) + service = self.service(root) + service.index_status.return_value = IndexStatus( + schema_version=1, + state="ready", + stage="complete", + message="ready", + repository_root=root, + index_directory=root / "indexes/current", ) - - app.selectbox[0].select("dialogue").run() - self.assertEqual(len(app.get("video")), 2) - self.assertEqual( - app.session_state[frontend.SEARCH_RESULT_KEY]["type"], - "scene", + service.search.return_value = SearchResult( + query_id="scene:1", + query="taxi", + modality="scene", + hits=( + SearchHit( + rank=1, + video_id="video-1", + start=12.5, + end=13.0, + score=-0.1, + raw_distance=0.1, + modality="scene", + source_id="scene:1", + ), + ), ) + with patch.object( + frontend, + "_configured_service", + return_value=service, + ): + result = frontend._run_search("scene", "taxi") - app.text_input[0].set_value("fresh bread").run() - app.button[1].click().run() - self.assertEqual( - app.session_state[frontend.SEARCH_RESULT_KEY]["type"], - "dialogue", - ) + self.assertEqual(result["timestamp"], 12.5) + command = service.search.call_args.args[0] + self.assertEqual(command.modality, "scene") + self.assertEqual(command.query, "taxi") if __name__ == "__main__": diff --git a/tests/test_index_worker.py b/tests/test_index_worker.py index aeb1a84..f5f3459 100644 --- a/tests/test_index_worker.py +++ b/tests/test_index_worker.py @@ -47,8 +47,8 @@ def setUp(self): index_worker._process = None index_worker._cancel_event = None self.service = Mock() - self.service.index_directory = Path("selected-index") - self.service.device = "cuda" + self.service.layout.root = Path("selected-repository") + self.service.runtime.backends.requested = "cuda" self.service.indexing_in_progress.return_value = False def tearDown(self): @@ -75,7 +75,7 @@ def test_worker_starts_indexing_in_a_separate_process(self): "video.mp4", "source.mp4", context.event, - "selected-index", + "selected-repository", "cuda", ("scene",), ), @@ -86,26 +86,31 @@ def test_worker_process_reconstructs_the_selected_service(self): service = Mock() with patch.object( index_worker, - "VidXPService", + "create_application", return_value=service, - ) as service_type: + ) as create: index_worker._run_indexing( "video.mp4", "source.mp4", FakeEvent(), - "selected-index", + "selected-repository", "cuda", ("scene",), ) - service_type.assert_called_once_with("selected-index", device="cuda") + settings = create.call_args.args[0] + self.assertEqual( + settings.repository_root, + Path("selected-repository"), + ) + self.assertEqual(settings.runtime_backend, "cuda") service.create_index.assert_called_once() self.assertEqual( - service.create_index.call_args.kwargs["source_name"], + service.create_index.call_args.args[0].source_name, "source.mp4", ) self.assertEqual( - service.create_index.call_args.kwargs["modalities"], + service.create_index.call_args.args[0].modalities, ("scene",), ) diff --git a/tests/test_indexing.py b/tests/test_indexing.py index b8ebb7d..d415f98 100644 --- a/tests/test_indexing.py +++ b/tests/test_indexing.py @@ -1,34 +1,42 @@ -import sys -import types import unittest from unittest.mock import Mock, patch import numpy as np -from vidxp.capabilities import visual as indexing_visual_module +from vidxp.capabilities.actor.indexing import _actor_records +from vidxp.capabilities.contracts import ( + CapabilityDefinition, + CapabilityExecutor, + CapabilityPlugin, +) from vidxp.capabilities.dialogue.indexing import ( build_dialogue_phrases, index_dialogue, ) +from vidxp.capabilities.registry import ( + CapabilityRegistry, + create_capability_registry, +) from vidxp.capabilities.visual import index_visuals -from vidxp.core.contracts import CancellationToken, IndexConfig, VideoSource +from vidxp.core.contracts import ( + CancellationToken, + IndexConfig, + VideoSource, +) from vidxp.core.video import FrameSample, VideoInfo +from vidxp.runtime import ModelRuntime +from vidxp.settings import VidXPSettings class CapturingStorage: def __init__(self): self.calls = [] - self.deleted_actor_clusters = [] def upsert(self, modality, records, **options): - self.calls.append((modality, list(records), options)) + records = list(records) + self.calls.append((modality, records, options)) return len(records) - def delete_records(self, modality, *, video_id, filters): - self.deleted_actor_clusters.append( - (modality, video_id, filters["cluster_id"]) - ) - class FakeEncoder: def __init__(self): @@ -36,102 +44,43 @@ def __init__(self): def encode(self, texts, **_): self.batches.append(list(texts)) - return np.asarray([[float(index), 1.0] for index, _ in enumerate(texts)]) - - -class FakeClipModel: - def __init__(self): - self.batch_sizes = [] - - def encode_image(self, images): - import torch - - self.batch_sizes.append(images.shape[0]) - return torch.ones((images.shape[0], 2), dtype=torch.float32) - - -class IndexingTests(unittest.TestCase): - def test_shared_visual_stream_accepts_a_registered_processor(self): - frame = np.zeros((2, 2, 3), dtype=np.uint8) - info = VideoInfo( - fps=10.0, - frame_count=1, - duration=0.1, - width=2, - height=2, + return np.asarray( + [[float(index), 1.0] for index, _ in enumerate(texts)] ) - processor = Mock() - processor.batch_size.return_value = 1 - processor.prepare.return_value = object() - processor.finalize.return_value = ({"ocr_frames": 1}, 1) - def stream(*_, **options): - options["stats"].frames_advanced = 1 - options["stats"].frames_materialized = 1 - return iter([[FrameSample(0, 0.0, frame)]]) - config = IndexConfig( - video_id="video-1", - enabled_modalities=("ocr",), - collection_names={"ocr": "ocr"}, - ) - with ( - patch( - "vidxp.capabilities.registry.get_capability", - return_value=Mock(index_processor=processor), - ), - patch( - "vidxp.capabilities.visual.probe_video", - return_value=info, - ), - patch( - "vidxp.capabilities.visual.iter_frame_batches", - side_effect=stream, - ), - patch( - "vidxp.capabilities.visual._rgb_samples", - side_effect=lambda samples: samples, - ), - ): - result = index_visuals( - VideoSource(path="unused.mp4"), - config=config, - storage=CapturingStorage(), - cancellation=CancellationToken(), +class IndexingTests(unittest.TestCase): + def runtime(self) -> ModelRuntime: + return ModelRuntime( + VidXPSettings( + repository_root="unused", + runtime_backend="cpu", ) + ) - processor.prepare.assert_called_once() - processor.process.assert_called_once() - processor.finalize.assert_called_once() - self.assertEqual(result.summary["ocr_frames"], 1) - self.assertEqual(result.summary["frame_operations"], 1) - - def test_timestamped_words_and_segments_keep_real_intervals(self): + def test_word_timestamps_are_grouped_without_interpolation(self): phrases = build_dialogue_phrases( [ { + "text": "ignored aggregate text", + "start": 0.0, + "end": 4.0, "words": [ - {"word": "one", "start": 0.0, "end": 0.4}, - {"word": "two", "start": 0.5, "end": 0.9}, - {"word": "three", "start": 1.0, "end": 1.4}, - ] - }, - {"text": "released ASR span", "start": 2.0, "end": 4.0}, + {"word": "one", "start": 0.1, "end": 0.5}, + {"word": "two", "start": 0.6, "end": 1.0}, + {"word": "three", "start": 1.2, "end": 1.8}, + ], + } ], words_per_phrase=2, ) self.assertEqual( [(item.text, item.start, item.end) for item in phrases], - [ - ("one two", 0.0, 0.9), - ("three", 1.0, 1.4), - ("released ASR", 2.0, 2.0 + 4.0 / 3.0), - ("span", 2.0 + 4.0 / 3.0, 4.0), - ], + [("one two", 0.1, 1.0), ("three", 1.2, 1.8)], ) - def test_segment_text_is_rechunked_with_interpolated_timestamps(self): + def test_segment_text_uses_interpolated_timestamps_as_fallback(self): phrases = build_dialogue_phrases( [ { @@ -151,7 +100,7 @@ def test_segment_text_is_rechunked_with_interpolated_timestamps(self): ], ) - def test_supplied_transcript_is_batched_without_video_or_whisper(self): + def test_transcript_indexing_batches_without_transcription(self): config = IndexConfig( dataset="hirest", split="test", @@ -179,7 +128,7 @@ def test_supplied_transcript_is_batched_without_video_or_whisper(self): ), patch( "vidxp.capabilities.dialogue.indexing.transcribe_video", - side_effect=AssertionError("video/Whisper path was used"), + side_effect=AssertionError("transcription was used"), ), ): stats = index_dialogue( @@ -187,270 +136,150 @@ def test_supplied_transcript_is_batched_without_video_or_whisper(self): config=config, storage=storage, cancellation=CancellationToken(), + runtime=self.runtime(), ) self.assertEqual([len(batch) for batch in encoder.batches], [2, 1]) self.assertEqual(stats["dialogue_phrases"], 3) - records = [ - record - for _, group, _ in storage.calls - for record in group - ] - self.assertEqual(len({record.source_id for record in records}), 3) - self.assertEqual( - set(records[0].metadata), - { - "dataset", - "split", - "run_id", - "video_id", - "modality", - "source_id", - "phrase_id", - "text", - "start", - "end", - }, - ) - - def test_scene_model_and_storage_writes_are_batched_with_full_metadata(self): - import torch + def test_visual_stream_uses_registered_processor_without_name_switches(self): + processor = Mock() + processor.batch_size.return_value = 1 + processor.prepare.return_value = object() + processor.finalize.return_value = ({"ocr_frames": 1}, 1) + definition = CapabilityDefinition( + name="ocr", + description="OCR.", + extra="ocr", + collection_name="ocr", + index_stage="visual_indexing", + execution_group="visual", + ) + registry = CapabilityRegistry( + ( + CapabilityPlugin( + definition=definition, + executor_factory=lambda: CapabilityExecutor( + indexer=index_visuals, + index_processor=processor, + ), + ), + ) + ) config = IndexConfig( - dataset="didemo", - split="test", - run_id="stride-2", video_id="video-1", - enabled_modalities=("scene",), - frame_stride=2, - capability_options={"scene": {"batch_size": 2}}, + enabled_modalities=("ocr",), + collection_names={"ocr": "ocr"}, ) - source = VideoSource(video_id="video-1", path="unused.mp4") - storage = CapturingStorage() - model = FakeClipModel() + info = VideoInfo(10.0, 1, 0.1, 2, 2) frame = np.zeros((2, 2, 3), dtype=np.uint8) - batches = [ - [ - FrameSample(0, 0.0, frame), - FrameSample(2, 0.2, frame), - ], - [FrameSample(4, 0.4, frame)], - ] - info = VideoInfo( - fps=10.0, - frame_count=5, - duration=0.5, - width=2, - height=2, - ) + + def stream(*_, **options): + options["stats"].frames_advanced = 1 + options["stats"].frames_materialized = 1 + return iter([[FrameSample(0, 0.0, frame)]]) with ( - patch("vidxp.capabilities.visual.probe_video", return_value=info), patch( - "vidxp.capabilities.visual.iter_frame_batches", - return_value=iter(batches), + "vidxp.capabilities.visual.probe_video", + return_value=info, ), patch( - "vidxp.capabilities.scene.indexing.get_clip_model", - return_value=( - model, - lambda _: torch.ones((3, 2, 2), dtype=torch.float32), - ), + "vidxp.capabilities.visual.iter_frame_batches", + side_effect=stream, ), ): - stats = index_visuals( - source, + result = index_visuals( + VideoSource(video_id="video-1", path="unused.mp4"), config=config, - storage=storage, + storage=CapturingStorage(), cancellation=CancellationToken(), - modalities=("scene",), - ).summary + registry=registry, + runtime=self.runtime(), + ) - self.assertEqual(model.batch_sizes, [2, 1]) - self.assertEqual(stats["scene_frames"], 3) - records = [ - record - for _, group, _ in storage.calls - for record in group - ] - self.assertEqual( - [record.metadata["frame_index"] for record in records], - [0, 2, 4], - ) - self.assertEqual(records[-1].metadata["end"], 0.5) - self.assertEqual(records[0].metadata["fps"], 10.0) - self.assertEqual(records[0].metadata["duration"], 0.5) + self.assertEqual(result.summary["ocr_frames"], 1) + processor.prepare.assert_called_once() + processor.process.assert_called_once() - def test_actor_only_records_have_stable_detection_metadata(self): - config = IndexConfig( - dataset="sample", - split="test", - run_id="actors", - video_id="video-1", - enabled_modalities=("actor",), - capability_options={"actor": {"minimum_detections": 2}}, - storage_batch_size=2, - ) - source = VideoSource(video_id="video-1", path="unused.mp4") - storage = CapturingStorage() - info = VideoInfo( - fps=10.0, - frame_count=4, - duration=0.4, - width=2, - height=2, + def test_scene_and_actor_share_one_frame_stream(self): + registry = create_capability_registry() + scene = registry.executor("scene").index_processor + actor = registry.executor("actor").index_processor + scene.prepare = Mock(return_value=object()) + actor.prepare = Mock(return_value=object()) + scene.process = Mock() + actor.process = Mock() + scene.batch_size = Mock(return_value=2) + actor.batch_size = Mock(return_value=1) + scene.finalize = Mock(return_value=({"scene_frames": 2}, 2)) + actor.finalize = Mock( + return_value=( + {"actor_frames": 2, "actor_detections": 0, "actor_clusters": 0}, + 2, + ) ) + info = VideoInfo(10.0, 2, 0.2, 2, 2) frame = np.zeros((2, 2, 3), dtype=np.uint8) - batches = [[ - FrameSample(0, 0.0, frame), - FrameSample(2, 0.2, frame), - ]] - fake_faces = types.SimpleNamespace( - face_locations=lambda _: [(1, 2, 3, 0)], - face_encodings=lambda *_args, **_kwargs: [ - np.asarray([1.0, 0.0]) - ], - face_distance=lambda known, _: np.asarray( - [0.0 for _ in known] - ), - ) - with ( - patch("vidxp.capabilities.visual.probe_video", return_value=info), - patch( - "vidxp.capabilities.visual.iter_frame_batches", - return_value=iter(batches), - ), - patch.dict(sys.modules, {"face_recognition": fake_faces}), - ): - stats = index_visuals( - source, - config=config, - storage=storage, - cancellation=CancellationToken(), - modalities=("actor",), - ).summary - - self.assertEqual(stats["actor_frames"], 2) - self.assertEqual(stats["actor_detections"], 2) - modality, records, options = storage.calls[0] - self.assertEqual(modality, "actor") - self.assertEqual(options["batch_size"], 2) - self.assertEqual( - [record.metadata["detection_id"] for record in records], - [ - "d000000000000-0000", - "d000000000002-0000", - ], - ) - self.assertEqual( - set(records[0].metadata), - { - "dataset", - "split", - "run_id", - "video_id", - "modality", - "source_id", - "detection_id", - "cluster_id", - "frame_index", - "timestamp", - "bbox_top", - "bbox_right", - "bbox_bottom", - "bbox_left", - }, - ) - - def test_scene_and_actor_share_one_probe_and_frame_stream(self): - import torch + def stream(*_, **options): + options["stats"].frames_advanced = 2 + options["stats"].frames_materialized = 2 + return iter( + [[FrameSample(0, 0.0, frame), FrameSample(1, 0.1, frame)]] + ) + frame_stream = Mock(side_effect=stream) config = IndexConfig( - dataset="sample", - split="test", - run_id="shared", video_id="video-1", enabled_modalities=("scene", "actor"), - frame_stride=2, - capability_options={ - "scene": {"batch_size": 2}, - "actor": { - "batch_size": 1, - "minimum_detections": 1, - }, - }, ) - source = VideoSource(video_id="video-1", path="unused.mp4") - storage = CapturingStorage() - model = FakeClipModel() - frame = np.zeros((2, 2, 3), dtype=np.uint8) - batches = [ - [ - FrameSample(0, 0.0, frame), - FrameSample(2, 0.2, frame), - ], - [FrameSample(4, 0.4, frame)], - ] - info = VideoInfo( - fps=10.0, - frame_count=5, - duration=0.5, - width=2, - height=2, - ) - frame_stream = Mock() - - def stream(*_, **options): - options["stats"].frames_advanced = 5 - options["stats"].frames_materialized = 3 - return iter(batches) - - frame_stream.side_effect = stream - - def consume_actor(samples, *, state, **_): - state.processed_frames += len(samples) - with ( patch( "vidxp.capabilities.visual.probe_video", return_value=info, - ) as probe, + ), patch( "vidxp.capabilities.visual.iter_frame_batches", frame_stream, ), - patch( - "vidxp.capabilities.scene.indexing.get_clip_model", - return_value=( - model, - lambda _: torch.ones((3, 2, 2), dtype=torch.float32), - ), - ), - patch( - "vidxp.capabilities.actor.indexing.process_actor_samples", - side_effect=consume_actor, - ) as actor_consumer, - patch( - "vidxp.capabilities.visual._rgb_samples", - wraps=indexing_visual_module._rgb_samples, - ) as rgb_conversion, ): result = index_visuals( - source, + VideoSource(video_id="video-1", path="unused.mp4"), config=config, - storage=storage, + storage=CapturingStorage(), cancellation=CancellationToken(), + registry=registry, + runtime=self.runtime(), ) - probe.assert_called_once_with("unused.mp4") frame_stream.assert_called_once() - self.assertEqual(frame_stream.call_args.kwargs["batch_size"], 2) - self.assertEqual(model.batch_sizes, [2, 1]) - self.assertEqual(actor_consumer.call_count, 2) - self.assertEqual(rgb_conversion.call_count, 2) - self.assertEqual(result.summary["source_frames_advanced"], 5) - self.assertEqual(result.summary["sampled_frames"], 3) - self.assertEqual(result.summary["frame_operations"], 6) + self.assertEqual(result.summary["processed_frames"], 2) + self.assertEqual(result.summary["scene_frames"], 2) + self.assertEqual(result.summary["actor_frames"], 2) + + def test_actor_records_preserve_stable_detection_metadata(self): + config = IndexConfig( + dataset="sample", + split="test", + run_id="actors", + video_id="video-1", + enabled_modalities=("actor",), + ) + records = _actor_records( + [ + { + "detection_id": "d000000000000-0000", + "cluster_id": "1", + "frame_index": 0, + "timestamp": 0.0, + "bbox": (1, 2, 3, 0), + } + ], + config, + ) + + self.assertEqual(records[0].metadata["cluster_id"], "1") + self.assertEqual(records[0].metadata["bbox_top"], 1) if __name__ == "__main__": diff --git a/tests/test_models.py b/tests/test_models.py index 50c144a..2895b21 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,109 +2,126 @@ import types import unittest from importlib.metadata import PackageNotFoundError +from pathlib import Path +from tempfile import TemporaryDirectory from unittest.mock import Mock, patch +from packaging.requirements import Requirement + from vidxp.capabilities.contracts import CapabilityDefinition from vidxp.capabilities.dialogue import models as dialogue_models -from vidxp.capabilities.registry import ( - dependency_checks, - requirements_for, - runtime_checks_for, - runtime_distributions, -) +from vidxp.capabilities.registry import create_capability_registry from vidxp.core.contracts import VideoSource from vidxp.dependencies import inspect_requirement -from packaging.requirements import Requirement +from vidxp.runtime import ModelRuntime +from vidxp.settings import VidXPSettings class ModelTests(unittest.TestCase): - def tearDown(self): - dialogue_models.clear_model_cache() + def runtime(self, root: str) -> ModelRuntime: + return ModelRuntime( + VidXPSettings( + repository_root=root, + model_cache=Path(root) / "models", + runtime_backend="cpu", + ) + ) - def test_dialogue_model_is_reused_across_videos(self): - constructor = Mock(return_value=object()) - fake_module = types.SimpleNamespace(SentenceTransformer=constructor) - with patch.dict(sys.modules, {"sentence_transformers": fake_module}): - first = dialogue_models.get_embedder("model-id", "cpu") - second = dialogue_models.get_embedder("model-id", "cpu") + def test_model_runtime_reuses_one_provider_instance(self): + with TemporaryDirectory() as directory: + runtime = self.runtime(directory) + constructor = Mock(return_value=object()) + fake_module = types.SimpleNamespace( + SentenceTransformer=constructor + ) + with patch.dict( + sys.modules, + {"sentence_transformers": fake_module}, + ): + first = dialogue_models.get_embedder( + runtime, + "model-id", + "revision", + ) + second = dialogue_models.get_embedder( + runtime, + "model-id", + "revision", + ) self.assertIs(first, second) - constructor.assert_called_once_with("model-id", device="cpu") + constructor.assert_called_once_with( + "model-id", + device="cpu", + cache_folder=str(Path(directory) / "models"), + revision="revision", + local_files_only=False, + ) - def test_scene_dependency_check_does_not_touch_other_capabilities(self): + def test_scene_dependency_check_is_capability_scoped(self): + registry = create_capability_registry() inspected = [] - versions = { - "chromadb": "1.0", - "clip-anytorch": "2.6.0", - "numpy": "2.1", - "opencv-python": "4.10", - "Pillow": "10.0", - "setuptools": "80.10.2", - "torch": "2.5", - } + def installed(name): + inspected.append(name) + return { + "chromadb": "1.5.9", + "numpy": "2.5.1", + "opencv-python-headless": "5.0.0.93", + "Pillow": "12.3.0", + "torch": "2.13.0", + "transformers": "5.14.1", + }[name] - with patch( - "vidxp.dependencies.version", - side_effect=lambda name: ( - inspected.append(name), - versions[name], - )[1], - ): - checks = dependency_checks(("scene",)) + with patch("vidxp.dependencies.version", side_effect=installed): + checks = registry.dependency_checks(("scene",)) self.assertTrue(all(check["ok"] for check in checks)) - self.assertNotIn("whisperx", inspected) - self.assertNotIn("face-recognition", inspected) + self.assertNotIn("faster-whisper", inspected) self.assertNotIn("sentence-transformers", inspected) + self.assertNotIn("pooch", inspected) - def test_supplied_transcript_only_requires_dialogue_search_dependencies(self): + def test_transcript_only_excludes_transcription_provider(self): + registry = create_capability_registry() source = VideoSource( transcript=({"text": "hello", "start": 0, "end": 1},) ) distributions = { requirement.name - for requirement in requirements_for( + for requirement in registry.requirements_for( ("dialogue",), source=source, ) } self.assertIn("sentence-transformers", distributions) - self.assertNotIn("whisperx", distributions) - self.assertNotIn("moviepy", distributions) - self.assertNotIn("opencv-python", distributions) + self.assertNotIn("faster-whisper", distributions) - def test_runtime_distributions_come_from_capability_registry(self): + def test_runtime_distributions_come_from_registry(self): + registry = create_capability_registry() with patch( "vidxp.capabilities.registry.installed_base_requirements", return_value=(), ): - distributions = runtime_distributions() + distributions = registry.runtime_distributions() - self.assertIn("clip-anytorch", distributions) - self.assertIn("face-recognition", distributions) + self.assertIn("transformers", distributions) + self.assertIn("faster-whisper", distributions) + self.assertIn("opencv-python-headless", distributions) + self.assertNotIn("clip-anytorch", distributions) + self.assertNotIn("face-recognition", distributions) self.assertEqual(len(distributions), len(set(distributions))) - def test_requirement_files_are_the_only_python_dependency_contract(self): + def test_requirement_files_are_dependency_contract(self): self.assertNotIn("dependencies", CapabilityDefinition.model_fields) - checks = runtime_checks_for( - ("dialogue",), - source=VideoSource( - transcript=({"text": "hello", "start": 0, "end": 1},) - ), - ) - self.assertEqual(checks, ()) + registry = create_capability_registry() self.assertEqual( - [check.label for check in runtime_checks_for( - ("dialogue",), - source=VideoSource(path="video.mp4"), - )], - ["FFmpeg"], + registry.runtime_checks_for(("dialogue",)), + (), ) - def test_requirement_check_uses_distribution_metadata_and_specifier(self): + def test_requirement_check_uses_distribution_metadata(self): requirement = Requirement("example>=2,<3") with patch("vidxp.dependencies.version", return_value="2.5"): self.assertTrue(inspect_requirement(requirement)["ok"]) diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 5338ddc..149b565 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -1,7 +1,7 @@ import unittest from pathlib import Path -from vidxp.capabilities.registry import CAPABILITIES +from vidxp.capabilities.registry import create_capability_registry ROOT = Path(__file__).resolve().parents[1] @@ -16,7 +16,7 @@ def test_capability_extras_read_capability_owned_requirements(self): 1, )[0] - for capability in CAPABILITIES.values(): + for capability in create_capability_registry().definitions.values(): extra_block = pyproject.split( f"{capability.extra} = {{ file = [", 1, @@ -38,7 +38,7 @@ def test_capability_extras_read_capability_owned_requirements(self): self.assertIn(f'storage = {{ file = ["{storage}"] }}', pyproject) self.assertEqual( sum( - line.strip() == "chromadb" + line.strip().startswith("chromadb") for path in (ROOT / "src" / "vidxp").rglob("*.txt") for line in path.read_text(encoding="utf-8").splitlines() ), @@ -56,14 +56,13 @@ def test_base_dependencies_exclude_capability_runtimes(self): for distribution in ( "chromadb", - "face-recognition", - "moviepy", + "faster-whisper", "numpy", - "opencv-python", + "opencv-python-headless", "sentence-transformers", "torch", - "whisperx", - "clip-anytorch", + "transformers", + "pooch", "streamlit", "srt", ): diff --git a/tests/test_runner.py b/tests/test_runner.py index 00db29a..52899e4 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -281,10 +281,12 @@ def test_transcript_only_run_does_not_request_transcription_dependencies(self): ): run_index([source], config, storage=FakeStorage()) - dependency_check.assert_called_once_with( - ("dialogue",), - source=source, + dependency_check.assert_called_once() + self.assertEqual( + dependency_check.call_args.args, + (("dialogue",),), ) + self.assertIs(dependency_check.call_args.kwargs["source"], source) def test_manifest_adds_transcription_model_when_run_later_needs_it(self): with TemporaryDirectory() as directory: @@ -321,10 +323,10 @@ def test_manifest_adds_transcription_model_when_run_later_needs_it(self): ) self.assertNotIn("transcription", first["models"]) - self.assertEqual( - second["models"]["transcription"], - dialogue_config(config).whisper_model, - ) + self.assertEqual( + second["models"]["transcription"]["model"], + dialogue_config(config).whisper_model, + ) def test_changed_input_is_not_silently_accepted_by_checkpoint(self): with TemporaryDirectory() as directory: diff --git a/tests/test_search.py b/tests/test_search.py index 3b10003..25fa844 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -12,6 +12,8 @@ stable_query_id, ) from vidxp.core.contracts import IndexConfig, IndexSchemaError +from vidxp.runtime import ModelRuntime +from vidxp.settings import VidXPSettings class FakeStorage: @@ -51,6 +53,12 @@ def setUp(self): run_id="run-1", enabled_modalities=("dialogue",), ) + self.runtime = ModelRuntime( + VidXPSettings( + repository_root="unused", + runtime_backend="cpu", + ) + ) def test_top_k_filter_order_distance_and_score_are_preserved(self): storage = FakeStorage( @@ -67,6 +75,7 @@ def test_top_k_filter_order_distance_and_score_are_preserved(self): result = search_dialogue( "fresh bread", config=self.config, + runtime=self.runtime, top_k=3, video_id="video-1", query_id="query-7", @@ -111,6 +120,7 @@ def test_nonpositive_top_k_is_rejected_before_querying(self): search_dialogue( "query", config=self.config, + runtime=self.runtime, top_k=0, storage=FakeStorage([]), ) @@ -135,6 +145,7 @@ def test_old_metadata_requires_an_explicit_reindex(self): search_dialogue( "query", config=self.config, + runtime=self.runtime, storage=storage, ) From 1b1e3ee638f03fd644d1413271b672a40b8261a3 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 01:53:41 +0500 Subject: [PATCH 03/46] refactor: enforce application platform boundaries --- .github/workflows/ci.yml | 107 +- .github/workflows/release-to-pypi.yml | 5 +- Dockerfile | 42 +- INSTALLATION_GUIDE.md | 261 +- README.md | 57 +- docs/CONTRIBUTING.md | 9 +- pyproject.toml | 17 + src/vidxp/application.py | 292 +- src/vidxp/application_models.py | 75 +- src/vidxp/benchmarks/didemo.py | 76 +- src/vidxp/benchmarks/hirest.py | 76 +- src/vidxp/capabilities/actor/cli.py | 2 - src/vidxp/capabilities/actor/definition.py | 28 +- src/vidxp/capabilities/actor/indexing.py | 13 +- src/vidxp/capabilities/actor/models.py | 83 +- src/vidxp/capabilities/actor/operations.py | 7 +- src/vidxp/capabilities/actor/results.py | 46 +- src/vidxp/capabilities/actor/specs.py | 49 + src/vidxp/capabilities/contracts.py | 72 +- src/vidxp/capabilities/dialogue/config.py | 16 - src/vidxp/capabilities/dialogue/definition.py | 62 +- src/vidxp/capabilities/dialogue/indexing.py | 32 +- src/vidxp/capabilities/dialogue/models.py | 62 +- src/vidxp/capabilities/dialogue/operations.py | 26 +- .../capabilities/dialogue/requirements.txt | 1 + src/vidxp/capabilities/dialogue/specs.py | 32 + src/vidxp/capabilities/registry.py | 324 +- src/vidxp/capabilities/scene/config.py | 8 - src/vidxp/capabilities/scene/definition.py | 37 +- src/vidxp/capabilities/scene/indexing.py | 23 +- src/vidxp/capabilities/scene/models.py | 27 +- src/vidxp/capabilities/scene/operations.py | 19 +- src/vidxp/capabilities/scene/requirements.txt | 1 + src/vidxp/capabilities/scene/specs.py | 15 + src/vidxp/capabilities/schemas.py | 8 +- src/vidxp/capabilities/search.py | 24 +- src/vidxp/capabilities/visual.py | 23 +- src/vidxp/cli.py | 43 +- src/vidxp/cli_commands/runtime.py | 63 +- src/vidxp/cli_commands/search.py | 76 +- src/vidxp/composition.py | 75 +- src/vidxp/core/manifest.py | 29 +- src/vidxp/core/runner.py | 131 +- src/vidxp/frontend.py | 64 +- src/vidxp/index_worker.py | 33 +- src/vidxp/infrastructure/local_index.py | 82 +- src/vidxp/model_contracts.py | 122 + src/vidxp/ports.py | 109 +- src/vidxp/requirements/server.txt | 2 + src/vidxp/requirements/storage.txt | 1 + src/vidxp/runtime.py | 246 +- src/vidxp/settings.py | 30 +- tests/test_actor_results.py | 11 + tests/test_application.py | 431 +- tests/test_benchmark_cli.py | 15 +- tests/test_capabilities.py | 239 +- tests/test_cli.py | 34 +- tests/test_frontend.py | 44 +- tests/test_index_worker.py | 28 +- tests/test_indexing.py | 26 +- tests/test_manifest.py | 85 +- tests/test_models.py | 323 +- tests/test_packaging.py | 64 +- tests/test_runner.py | 57 +- tests/test_search.py | 19 + uv.lock | 3761 +++++++++++++++++ 66 files changed, 7229 insertions(+), 1071 deletions(-) create mode 100644 src/vidxp/capabilities/actor/specs.py create mode 100644 src/vidxp/capabilities/dialogue/specs.py create mode 100644 src/vidxp/capabilities/scene/specs.py create mode 100644 src/vidxp/model_contracts.py create mode 100644 src/vidxp/requirements/server.txt create mode 100644 uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a10b564..58a7060 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,12 +36,12 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} run: | changed_files="$(git diff --name-only "$BASE_SHA" HEAD)" - if grep -Eq '^(\.github/workflows/ci\.yml$|src/|tests/|utils/|LICENSE$|MANIFEST\.in$|pyproject\.toml$)' <<< "$changed_files"; then + if grep -Eq '^(\.github/workflows/ci\.yml$|src/|tests/|utils/|LICENSE$|MANIFEST\.in$|pyproject\.toml$|uv\.lock$)' <<< "$changed_files"; then run_suite=true else run_suite=false fi - if grep -Eq '^(\.dockerignore$|\.github/workflows/ci\.yml$|Dockerfile$|compose\.yaml$|pyproject\.toml$|src/vidxp/(requirements/.*\.txt|capabilities/[^/]+/requirements\.txt)$)' <<< "$changed_files"; then + if grep -Eq '^(\.dockerignore$|\.github/workflows/ci\.yml$|Dockerfile$|compose\.yaml$|pyproject\.toml$|uv\.lock$|src/vidxp/(requirements/.*\.txt|capabilities/[^/]+/requirements\.txt)$)' <<< "$changed_files"; then run_container=true else run_container=false @@ -86,7 +86,14 @@ jobs: - name: Install test surface if: steps.scope.outputs.run_suite == 'true' - run: python -m pip install ".[scene,frontend,benchmarks]" + run: | + python -m pip install "uv==0.12.0" + uv sync \ + --frozen \ + --extra all \ + --extra frontend \ + --extra benchmarks \ + --no-dev - name: Lint if: steps.scope.outputs.run_suite == 'true' && matrix.python-version == '3.14' @@ -94,7 +101,7 @@ jobs: - name: Test if: steps.scope.outputs.run_suite == 'true' - run: python -m unittest discover -s tests -q + run: uv run --no-sync python -m unittest discover -s tests -q env: PYTHONPATH: src @@ -154,8 +161,11 @@ jobs: trap 'docker rm --force vidxp-ci >/dev/null 2>&1 || true' EXIT docker exec vidxp-ci vidxp --version + docker exec vidxp-ci vidxp doctor --json + docker exec vidxp-ci python -c \ + 'import chromadb, cv2, faster_whisper, huggingface_hub, pooch, psutil, sentence_transformers, torch, transformers; assert psutil.virtual_memory().available > 0' docker exec vidxp-ci python -c \ - 'import cv2, faster_whisper, sentence_transformers, transformers' + 'import importlib.metadata as m; assert not any(d.metadata["Name"].lower().startswith("nvidia-") for d in m.distributions())' docker exec vidxp-ci sh -c \ 'test ! -d "$HOME/.cache/huggingface" && test ! -d "$HOME/.cache/clip"' @@ -171,3 +181,90 @@ jobs: docker logs vidxp-ci exit 1 + + provider-platform-smoke: + name: CPU providers (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-24.04 + - windows-2025 + - macos-15 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: "3.14" + + - name: Install locked local-worker environment + run: | + python -m pip install "uv==0.12.0" + uv sync --frozen --extra local-worker --no-dev + + - name: Verify provider contracts without model downloads + run: | + uv run --no-sync python -m unittest discover -s tests -p "test_models.py" -q + uv run --no-sync python -m unittest discover -s tests -p "test_indexing.py" -q + env: + PYTHONPATH: src + VIDXP_ALLOW_MODEL_DOWNLOADS: "false" + + - name: Verify platform and CPU runtime + shell: bash + run: | + uv run --no-sync python - <<'PY' + from importlib import metadata + import platform + import chromadb + import cv2 + import faster_whisper + import huggingface_hub + import pooch + import psutil + import sentence_transformers + import torch + import transformers + + from vidxp.runtime import resolve_backends + + assert hasattr(cv2, "FaceDetectorYN") + assert hasattr(cv2, "FaceRecognizerSF") + assert hasattr(faster_whisper, "WhisperModel") + assert hasattr(faster_whisper, "BatchedInferencePipeline") + assert hasattr( + sentence_transformers.SentenceTransformer, + "encode_query", + ) + assert hasattr( + sentence_transformers.SentenceTransformer, + "encode_document", + ) + assert hasattr(transformers, "AutoModel") + assert hasattr(transformers, "AutoProcessor") + assert hasattr(chromadb, "PersistentClient") + assert hasattr(huggingface_hub, "snapshot_download") + assert hasattr(pooch, "retrieve") + assert psutil.virtual_memory().available > 0 + assert torch.version.cuda is None, torch.version.cuda + installed = { + distribution.metadata["Name"].lower().replace("_", "-") + for distribution in metadata.distributions() + if distribution.metadata.get("Name") + } + leaks = sorted( + name + for name in installed + if name in {"cuda-toolkit", "cuda-bindings", "triton"} + or name.startswith("nvidia-") + or name.startswith("pytorch-triton") + ) + assert not leaks, leaks + + profile = resolve_backends("cpu") + assert profile.torch_device == "cpu" + if platform.system() == "Darwin": + assert platform.machine() == "arm64" + PY diff --git a/.github/workflows/release-to-pypi.yml b/.github/workflows/release-to-pypi.yml index 29e637a..688d268 100644 --- a/.github/workflows/release-to-pypi.yml +++ b/.github/workflows/release-to-pypi.yml @@ -143,8 +143,11 @@ jobs: trap 'docker rm --force vidxp-release >/dev/null 2>&1 || true' EXIT docker exec vidxp-release vidxp --version + docker exec vidxp-release vidxp doctor --json docker exec vidxp-release python -c \ - 'import cv2, faster_whisper, sentence_transformers, transformers' + 'import chromadb, cv2, faster_whisper, huggingface_hub, pooch, psutil, sentence_transformers, torch, transformers; assert psutil.virtual_memory().available > 0' + docker exec vidxp-release python -c \ + 'import importlib.metadata as m; assert not any(d.metadata["Name"].lower().startswith("nvidia-") for d in m.distributions())' docker exec vidxp-release sh -c \ 'test ! -d "$HOME/.cache/huggingface" && test ! -d "$HOME/.cache/clip"' diff --git a/Dockerfile b/Dockerfile index 875ae2e..dc21950 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,39 +4,23 @@ FROM python:3.14-slim-trixie AS builder WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - git \ - && rm -rf /var/lib/apt/lists/* - -RUN python -m venv /opt/vidxp +RUN python -m pip install --no-cache-dir "uv==0.12.0" -ENV PATH="/opt/vidxp/bin:${PATH}" \ - PIP_DISABLE_PIP_VERSION_CHECK=1 - -COPY pyproject.toml README.md LICENSE MANIFEST.in ./ -COPY src/vidxp/requirements ./src/vidxp/requirements -COPY src/vidxp/capabilities/dialogue/requirements.txt ./src/vidxp/capabilities/dialogue/requirements.txt -COPY src/vidxp/capabilities/scene/requirements.txt ./src/vidxp/capabilities/scene/requirements.txt -COPY src/vidxp/capabilities/actor/requirements.txt ./src/vidxp/capabilities/actor/requirements.txt - -ARG PYTORCH_INDEX_URL="https://download.pytorch.org/whl/cpu" - -RUN --mount=type=cache,target=/root/.cache/pip \ - python -m pip install --upgrade pip setuptools wheel \ - && python -m pip install \ - --extra-index-url "${PYTORCH_INDEX_URL}" \ - -r src/vidxp/requirements/storage.txt \ - -r src/vidxp/requirements/frontend.txt \ - -r src/vidxp/capabilities/dialogue/requirements.txt \ - -r src/vidxp/capabilities/scene/requirements.txt \ - -r src/vidxp/capabilities/actor/requirements.txt +ENV UV_PROJECT_ENVIRONMENT="/opt/vidxp" \ + UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 +COPY pyproject.toml uv.lock README.md LICENSE MANIFEST.in ./ COPY src ./src -RUN --mount=type=cache,target=/root/.cache/pip \ - python -m pip install . \ - && python -m pip check +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync \ + --frozen \ + --extra local-worker \ + --extra frontend \ + --no-dev \ + --no-editable \ + && uv pip check --python /opt/vidxp/bin/python FROM python:3.14-slim-trixie AS runtime diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md index fc5c747..e060ec7 100644 --- a/INSTALLATION_GUIDE.md +++ b/INSTALLATION_GUIDE.md @@ -1,218 +1,221 @@ # Installation guide -The main [README](README.md) introduces the product and contains the shortest -install-and-run path. This guide covers platform prerequisites, source -installation, model preparation, and common first-run issues. +The main [README](README.md) contains the shortest path. This guide separates +the lightweight CLI from the native local-worker stack so CPU, CUDA, and model +dependencies are never installed accidentally. -## Prerequisites +## Supported local platforms -- Python 3.10 through 3.13 -- FFmpeg available on `PATH` when installing `dialogue` -- CMake and a C/C++ build toolchain when installing `actor` +- Python 3.11 through 3.14 +- Apple Silicon macOS 14 or newer +- Linux x86-64 +- Windows x86-64 +- FFmpeg and ffprobe on `PATH` for video/audio processing -The official `dlib` package is distributed through PyPI as source. Installing -VidXP therefore compiles it locally unless a compatible build is already cached -or provided by the environment. Changing from Python 3.13 to 3.11 does not -remove that requirement. +The actor capability uses OpenCV YuNet and SFace. It does not use `dlib`, CMake, +or a local C++ compiler. -On Windows, install CMake and the Microsoft C++ Build Tools. Do not add -platform-specific `dlib` wheels to this repository. +CPU execution is the current default on every platform. CUDA is a later runtime +profile. MPS is not selected by `auto` until its capability parity gate is +complete. -## Create an environment +## Install the lightweight CLI -Using a virtual environment keeps VidXP and its Python dependencies separate -from the system Python: +Use pipx to expose `vidxp` on `PATH` while keeping its dependencies isolated: ```bash -python -m venv venv +pipx install vidxp +vidxp --version ``` -```bash -# Windows -venv\Scripts\activate +This base install intentionally excludes Chroma, Torch, model providers, +Streamlit, and model weights. It is the correct foundation for a future remote +API client and for commands that do not execute local indexing. -# macOS/Linux -source venv/bin/activate -``` +## Install a published local worker -Upgrade pip before installing: - -```bash -python -m pip install --upgrade pip -``` +The `local-worker` extra contains Chroma and all current CPU capability +providers. The `frontend` extra adds Streamlit. -## Install from PyPI +### macOS -Install the lightweight command and application layer: +PyPI publishes native macOS Torch wheels without the Linux CUDA dependency +stack: ```bash -python -m pip install vidxp +pipx install "vidxp[local-worker,frontend]" ``` -Add only the indexing capabilities you need: +### Linux and Windows -```bash -python -m pip install "vidxp[dialogue]" -python -m pip install "vidxp[scene,actor]" -python -m pip install "vidxp[all]" -``` - -`all` contains all runtime capabilities. It does not include the `frontend` or -`benchmarks` extras: +Python package metadata cannot select a package index. Install the official CPU +Torch wheel into the pipx environment first, then install VidXP's local extras: ```bash -python -m pip install "vidxp[all,frontend]" +pipx install vidxp +pipx runpip vidxp install "torch==2.13.0+cpu" --index-url https://download.pytorch.org/whl/cpu +pipx runpip vidxp install "vidxp[local-worker,frontend]" ``` -The `benchmarks` extra contains evaluation tooling only. Combine it with the -capability being evaluated, for example `vidxp[scene,benchmarks]`. +The final command accepts the already installed `2.13.0+cpu` build because it +satisfies VidXP's published `torch>=2.13,<3` requirement. Repeat the staged +Torch step after recreating the pipx environment. + +Do not add a Torch wheel URL to VidXP dependency metadata. Direct URL +requirements are unnecessary here and can make published package metadata fail +repository validation. ## Install from source -From the repository root: +The repository lock is the preferred development and reproducible local-worker +path. It routes Torch through an explicit CPU-only index on Linux and Windows, +uses PyPI's native wheel on macOS, and never uses `extra-index-url` mixing: ```bash -python -m pip install . +uv sync --frozen --extra local-worker --extra frontend +uv run vidxp doctor ``` -Include all capabilities and the Streamlit interface: +Add benchmark-only dependencies when working on benchmark adapters: ```bash -python -m pip install ".[all,frontend]" +uv sync --frozen --extra local-worker --extra benchmarks ``` -Use an editable installation while developing: +For a non-repository virtual environment, uv can select the Torch backend +without embedding a direct URL in project metadata: ```bash -python -m pip install -e ".[all,frontend,benchmarks]" -``` +uv venv --python 3.14 -## Run the container +# macOS/Linux +source .venv/bin/activate -Stable container images include every runtime capability and the browser -interface, but not downloaded model weights. Docker Compose keeps indexes, -repository configuration, and model caches in the `vidxp-data` volume: +# Windows PowerShell +.venv\Scripts\Activate.ps1 -```bash -docker compose pull -docker compose run --rm vidxp vidxp prepare -docker compose up +uv pip install --torch-backend cpu "vidxp[local-worker,frontend]" ``` -The interface is available at `http://localhost:8501`. Set `VIDXP_PORT` in a -local `.env` file to publish a different host port. Set `VIDXP_IMAGE` to select -an immutable release instead of `latest`: +## Verify providers without downloading models -```dotenv -VIDXP_IMAGE=ghcr.io/grayhatdevelopers/vidxp:0.2.0 -VIDXP_PORT=8502 -VIDXP_DEVICE=cpu +```bash +vidxp doctor ``` -Run the image without Compose when persistent storage is not required: +Restrict the check to selected capabilities: ```bash -docker run --rm -p 8501:8501 ghcr.io/grayhatdevelopers/vidxp:latest +vidxp doctor --modalities scene +vidxp doctor --modalities dialogue,actor ``` -## Verify the installation +The doctor command checks installed distributions and imports each selected +native provider without constructing models or downloading model weights. -Display the installed package version: +## Prepare pinned models -```bash -vidxp --version -``` +Model weights are cached separately from the Python environment: -Check the Python and system dependencies needed by all indexing capabilities: +| Capability | Provider/model | +|---|---| +| Dialogue embeddings | `Qwen/Qwen3-Embedding-0.6B` | +| Transcription | `mobiuslabsgmbh/faster-whisper-large-v3-turbo` | +| Scene search | `google/siglip2-base-patch16-224` | +| Actor detection | OpenCV Zoo YuNet | +| Actor recognition | OpenCV Zoo SFace | + +Every model revision and weight checksum is pinned in its capability spec. +Prepare all selected models before indexing: ```bash -vidxp doctor +vidxp prepare ``` -`vidxp doctor` imports the selected dependencies but does not download model -weights. A base-only install therefore reports which capability extras are -missing. Restrict the check when diagnosing one capability: +Prepare a subset when disk or network capacity is limited: ```bash -vidxp doctor --modalities scene +vidxp prepare --modalities scene +vidxp prepare --modalities dialogue,actor ``` -If the frontend extra was installed, start the interface with `vidxp ui`. It -uses the active repository configured by `vidxp repositories use` and runs -until stopped with `Ctrl+C`. +Set `VIDXP_MODEL_CACHE` to choose the cache directory. Set +`VIDXP_ALLOW_MODEL_DOWNLOADS=false` for an offline worker after preparation. -## Prepare models - -Model weights are not part of the Python package: +## First indexing run -| Capability | Model | -|---|---| -| Dialogue embeddings | `sentence-transformers/all-MiniLM-L6-v2` | -| Transcription | WhisperX `large-v2` | -| Scene search | CLIP `ViT-B/32` | -| Word alignment | WhisperX model selected for the detected language | +```bash +vidxp index create samplevideo.mp4 +vidxp search scene "a yellow taxi on a city street" +``` -Download and cache the fixed dialogue, transcription, and scene models before -indexing: +Use fewer capabilities or a larger visual stride when appropriate: ```bash -vidxp prepare +vidxp index create samplevideo.mp4 \ + --modality scene \ + --frame-stride 5 ``` -WhisperX selects its alignment model after detecting the video's language. Cache -a known language explicitly when required: +Indexes and manifests live under the configured repository root. Model identity, +revision, checksum, precision, license, and resolved runtime state are recorded +for reproducibility. + +## Browser interface + +With the `frontend` extra installed: ```bash -vidxp prepare --option dialogue.alignment_language=en +vidxp ui ``` -SentenceTransformer and WhisperX use the Hugging Face cache; CLIP uses its own -user cache. These caches normally live outside the virtual environment, so -removing the environment does not necessarily remove downloaded model weights. +The browser interface uses the same application service and capability +contracts as the CLI. It does not implement a second indexing/search layer. -## First indexing run - -A successful installation means the commands and Python dependencies are -available. It does not mean the model weights have been downloaded. +## Run the current container -If `vidxp prepare` was not run first, the initial indexing command downloads any -missing models before processing the video: +The CPU image contains the local worker and Streamlit frontend, but not model +weights. Compose persists indexes and the model cache: ```bash -vidxp index create samplevideo.mp4 +docker compose pull +docker compose run --rm vidxp vidxp prepare +docker compose up ``` -Keep the terminal and internet connection open until VidXP reports that the -index is ready. Machine-learning dependencies and model caches can consume -several gigabytes, and the first run takes longer while models are downloaded. -Exact sizes depend on the platform and selected package builds. +The interface is available at `http://localhost:8501`. Configure the image, +port, and CPU backend in `.env` when needed: -VidXP saves local progress and the final state in -`chroma_data/index_status.json`. Search is unavailable until indexing completes. -If the process stops early, run indexing again; VidXP clears the incomplete -single-video index before rebuilding it. +```dotenv +VIDXP_IMAGE=ghcr.io/grayhatdevelopers/vidxp:0.2.1 +VIDXP_PORT=8501 +VIDXP_DEVICE=cpu +``` + +The separate API+MCP/Coolify composition belongs to the later server phases and +is not claimed by this image yet. ## Common problems -### `dlib` fails to install +### FFmpeg is not found -Confirm that CMake and a working C/C++ compiler are installed and visible from -the active terminal. On Windows, use the Microsoft C++ Build Tools. +Run `ffmpeg -version` and `ffprobe -version` in the same terminal. Install +FFmpeg or add its executable directory to `PATH`, then rerun `vidxp doctor`. -### FFmpeg is not found +### Linux pulls CUDA or NVIDIA packages -Run `ffmpeg -version` in the same terminal. Install FFmpeg or add its executable -directory to `PATH`, then rerun `vidxp doctor`. +Do not use a plain published `vidxp[local-worker]` install on Linux. Recreate +the environment with the staged pipx commands above, use `uv pip +--torch-backend cpu`, or use the repository's frozen uv lock. -### A search says the index is not ready +### A model cannot be used offline -Wait for the active indexing command to finish. If the previous process ended -or failed, run `vidxp index create` again to rebuild the incomplete local -index. +Temporarily allow downloads, run `vidxp prepare` for the selected capability, +then restore `VIDXP_ALLOW_MODEL_DOWNLOADS=false`. The runtime verifies pinned +checksums before recording an artifact as resolved. -### The first indexing run appears slow +### Search says the index is not ready -Check the terminal for model-download or indexing progress. Model preparation, -transcription, scene analysis, and actor detection are separate stages. Use -`vidxp prepare` before indexing or select fewer capabilities with -one or more `--modality` options. +Wait for indexing to finish. If the earlier process failed or was interrupted, +run the create command again. The current local worker resumes or rebuilds +according to its manifest and index state. diff --git a/README.md b/README.md index d151fda..e7ee3ff 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,8 @@
  • From the command line
  • Through its browser interface
  • -
  • As a desktop app
  • -
  • As an API
  • -
  • As an MCP, with your agents (coming soon ⚡️)
  • As an indexing and retrieval layer inside another application
  • +
  • Through API, MCP, and desktop interfaces as those roadmap phases land

@@ -81,36 +79,44 @@ Some ideas on how to use VidXP: ## Quick start -VidXP supports Python 3.10 through 3.13 and requires FFmpeg. See the -[installation guide](INSTALLATION_GUIDE.md) for the `dlib` compiler -requirements, source installation, model preparation, and troubleshooting. +VidXP supports Python 3.11 through 3.14 and requires FFmpeg for media +processing. See the [installation guide](INSTALLATION_GUIDE.md) for +platform-specific local-worker installation, model preparation, and +troubleshooting. -Install the command line and browser interface with +Install the lightweight command line in an isolated environment with [pipx](https://packaging.python.org/en/latest/guides/installing-stand-alone-command-line-tools/). -The command is available on your `PATH` while VidXP and its dependencies remain -isolated: ```bash -pipx install "vidxp[all,frontend]" +pipx install vidxp ``` -Install only the capabilities you need with a smaller selection such as -`pipx install "vidxp[scene,frontend]"` or -`pipx install "vidxp[dialogue,scene]"`. To import VidXP from another Python -project, install it into that project's environment instead; the -[installation guide](INSTALLATION_GUIDE.md) covers that path. +For a local CPU worker and browser UI, a source checkout is the strictest +cross-platform path because the lock routes only Torch through its official CPU +index on Linux and Windows: -Confirm the installed package and its runtime dependencies: +```bash +uv sync --frozen --extra local-worker --extra frontend +uv run vidxp doctor +``` + +On Apple Silicon macOS 14+, the published package can be installed directly +because PyPI provides the native CPU/MPS Torch wheel: ```bash -vidxp --version -vidxp doctor +pipx install "vidxp[local-worker,frontend]" ``` -The first use of each capability downloads its model weights. Download the fixed -dialogue, transcription, and scene models in advance with: +Linux and Windows published installs require a staged CPU Torch install; plain +`pipx install "vidxp[local-worker]"` can otherwise resolve PyPI's CUDA-enabled +Linux Torch build. The exact commands are in the +[installation guide](INSTALLATION_GUIDE.md#install-a-published-local-worker). + +Confirm the package and prepare the pinned models: ```bash +vidxp --version +vidxp doctor vidxp prepare ``` @@ -233,14 +239,17 @@ VidXP is an evolving beta. We'd love to hear your feedback and where you'd like | Capability | Model | |---|---| -| Dialogue embeddings | `sentence-transformers/all-MiniLM-L6-v2` | -| Transcription | WhisperX `large-v2` | -| Scene search | CLIP `ViT-B/32` | -| Word alignment | WhisperX model selected for the detected language | +| Dialogue embeddings | `Qwen/Qwen3-Embedding-0.6B` | +| Transcription | `mobiuslabsgmbh/faster-whisper-large-v3-turbo` | +| Scene search | `google/siglip2-base-patch16-224` | +| Actor detection | OpenCV Zoo YuNet | +| Actor recognition | OpenCV Zoo SFace | VidXP maintains the standard local CLI/UI index in `chroma_data/`. Starting a new local indexing run replaces the previous or incomplete local index. Model caches normally live outside this directory and outside the virtual environment. +Provider revisions and weight checksums are pinned in capability specs and +recorded in the index manifest. ## Documentation and project links diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 6847f70..57273cd 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -17,7 +17,7 @@ Thanks for contributing to VidXP (Video eXPlain). | `docs/` | Installation-linked guidance, benchmark research, and contribution notes | | `chroma_data/` | Local ChromaDB index and `index_status.json` readiness record (generated; do not commit) | | `benchmark_runs/` | Isolated programmatic and benchmark runs (generated; do not commit) | -| Model caches | Managed by WhisperX, SentenceTransformer, and CLIP outside the repository | +| Model caches | Managed by the shared model runtime outside the repository | The full local CLI/UI index uses up to three collections: `dialogue`, `scene`, and `actor`. Runs containing @@ -29,10 +29,7 @@ Follow the [installation guide](../INSTALLATION_GUIDE.md) or install from the package metadata directly. ```bash -python -m venv venv -# Windows: venv\Scripts\activate -# macOS/Linux: source venv/bin/activate -python -m pip install -e ".[all,frontend,benchmarks]" +uv sync --frozen --extra local-worker --extra frontend --extra benchmarks ``` Verify the environment: @@ -82,7 +79,7 @@ are stored, state whether an existing `chroma_data` index must be rebuilt. Run the complete automated suite with: ```bash -python -m unittest discover -s tests +uv run --frozen pytest ``` ## Pull requests diff --git a/pyproject.toml b/pyproject.toml index 32b2c6d..bbc95e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,13 @@ all = { file = [ "src/vidxp/capabilities/scene/requirements.txt", "src/vidxp/capabilities/actor/requirements.txt", ] } +local-worker = { file = [ + "src/vidxp/requirements/storage.txt", + "src/vidxp/capabilities/dialogue/requirements.txt", + "src/vidxp/capabilities/scene/requirements.txt", + "src/vidxp/capabilities/actor/requirements.txt", +] } +server = { file = ["src/vidxp/requirements/server.txt"] } frontend = { file = ["src/vidxp/requirements/frontend.txt"] } benchmarks = { file = ["src/vidxp/benchmarks/requirements.txt"] } @@ -95,6 +102,16 @@ target-version = "py311" [tool.ruff.lint] select = ["E4", "E7", "E9", "F"] +[tool.uv.sources] +torch = [ + { index = "pytorch-cpu", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[[tool.uv.index]] +name = "pytorch-cpu" +url = "https://download.pytorch.org/whl/cpu" +explicit = true + [tool.semantic_release] version_toml = ["pyproject.toml:project.version"] tag_format = "v{version}" diff --git a/src/vidxp/application.py b/src/vidxp/application.py index f8fc4b2..987f1e1 100644 --- a/src/vidxp/application.py +++ b/src/vidxp/application.py @@ -1,9 +1,11 @@ from __future__ import annotations +from contextlib import contextmanager from pathlib import Path -from typing import Any, Mapping, cast +from functools import wraps +from typing import Any, Callable, Iterator, Mapping, cast -from pydantic import BaseModel +from pydantic import BaseModel, JsonValue, ValidationError from vidxp.application_models import ( ApplicationError, @@ -12,10 +14,12 @@ DependencyCheckResult, DependencyUnavailableError, ErrorCategory, + InvalidRequestError, IndexResult, IndexStatus, PrepareModelsCommand, PrepareModelsResult, + ResourceNotFoundError, SearchCommand, ) from vidxp.capabilities.actor.schemas import ( @@ -23,30 +27,106 @@ ActorDetection, ActorRenderResult, ) +from vidxp.capabilities.actor.results import ActorClusterNotFoundError from vidxp.capabilities.contracts import ( + CapabilityDependencyError, CapabilityContext, + CapabilityRequestError, PreparationContext, - capability_install_hint, ) from vidxp.capabilities.registry import CapabilityRegistry from vidxp.capabilities.schemas import SearchResult -from vidxp.core.contracts import CancellationToken, IndexConfig, IndexSchemaError -from vidxp.core.indexing_common import ProgressCallback -from vidxp.core.manifest import ( - CHECKPOINT_DIRECTORY, - COMPLETION_FILE, - FAILURES_FILE, - MANIFEST_FILE, - TIMINGS_FILE, +from vidxp.core.contracts import ( + CancellationToken, + IndexCancelledError, + IndexConfig, + IndexSchemaError, ) +from vidxp.core.indexing_common import ProgressCallback from vidxp.index_state import ( - INDEX_STATUS_FILE, INDEX_STATUS_SCHEMA, IndexingInProgressError, + IndexNotReadyError, ) -from vidxp.ports import IndexBackend +from vidxp.ports import IndexBackend, ModelRuntimePort, ResourceLimitError +from vidxp.model_contracts import ModelArtifactUnavailableError from vidxp.repository_layout import RepositoryLayout -from vidxp.runtime import ModelRuntime +from vidxp.settings import VidXPSettings + + +def _validation_details( + exc: ValidationError, +) -> list[dict[str, JsonValue]]: + return [ + { + "type": item["type"], + "location": [str(part) for part in item["loc"]], + "message": item["msg"], + } + for item in exc.errors( + include_url=False, + include_context=False, + include_input=False, + ) + ] + + +def application_boundary(handler: Callable) -> Callable: + """Translate expected domain failures once for every transport.""" + + @wraps(handler) + def wrapped(*args: Any, **kwargs: Any) -> Any: + try: + return handler(*args, **kwargs) + except ApplicationError: + raise + except ActorClusterNotFoundError as exc: + raise ApplicationError( + "actor_cluster_not_found", + ErrorCategory.not_found, + "The requested actor cluster was not found.", + ) from exc + except IndexingInProgressError as exc: + raise ApplicationError( + "indexing_in_progress", + ErrorCategory.conflict, + "An indexing operation is already in progress.", + retryable=True, + ) from exc + except IndexNotReadyError as exc: + raise ApplicationError( + "index_not_ready", + ErrorCategory.conflict, + "The index is not ready.", + retryable=True, + ) from exc + except IndexSchemaError as exc: + raise ApplicationError( + "index_schema_incompatible", + ErrorCategory.conflict, + "The index schema is incompatible with this version.", + ) from exc + except IndexCancelledError as exc: + raise ApplicationError( + "operation_cancelled", + ErrorCategory.cancelled, + "The operation was cancelled.", + ) from exc + except ResourceLimitError as exc: + raise ApplicationError( + "resource_limit", + ErrorCategory.resource_limit, + "The worker does not currently have enough host capacity.", + retryable=True, + ) from exc + except ValidationError as exc: + raise InvalidRequestError( + errors=_validation_details(exc), + ) from exc + except CapabilityRequestError as exc: + raise InvalidRequestError() from exc + + return wrapped class VidXPApplication: @@ -55,16 +135,44 @@ class VidXPApplication: def __init__( self, *, + settings: VidXPSettings, layout: RepositoryLayout, registry: CapabilityRegistry, - runtime: ModelRuntime, + runtime: ModelRuntimePort, index_backend: IndexBackend, ) -> None: + self.settings = settings self.layout = layout self.registry = registry self.runtime = runtime self.index_backend = index_backend + @contextmanager + def _capability_dependencies( + self, + capabilities: tuple[str, ...], + *, + missing_resource: str | None = None, + ) -> Iterator[None]: + try: + yield + except FileNotFoundError as exc: + if missing_resource is not None: + raise ResourceNotFoundError(missing_resource) from exc + raise DependencyUnavailableError( + capabilities, + self.registry.install_hint(capabilities), + ) from exc + except ( + ModuleNotFoundError, + CapabilityDependencyError, + ModelArtifactUnavailableError, + ) as exc: + raise DependencyUnavailableError( + capabilities, + self.registry.install_hint(capabilities), + ) from exc + @property def index_directory(self) -> Path: return self.layout.local_index @@ -73,6 +181,7 @@ def index_directory(self) -> Path: def device(self) -> str: return self.runtime.backends.torch_device + @application_boundary def index_status(self) -> IndexStatus: stored = self.index_backend.status(self.index_directory) payload = ( @@ -93,12 +202,14 @@ def index_status(self) -> IndexStatus: ) return IndexStatus.model_validate(payload) + @application_boundary def active_config(self) -> tuple[IndexConfig, dict[str, Any]]: return self.index_backend.active_config( self.index_directory, device=self.device, ) + @application_boundary def create_index( self, command: CreateIndexCommand, @@ -113,10 +224,11 @@ def create_index( if self.registry.get(name).collection_name is None ] if non_indexable: - raise ValueError( - "These capabilities do not support indexing: " - + ", ".join(non_indexable) + raise CapabilityRequestError( + "One or more selected capabilities do not support indexing." ) + if not command.path.is_file(): + raise ResourceNotFoundError("media") self.layout.ensure_local_directories() config = IndexConfig.local( enabled_modalities=selected, @@ -129,21 +241,25 @@ def create_index( ), device=self.device, ) - return IndexResult( - summary=self.index_backend.create( - command.path, - config=config, - progress=progress_callback, - cancellation=cancellation, - source_name=command.source_name, - ) - ) + with self.runtime.scheduler.indexing(): + with self._capability_dependencies(selected): + return IndexResult( + summary=self.index_backend.create( + command.path, + config=config, + progress=progress_callback, + cancellation=cancellation, + source_name=command.source_name, + ) + ) + @application_boundary def indexing_in_progress(self) -> bool: return self.index_backend.indexing_in_progress( self._base_config() ) + @application_boundary def check_dependencies( self, command: DependencyCheckCommand, @@ -151,11 +267,12 @@ def check_dependencies( selected = self.registry.validate_names(command.modalities) checks = self.registry.dependency_checks(selected) return DependencyCheckResult( - ok=all(check["ok"] for check in checks), + ok=all(check.ok for check in checks), modalities=selected, checks=checks, ) + @application_boundary def prepare_models( self, command: PrepareModelsCommand, @@ -168,39 +285,36 @@ def prepare_models( command.capability_options, ) checks = self.registry.dependency_checks(selected) - failures = [check for check in checks if not check["ok"]] + failures = [check for check in checks if not check.ok] if failures: - details = "; ".join( - f"{check['name']}: {check['error']}" for check in failures - ) - extras = ",".join(self.registry.get(name).extra for name in selected) - raise ApplicationError( - "capability_dependencies_unavailable", - ErrorCategory.unavailable, - f"{details}. {capability_install_hint(extras)}", - details={"failures": failures}, + raise DependencyUnavailableError( + selected, + self.registry.install_hint(selected), ) prepared: list[str] = [] - for name in selected: - executor = self.registry.executor(name) - if executor.prepare is not None: - prepared.extend( - executor.prepare( - PreparationContext( - runtime=self.runtime, - settings=self.registry.get(name) - .config_model.model_validate(options[name]), - ), - progress_callback, - ) - ) + with self.runtime.scheduler.inference(): + with self._capability_dependencies(selected): + for name in selected: + executor = self.registry.executor(name) + if executor.prepare is not None: + prepared.extend( + executor.prepare( + PreparationContext( + runtime=self.runtime, + settings=self.registry.get(name) + .config_model.model_validate(options[name]), + ), + progress_callback, + ) + ) return PrepareModelsResult( prepared=tuple(prepared), modalities=selected, runtime=self.runtime.describe(), ) + @application_boundary def execute( self, capability: str, @@ -213,32 +327,43 @@ def execute( handler = self.registry.executor(capability).operations[operation] except KeyError as exc: available = ", ".join(definition.operations) or "none" - raise ValueError( - f"Capability {capability!r} has no operation {operation!r}. " - f"Available operations: {available}." + raise CapabilityRequestError( + f"Capability {capability!r} has no operation {operation!r}; " + f"available operations: {available}." ) from exc config = None if contract.requires_index: config, _ = self.active_config() if capability not in config.enabled_modalities: - raise ValueError( + raise CapabilityRequestError( f"The {capability} capability is not present in this index." ) request = contract.input_model.model_validate(payload) - try: - response = handler( - CapabilityContext(config=config, runtime=self.runtime), - request, - ) - except ModuleNotFoundError as exc: - dependency = exc.name or "optional dependency" - raise DependencyUnavailableError( - dependency, - capability_install_hint(definition.extra), - ) from exc - return contract.output_model.model_validate(response) + with self._capability_dependencies((capability,)): + if config is None: + with self.runtime.scheduler.inference(): + response = handler( + CapabilityContext( + config=None, + runtime=self.runtime, + ), + request, + ) + return contract.output_model.model_validate(response) + with self.index_backend.open_store(config) as storage: + with self.runtime.scheduler.inference(): + response = handler( + CapabilityContext( + config=config, + runtime=self.runtime, + storage=storage, + ), + request, + ) + return contract.output_model.model_validate(response) + @application_boundary def search(self, command: SearchCommand) -> SearchResult: return cast( SearchResult, @@ -249,10 +374,12 @@ def search(self, command: SearchCommand) -> SearchResult: ), ) + @application_boundary def actor_clusters(self) -> tuple[ActorClusterSummary, ...]: result = self.execute("actor", "clusters", {}) return tuple(result.clusters) + @application_boundary def actor_detections(self, cluster_id: str) -> list[ActorDetection]: result = self.execute( "actor", @@ -261,6 +388,7 @@ def actor_detections(self, cluster_id: str) -> list[ActorDetection]: ) return list(result.detections) + @application_boundary def render_actor( self, cluster_id: str, @@ -280,6 +408,7 @@ def render_actor( ), ) + @application_boundary def clear_index(self) -> bool: if not self.index_directory.exists(): return False @@ -291,7 +420,10 @@ def clear_index(self) -> bool: status = self.index_backend.status(self.index_directory) if status is not None and status.get("state") == "ready": try: - config, _ = self.active_config() + config, _ = self.index_backend.active_config( + self.index_directory, + device=self.device, + ) except (IndexSchemaError, KeyError, TypeError, ValueError): config = base_config else: @@ -299,28 +431,11 @@ def clear_index(self) -> bool: try: self.index_backend.clear(config) except ModuleNotFoundError as exc: - dependency = exc.name or "optional storage dependency" raise DependencyUnavailableError( - dependency, - capability_install_hint("storage"), + self.registry.index_names(), + self.registry.install_hint(self.registry.index_names()), ) from exc - for name in ( - INDEX_STATUS_FILE, - MANIFEST_FILE, - TIMINGS_FILE, - FAILURES_FILE, - COMPLETION_FILE, - ): - (self.index_directory / name).unlink(missing_ok=True) - checkpoint_directory = self.index_directory / CHECKPOINT_DIRECTORY - if checkpoint_directory.is_dir(): - for checkpoint in checkpoint_directory.glob("*.json"): - checkpoint.unlink() - try: - checkpoint_directory.rmdir() - except OSError: - pass return True def _base_config(self) -> IndexConfig: @@ -330,6 +445,3 @@ def _base_config(self) -> IndexConfig: collection_names=self.registry.collection_names(), device=self.device, ) - - -VidXPService = VidXPApplication diff --git a/src/vidxp/application_models.py b/src/vidxp/application_models.py index ebeb2c2..0e8ec03 100644 --- a/src/vidxp/application_models.py +++ b/src/vidxp/application_models.py @@ -4,7 +4,13 @@ from pathlib import Path from typing import Annotated, Any, Generic, Mapping, TypeAlias, TypeVar -from pydantic import BaseModel, ConfigDict, Field, StringConstraints +from pydantic import ( + BaseModel, + ConfigDict, + Field, + JsonValue, + StringConstraints, +) Identifier: TypeAlias = Annotated[ @@ -58,10 +64,10 @@ class ErrorCategory(StrEnum): class ErrorDetail(ApplicationModel): - code: str = Field(min_length=1) + code: str = Field(min_length=1, pattern=r"^[a-z][a-z0-9_]*$") category: ErrorCategory message: str = Field(min_length=1) - details: Mapping[str, Any] = Field(default_factory=dict) + details: dict[str, JsonValue] = Field(default_factory=dict) retryable: bool = False correlation_id: str | None = None @@ -104,15 +110,70 @@ def to_dict(self) -> dict[str, Any]: class DependencyUnavailableError(ApplicationError): - def __init__(self, dependency: str, install_hint: str) -> None: + def __init__( + self, + capabilities: tuple[str, ...], + install_hint: str, + ) -> None: + label = capabilities[0] if len(capabilities) == 1 else "selected" super().__init__( "dependency_unavailable", ErrorCategory.unavailable, - f"{dependency} is unavailable. {install_hint}", - details={"dependency": dependency}, + f"Dependencies for the {label} capability are unavailable. " + f"{install_hint}", + details={ + "capabilities": list(capabilities), + "install_hint": install_hint, + }, + ) + + +class InvalidRequestError(ApplicationError): + def __init__( + self, + *, + errors: list[dict[str, JsonValue]] | None = None, + ) -> None: + super().__init__( + "invalid_request", + ErrorCategory.validation, + "The request is invalid.", + details={"errors": errors or []}, ) +class ResourceNotFoundError(ApplicationError): + def __init__(self, resource: str) -> None: + super().__init__( + "resource_not_found", + ErrorCategory.not_found, + f"The requested {resource} was not found.", + details={"resource": resource}, + ) + + +class CapabilityProvenance(ApplicationModel): + distribution: str = Field(min_length=1) + entry_point: str = Field(min_length=1) + version: str | None = None + + +class DependencyKind(StrEnum): + distribution = "distribution" + runtime = "runtime" + + +class CapabilityDependencyCheck(ApplicationModel): + capability: str = Field(min_length=1) + provenance: CapabilityProvenance | None = None + kind: DependencyKind + name: str = Field(min_length=1) + requirement: str | None = None + installed_version: str | None = None + ok: bool + error: str | None = None + + class CreateIndexCommand(ApplicationModel): path: Path modalities: tuple[str, ...] @@ -162,7 +223,7 @@ class DependencyCheckCommand(ApplicationModel): class DependencyCheckResult(ApplicationModel): ok: bool modalities: tuple[str, ...] - checks: tuple[Mapping[str, Any], ...] + checks: tuple[CapabilityDependencyCheck, ...] class PrepareModelsResult(ApplicationModel): diff --git a/src/vidxp/benchmarks/didemo.py b/src/vidxp/benchmarks/didemo.py index bdf6752..a07012b 100644 --- a/src/vidxp/benchmarks/didemo.py +++ b/src/vidxp/benchmarks/didemo.py @@ -18,9 +18,14 @@ ) from vidxp.capabilities.scene.operations import search_scene from vidxp.capabilities.schemas import SearchHit +from vidxp.capabilities.registry import create_capability_registry from vidxp.core.contracts import IndexConfig, VideoSource -from vidxp.core.manifest import write_json_atomic +from vidxp.core.manifest import ManifestStore, write_json_atomic from vidxp.core.runner import run_index +from vidxp.core.storage import IndexStorage +from vidxp.infrastructure.local_index import LOCAL_INDEX_RUNTIME_CHECKS +from vidxp.runtime import ModelRuntime +from vidxp.settings import VidXPSettings DIDEMO_REVISION = "b6a555c8134581305d0ed4716fbc192860e0b88c" @@ -271,6 +276,8 @@ def _generate_predictions( config: IndexConfig, manifest: Mapping[str, Any], chunk_pooling: Literal["max", "mean"], + runtime: ModelRuntime, + storage: IndexStorage, ) -> list[list[list[int]]]: scene_counts = { video_id: int(video["summary"]["scene_frames"]) @@ -285,6 +292,8 @@ def _generate_predictions( top_k=scene_counts[video_id], video_id=video_id, query_id=str(annotation["annotation_id"]), + runtime=runtime, + storage=storage, ) predictions.append( [ @@ -383,6 +392,16 @@ def run_didemo( output_root=output_root, ) run_directory = config.run_directory + registry = create_capability_registry( + platform_runtime_checks=LOCAL_INDEX_RUNTIME_CHECKS + ) + runtime = ModelRuntime( + VidXPSettings( + repository_root=run_directory, + runtime_backend=device, + ), + allowed_specs=registry.model_specs(), + ) ensure_adapter_outputs(run_directory) subset = { "label": ( @@ -397,29 +416,40 @@ def run_didemo( "video_count": len({item["video"] for item in annotations}), } try: - manifest = run_index( - _video_sources( + with IndexStorage(config) as storage: + manifest = run_index( + _video_sources( + annotations, + media_directory=media_directory, + media_overrides=media_overrides, + ), + config, + reset=reset, + storage=storage, + manifest_store=ManifestStore( + config, + registry=registry, + runtime=runtime, + ), + registry=registry, + runtime=runtime, + ) + ensure_adapter_outputs(run_directory) + record_adapter_manifest( + run_directory, + benchmark="didemo", + subset=subset, + artifacts=artifacts, + state="predicting", + ) + predictions = _generate_predictions( annotations, - media_directory=media_directory, - media_overrides=media_overrides, - ), - config, - reset=reset, - ) - ensure_adapter_outputs(run_directory) - record_adapter_manifest( - run_directory, - benchmark="didemo", - subset=subset, - artifacts=artifacts, - state="predicting", - ) - predictions = _generate_predictions( - annotations, - config=config, - manifest=manifest, - chunk_pooling=chunk_pooling, - ) + config=config, + manifest=manifest, + chunk_pooling=chunk_pooling, + runtime=runtime, + storage=storage, + ) validate_predictions(predictions, annotations) predictions_path = run_directory / "predictions.json" ground_truth_path = run_directory / "ground_truth.subset.json" diff --git a/src/vidxp/benchmarks/hirest.py b/src/vidxp/benchmarks/hirest.py index 3e917a9..c564a0b 100644 --- a/src/vidxp/benchmarks/hirest.py +++ b/src/vidxp/benchmarks/hirest.py @@ -21,9 +21,14 @@ from vidxp.capabilities.dialogue.config import dialogue_config from vidxp.capabilities.dialogue.operations import search_dialogue from vidxp.capabilities.schemas import SearchHit +from vidxp.capabilities.registry import create_capability_registry from vidxp.core.contracts import IndexConfig, VideoSource -from vidxp.core.manifest import sha256_file, write_json_atomic +from vidxp.core.manifest import ManifestStore, sha256_file, write_json_atomic from vidxp.core.runner import run_index +from vidxp.core.storage import IndexStorage +from vidxp.infrastructure.local_index import LOCAL_INDEX_RUNTIME_CHECKS +from vidxp.runtime import ModelRuntime +from vidxp.settings import VidXPSettings HIREST_REVISION = "deffc169b4e8d51c1589d5512ad05da61e81bcee" @@ -312,6 +317,8 @@ def _generate_predictions( config: IndexConfig, manifest: Mapping[str, Any], temporal_window_fraction: float, + runtime: ModelRuntime, + storage: IndexStorage, ) -> dict[str, dict[str, dict[str, list[float]]]]: dialogue_counts = { video_id: int(video["summary"]["dialogue_phrases"]) @@ -325,6 +332,8 @@ def _generate_predictions( top_k=dialogue_counts[video], video_id=video, query_id=f"{prompt}\0{video}", + runtime=runtime, + storage=storage, ).hits if not hits: raise RuntimeError( @@ -432,6 +441,16 @@ def run_hirest( output_root=output_root, ) run_directory = config.run_directory + registry = create_capability_registry( + platform_runtime_checks=LOCAL_INDEX_RUNTIME_CHECKS + ) + runtime = ModelRuntime( + VidXPSettings( + repository_root=run_directory, + runtime_backend=device, + ), + allowed_specs=registry.model_specs(), + ) ensure_adapter_outputs(run_directory) subset = { "label": ( @@ -444,29 +463,40 @@ def run_hirest( "video_count": len({video for _, video in ordered_pairs}), } try: - manifest = run_index( - _transcript_sources( + with IndexStorage(config) as storage: + manifest = run_index( + _transcript_sources( + ordered_pairs, + asr_directory=asr_directory, + ), + config, + reset=reset, + storage=storage, + manifest_store=ManifestStore( + config, + registry=registry, + runtime=runtime, + ), + registry=registry, + runtime=runtime, + ) + ensure_adapter_outputs(run_directory) + record_adapter_manifest( + run_directory, + benchmark="hirest", + subset=subset, + artifacts=artifacts, + state="predicting", + ) + predictions = _generate_predictions( ordered_pairs, - asr_directory=asr_directory, - ), - config, - reset=reset, - ) - ensure_adapter_outputs(run_directory) - record_adapter_manifest( - run_directory, - benchmark="hirest", - subset=subset, - artifacts=artifacts, - state="predicting", - ) - predictions = _generate_predictions( - ordered_pairs, - ground_truth=ground_truth, - config=config, - manifest=manifest, - temporal_window_fraction=temporal_window_fraction, - ) + ground_truth=ground_truth, + config=config, + manifest=manifest, + temporal_window_fraction=temporal_window_fraction, + runtime=runtime, + storage=storage, + ) validate_predictions(predictions, ground_truth) predictions_path = run_directory / "predictions.json" ground_truth_subset_path = ( diff --git a/src/vidxp/capabilities/actor/cli.py b/src/vidxp/capabilities/actor/cli.py index 51767ed..41fb944 100644 --- a/src/vidxp/capabilities/actor/cli.py +++ b/src/vidxp/capabilities/actor/cli.py @@ -98,8 +98,6 @@ def actors_inspect( state = state_from_context(ctx) detections = state.service.actor_detections(cluster_id) - if not detections: - raise typer.BadParameter(f"Actor cluster {cluster_id} was not found.") payload = { "cluster_id": cluster_id, "detection_count": len(detections), diff --git a/src/vidxp/capabilities/actor/definition.py b/src/vidxp/capabilities/actor/definition.py index c1142ae..0f8b974 100644 --- a/src/vidxp/capabilities/actor/definition.py +++ b/src/vidxp/capabilities/actor/definition.py @@ -5,11 +5,6 @@ from vidxp.capabilities.actor.config import ActorConfig, actor_config from vidxp.capabilities.actor.indexing import VISUAL_PROCESSOR from vidxp.capabilities.actor.models import ( - OPENCV_ZOO_REVISION, - SFACE_FILE, - SFACE_SHA256, - YUNET_FILE, - YUNET_SHA256, get_actor_models, ) from vidxp.capabilities.actor.operations import ( @@ -17,12 +12,14 @@ detections_operation, render_operation, ) +from vidxp.capabilities.actor.specs import SFACE_MODEL, YUNET_MODEL from vidxp.capabilities.contracts import ( CapabilityDefinition, CapabilityExecutor, CapabilityPlugin, OperationDefinition, PreparationContext, + module_import_check, ) from vidxp.capabilities.actor.schemas import ( ActorClustersInput, @@ -50,7 +47,8 @@ def prepare_models( } ) get_actor_models(context.runtime) - return (YUNET_FILE, SFACE_FILE) + return (YUNET_MODEL.filename, SFACE_MODEL.filename) + def model_manifest( config: IndexConfig, @@ -59,10 +57,10 @@ def model_manifest( settings = actor_config(config) return { "actor": { - "provider": "opencv-zoo", - "revision": OPENCV_ZOO_REVISION, - "detector": {"file": YUNET_FILE, "sha256": YUNET_SHA256}, - "recognizer": {"file": SFACE_FILE, "sha256": SFACE_SHA256}, + "models": { + "detector": YUNET_MODEL.identity(), + "recognizer": SFACE_MODEL.identity(), + }, "match_threshold": settings.match_threshold, "detection_threshold": settings.detection_threshold, "minimum_detections": settings.minimum_detections, @@ -79,6 +77,7 @@ def model_manifest( index_stage="visual_indexing", execution_group="visual", prepares_models=True, + model_specs=(YUNET_MODEL, SFACE_MODEL), operations={ "clusters": OperationDefinition( input_model=ActorClustersInput, @@ -107,6 +106,15 @@ def create_executor() -> CapabilityExecutor: }, model_manifest=model_manifest, prepare=prepare_models, + runtime_checks=( + module_import_check( + "OpenCV import", + "cv2", + "FaceDetectorYN", + "FaceRecognizerSF", + ), + module_import_check("Pooch import", "pooch", "retrieve"), + ), ) diff --git a/src/vidxp/capabilities/actor/indexing.py b/src/vidxp/capabilities/actor/indexing.py index 66743a3..ae83ca4 100644 --- a/src/vidxp/capabilities/actor/indexing.py +++ b/src/vidxp/capabilities/actor/indexing.py @@ -13,8 +13,7 @@ stable_source_id, ) from vidxp.core.indexing_common import ProgressCallback -from vidxp.core.storage import IndexStorage -from vidxp.runtime import ModelRuntime +from vidxp.ports import IndexStore, ModelRuntimePort @dataclass @@ -75,7 +74,7 @@ def process_actor_samples( *, state: ActorIndexState, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, cancellation: CancellationToken, ) -> None: import cv2 @@ -157,7 +156,7 @@ def finalize_actor_index( state: ActorIndexState, *, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, ) -> tuple[int, int]: settings = actor_config(config) rejected = [ @@ -186,7 +185,7 @@ def batch_size(self, config: IndexConfig) -> int: def prepare( self, config: IndexConfig, - runtime: ModelRuntime, + runtime: ModelRuntimePort, progress: ProgressCallback | None, ) -> ActorIndexState: return ActorIndexState(models=get_actor_models(runtime)) @@ -198,7 +197,7 @@ def process( state: ActorIndexState, info, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, cancellation: CancellationToken, ) -> None: process_actor_samples( @@ -214,7 +213,7 @@ def finalize( state: ActorIndexState, *, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, ) -> tuple[dict[str, Any], int]: detections, clusters = finalize_actor_index( state, diff --git a/src/vidxp/capabilities/actor/models.py b/src/vidxp/capabilities/actor/models.py index 556dce1..552e98f 100644 --- a/src/vidxp/capabilities/actor/models.py +++ b/src/vidxp/capabilities/actor/models.py @@ -3,17 +3,11 @@ from dataclasses import dataclass from typing import Any -from vidxp.runtime import ModelKey, ModelRuntime - - -OPENCV_ZOO_REVISION = "47534e27c9851bb1128ccc0102f1145e27f23f98" -YUNET_FILE = "face_detection_yunet_2026may.onnx" -YUNET_SHA256 = "ebafce4e3c118d6554634be5c27ab333b4c047a9a8c3faf1d7cf93101c22f0f0" -SFACE_FILE = "face_recognition_sface_2021dec.onnx" -SFACE_SHA256 = "0ba9fbfa01b5270c96627c4ef784da859931e02f04419c829e83484087c34e79" -_RAW_ROOT = ( - "https://raw.githubusercontent.com/opencv/opencv_zoo/" - f"{OPENCV_ZOO_REVISION}/models" +from vidxp.ports import ModelRuntimePort +from vidxp.capabilities.actor.specs import ( + SFACE_MODEL, + YUNET_MODEL, + actor_model_key, ) @@ -23,68 +17,33 @@ class ActorModels: recognizer: Any -def _model_path( - runtime: ModelRuntime, - *, - relative_url: str, - filename: str, - sha256: str, -) -> str: - import pooch - - destination = runtime.settings.model_cache / "opencv-zoo" - if not runtime.settings.allow_model_downloads: - path = destination / filename - if not path.is_file(): - raise FileNotFoundError( - f"Model {filename} is not cached and downloads are disabled." - ) - if pooch.file_hash(path, alg="sha256") != sha256: - raise RuntimeError(f"Cached model checksum failed: {filename}.") - return str(path) - return pooch.retrieve( - url=f"{_RAW_ROOT}/{relative_url}/{filename}", - known_hash=f"sha256:{sha256}", - fname=filename, - path=destination, - progressbar=False, - ) - - -def get_actor_models(runtime: ModelRuntime) -> ActorModels: - key = ModelKey( - "actor", - "opencv-zoo", - "yunet+sface", - OPENCV_ZOO_REVISION, - "cpu", - ) +def get_actor_models(runtime: ModelRuntimePort) -> ActorModels: + key = actor_model_key(runtime.device_for("actor")) def load() -> ActorModels: import cv2 - detector_path = _model_path( - runtime, - relative_url="face_detection_yunet", - filename=YUNET_FILE, - sha256=YUNET_SHA256, - ) - recognizer_path = _model_path( - runtime, - relative_url="face_recognition_sface", - filename=SFACE_FILE, - sha256=SFACE_SHA256, - ) - return ActorModels( + detector_path = runtime.resolve_artifact(YUNET_MODEL) + recognizer_path = runtime.resolve_artifact(SFACE_MODEL) + models = ActorModels( detector=cv2.FaceDetectorYN.create( - detector_path, + str(detector_path), "", (320, 320), score_threshold=0.9, nms_threshold=0.3, top_k=5000, ), - recognizer=cv2.FaceRecognizerSF.create(recognizer_path, ""), + recognizer=cv2.FaceRecognizerSF.create(str(recognizer_path), ""), + ) + runtime.record_compute_precision( + YUNET_MODEL.capability, + YUNET_MODEL.weights_precision, + ) + runtime.record_compute_precision( + SFACE_MODEL.capability, + SFACE_MODEL.weights_precision, ) + return models return runtime.get_or_load(key, load) diff --git a/src/vidxp/capabilities/actor/operations.py b/src/vidxp/capabilities/actor/operations.py index 88b7276..5a48893 100644 --- a/src/vidxp/capabilities/actor/operations.py +++ b/src/vidxp/capabilities/actor/operations.py @@ -21,7 +21,10 @@ def clusters_operation( _request: ActorClustersInput, ) -> ActorClustersOutput: return ActorClustersOutput( - clusters=actor_clusters(context.require_config()) + clusters=actor_clusters( + context.require_config(), + storage=context.require_storage(), + ) ) @@ -35,6 +38,7 @@ def detections_operation( detections=actor_detections( config, request.cluster_id, + storage=context.require_storage(), ), ) @@ -49,4 +53,5 @@ def render_operation( request.cluster_id, request.input_path, request.output_path, + storage=context.require_storage(), ) diff --git a/src/vidxp/capabilities/actor/results.py b/src/vidxp/capabilities/actor/results.py index 1ab4386..58de7ed 100644 --- a/src/vidxp/capabilities/actor/results.py +++ b/src/vidxp/capabilities/actor/results.py @@ -8,7 +8,7 @@ ActorRenderResult, ) from vidxp.core.contracts import IndexConfig -from vidxp.core.storage import IndexStorage +from vidxp.ports import IndexStore from vidxp.core.video import render_actor_video @@ -19,20 +19,14 @@ class ActorClusterNotFoundError(LookupError): def actor_clusters( config: IndexConfig, *, - storage: IndexStorage | None = None, + storage: IndexStore, ) -> tuple[ActorClusterSummary, ...]: if config.video_id is None: raise ValueError("IndexConfig.video_id is required for actor results.") - owns_storage = storage is None - active_storage = storage or IndexStorage(config) - try: - records = active_storage.records( - "actor", - video_id=config.video_id, - ) - finally: - if owns_storage: - active_storage.close() + records = storage.records( + "actor", + video_id=config.video_id, + ) grouped: dict[str, list[dict]] = {} for record in records: @@ -57,21 +51,15 @@ def actor_detections( config: IndexConfig, cluster_id: str, *, - storage: IndexStorage | None = None, + storage: IndexStore, ) -> list[ActorDetection]: if config.video_id is None: raise ValueError("IndexConfig.video_id is required for actor results.") - owns_storage = storage is None - active_storage = storage or IndexStorage(config) - try: - records = active_storage.records( - "actor", - video_id=config.video_id, - filters={"cluster_id": cluster_id}, - ) - finally: - if owns_storage: - active_storage.close() + records = storage.records( + "actor", + video_id=config.video_id, + filters={"cluster_id": cluster_id}, + ) detections = [ ActorDetection( @@ -89,6 +77,10 @@ def actor_detections( ) for record in records ] + if not detections: + raise ActorClusterNotFoundError( + f"Actor cluster {cluster_id} was not found in the completed index." + ) return sorted( detections, key=lambda item: (item.frame_index, item.detection_id), @@ -101,13 +93,9 @@ def render_actor_result( input_path: str | Path, output_path: str | Path, *, - storage: IndexStorage | None = None, + storage: IndexStore, ) -> ActorRenderResult: detections = actor_detections(config, cluster_id, storage=storage) - if not detections: - raise ActorClusterNotFoundError( - f"Actor cluster {cluster_id} was not found in the completed index." - ) destination = Path(output_path) render_actor_video(input_path, destination, cluster_id, detections) return ActorRenderResult( diff --git a/src/vidxp/capabilities/actor/specs.py b/src/vidxp/capabilities/actor/specs.py new file mode 100644 index 0000000..a105e14 --- /dev/null +++ b/src/vidxp/capabilities/actor/specs.py @@ -0,0 +1,49 @@ +from vidxp.model_contracts import ArtifactSpec, ModelKey + + +OPENCV_ZOO_REVISION = "47534e27c9851bb1128ccc0102f1145e27f23f98" +_MODEL_ROOT = ( + "https://media.githubusercontent.com/media/opencv/opencv_zoo/" + f"{OPENCV_ZOO_REVISION}/models" +) + +YUNET_MODEL = ArtifactSpec( + capability="actor.detector", + provider="opencv-zoo", + model_id="yunet", + revision=OPENCV_ZOO_REVISION, + url=f"{_MODEL_ROOT}/face_detection_yunet/" + "face_detection_yunet_2026may.onnx", + filename="face_detection_yunet_2026may.onnx", + sha256="ebafce4e3c118d6554634be5c27ab333b4c047a9a8c3faf1d7cf93101c22f0f0", + license="MIT", + weights_precision="float32", +) + +SFACE_MODEL = ArtifactSpec( + capability="actor.recognizer", + provider="opencv-zoo", + model_id="sface", + revision=OPENCV_ZOO_REVISION, + url=f"{_MODEL_ROOT}/face_recognition_sface/" + "face_recognition_sface_2021dec.onnx", + filename="face_recognition_sface_2021dec.onnx", + sha256="0ba9fbfa01b5270c96627c4ef784da859931e02f04419c829e83484087c34e79", + license="Apache-2.0", + weights_precision="float32", +) + + +def actor_model_key(device: str) -> ModelKey: + specs = (YUNET_MODEL, SFACE_MODEL) + providers = {spec.provider for spec in specs} + revisions = {spec.revision for spec in specs} + if len(providers) != 1 or len(revisions) != 1: + raise RuntimeError("Actor model artifacts must share provider and revision.") + return ModelKey( + capability=YUNET_MODEL.capability.split(".", 1)[0], + provider=YUNET_MODEL.provider, + model_id="+".join(spec.model_id for spec in specs), + revision=YUNET_MODEL.revision, + device=device, + ) diff --git a/src/vidxp/capabilities/contracts.py b/src/vidxp/capabilities/contracts.py index 62a3e67..37cc014 100644 --- a/src/vidxp/capabilities/contracts.py +++ b/src/vidxp/capabilities/contracts.py @@ -1,5 +1,6 @@ from __future__ import annotations +from importlib import import_module from types import MappingProxyType from typing import Any, Callable, Mapping @@ -15,7 +16,9 @@ from vidxp.core.contracts import IndexConfig, VideoSource from vidxp.core.indexing_common import ProgressCallback -from vidxp.runtime import ModelRuntime +from vidxp.model_contracts import ArtifactSpec, ModelSpec +from vidxp.ports import IndexStore, ModelRuntimePort +from vidxp.application_models import CapabilityProvenance CAPABILITY_CONTRACT_VERSION = 1 @@ -53,11 +56,11 @@ class RuntimeCheck(_ContractModel): def inspect(self) -> dict[str, Any]: try: detail = self.check() - except Exception as exc: + except Exception: return { "name": self.label, "ok": False, - "error": f"{type(exc).__name__}: {exc}", + "error": "runtime check failed", } result: dict[str, Any] = { "name": self.label, @@ -76,22 +79,36 @@ def applies(self, source: VideoSource | None) -> bool: ) +class RuntimeCheckBinding(_ContractModel): + """Runtime check owned by a capability or platform service.""" + + capability: str = Field(min_length=1) + check: RuntimeCheck + provenance: CapabilityProvenance | None = None + + class CapabilityContext(_ContractModel): """Runtime context shared by transport-neutral capability operations.""" config: IndexConfig | None - runtime: ModelRuntime + runtime: ModelRuntimePort + storage: IndexStore | None = None def require_config(self) -> IndexConfig: if self.config is None: raise RuntimeError("This operation requires an active index.") return self.config + def require_storage(self) -> IndexStore: + if self.storage is None: + raise RuntimeError("This operation requires an active index store.") + return self.storage + class PreparationContext(_ContractModel): """Runtime values supplied to one capability's preparation hook.""" - runtime: ModelRuntime + runtime: ModelRuntimePort settings: CapabilityConfig @@ -115,6 +132,7 @@ def _require_model( raise ValueError("Operation schemas must be Pydantic models.") return value + class CapabilityIndexResult(_ContractModel): """Summary and timing data returned by an indexing handler.""" @@ -149,6 +167,7 @@ class CapabilityDefinition(_ContractModel): index_stage: str | None = None execution_group: str | None = None operations: Mapping[str, OperationDefinition] = Field(default_factory=dict) + model_specs: tuple[ModelSpec | ArtifactSpec, ...] = () prepares_models: bool = False @field_validator("config_model") @@ -195,6 +214,22 @@ def _require_complete_metadata(self) -> CapabilityDefinition: return self +def module_import_check( + label: str, + module_name: str, + *attributes: str, +) -> RuntimeCheck: + def check() -> None: + module = import_module(module_name) + for attribute in attributes: + if not hasattr(module, attribute): + raise AttributeError( + f"{module_name} does not expose {attribute}." + ) + + return RuntimeCheck(label=label, check=check) + + class CapabilityExecutor(_ContractModel): """Infrastructure hooks bound to one capability definition.""" @@ -228,7 +263,34 @@ class CapabilityPlugin(_ContractModel): definition: CapabilityDefinition executor_factory: ExecutorFactory contract_version: int = CAPABILITY_CONTRACT_VERSION + requirements: tuple[str, ...] = () + provenance: CapabilityProvenance | None = None + + @field_validator("requirements") + @classmethod + def _validate_requirements( + cls, + values: tuple[str, ...], + ) -> tuple[str, ...]: + for value in values: + Requirement(value) + return values def capability_install_hint(name: str) -> str: return f'Install the capability with: pip install "vidxp[{name}]"' + + +class CapabilityRequestError(ValueError): + """Expected invalid capability selection or options.""" + + +class CapabilityDependencyError(RuntimeError): + def __init__( + self, + capabilities: tuple[str, ...], + failures: tuple[Mapping[str, Any], ...], + ) -> None: + self.capabilities = capabilities + self.failures = failures + super().__init__("Capability dependencies are unavailable.") diff --git a/src/vidxp/capabilities/dialogue/config.py b/src/vidxp/capabilities/dialogue/config.py index 34e4f00..8f68833 100644 --- a/src/vidxp/capabilities/dialogue/config.py +++ b/src/vidxp/capabilities/dialogue/config.py @@ -11,22 +11,6 @@ class DialogueConfig(CapabilityConfig): embedding_batch_size: int = Field(default=128, gt=0) transcription_batch_size: int = Field(default=16, gt=0) normalize_embeddings: bool = True - sentence_model: str = Field( - default="Qwen/Qwen3-Embedding-0.6B", - min_length=1, - ) - sentence_revision: str = Field( - default="97b0c614be4d77ee51c0cef4e5f07c00f9eb65b3", - min_length=1, - ) - whisper_model: str = Field( - default="mobiuslabsgmbh/faster-whisper-large-v3-turbo", - min_length=1, - ) - whisper_revision: str = Field( - default="0a363e9161cbc7ed1431c9597a8ceaf0c4f78fcf", - min_length=1, - ) def dialogue_config(config: IndexConfig) -> DialogueConfig: diff --git a/src/vidxp/capabilities/dialogue/definition.py b/src/vidxp/capabilities/dialogue/definition.py index d68d3a2..2fda3e0 100644 --- a/src/vidxp/capabilities/dialogue/definition.py +++ b/src/vidxp/capabilities/dialogue/definition.py @@ -11,9 +11,14 @@ CapabilityPlugin, OperationDefinition, PreparationContext, + module_import_check, ) -from vidxp.capabilities.dialogue.config import DialogueConfig, dialogue_config +from vidxp.capabilities.dialogue.config import DialogueConfig from vidxp.capabilities.dialogue.models import get_embedder, get_whisper_model +from vidxp.capabilities.dialogue.specs import ( + FASTER_WHISPER_MODEL, + QWEN3_EMBEDDING_MODEL, +) from vidxp.capabilities.dialogue.operations import ( index_capability, search_operation, @@ -39,7 +44,7 @@ def prepare_models( context: PreparationContext, progress: ProgressCallback | None, ) -> tuple[str, ...]: - settings = DialogueConfig.model_validate(context.settings) + DialogueConfig.model_validate(context.settings) prepared = [] def report(stage: str, message: str) -> None: @@ -54,45 +59,31 @@ def report(stage: str, message: str) -> None: report( "dialogue_model", - f"Preparing dialogue model: {settings.sentence_model}", - ) - get_embedder( - context.runtime, - settings.sentence_model, - settings.sentence_revision, + f"Preparing dialogue model: {QWEN3_EMBEDDING_MODEL.model_id}", ) - prepared.append(settings.sentence_model) + get_embedder(context.runtime) + prepared.append(QWEN3_EMBEDDING_MODEL.model_id) report( "transcription_model", - f"Preparing transcription model: faster-whisper {settings.whisper_model}", + "Preparing transcription model: faster-whisper " + f"{FASTER_WHISPER_MODEL.model_id}", ) - get_whisper_model( - context.runtime, - settings.whisper_model, - settings.whisper_revision, - ) - prepared.append(settings.whisper_model) + get_whisper_model(context.runtime) + prepared.append(FASTER_WHISPER_MODEL.model_id) return tuple(prepared) def model_manifest( - config: IndexConfig, + _config: IndexConfig, sources: tuple[VideoSource, ...], ) -> Mapping[str, Any]: - settings = dialogue_config(config) result: dict[str, Any] = { "dialogue": { - "provider": "sentence-transformers", - "model": settings.sentence_model, - "revision": settings.sentence_revision, + **QWEN3_EMBEDDING_MODEL.identity(), } } if any(source.transcript is None for source in sources): - result["transcription"] = { - "provider": "faster-whisper", - "model": settings.whisper_model, - "revision": settings.whisper_revision, - } + result["transcription"] = FASTER_WHISPER_MODEL.identity() return result @@ -105,6 +96,7 @@ def model_manifest( index_stage="dialogue_indexing", execution_group="dialogue", prepares_models=True, + model_specs=(QWEN3_EMBEDDING_MODEL, FASTER_WHISPER_MODEL), operations={ "search": OperationDefinition( input_model=SearchInput, @@ -121,6 +113,24 @@ def create_executor() -> CapabilityExecutor: requirement_filter=filter_requirements_for_source, prepare=prepare_models, model_manifest=model_manifest, + runtime_checks=( + module_import_check( + "faster-whisper import", + "faster_whisper", + "WhisperModel", + "BatchedInferencePipeline", + ), + module_import_check( + "Sentence Transformers import", + "sentence_transformers", + "SentenceTransformer", + ), + module_import_check( + "Hugging Face Hub import", + "huggingface_hub", + "snapshot_download", + ), + ), ) diff --git a/src/vidxp/capabilities/dialogue/indexing.py b/src/vidxp/capabilities/dialogue/indexing.py index fb5c1cf..976e446 100644 --- a/src/vidxp/capabilities/dialogue/indexing.py +++ b/src/vidxp/capabilities/dialogue/indexing.py @@ -6,6 +6,10 @@ from vidxp.capabilities.dialogue.config import dialogue_config from vidxp.capabilities.dialogue.models import get_embedder, get_whisper_model +from vidxp.capabilities.dialogue.specs import ( + FASTER_WHISPER_MODEL, + QWEN3_EMBEDDING_MODEL, +) from vidxp.core.contracts import ( CancellationToken, IndexConfig, @@ -14,8 +18,7 @@ stable_source_id, ) from vidxp.core.indexing_common import ProgressCallback, report_progress -from vidxp.core.storage import IndexStorage -from vidxp.runtime import ModelRuntime +from vidxp.ports import IndexStore, ModelRuntimePort @dataclass(frozen=True) @@ -114,7 +117,7 @@ def transcribe_video( *, config: IndexConfig, cancellation: CancellationToken, - runtime: ModelRuntime, + runtime: ModelRuntimePort, progress: ProgressCallback | None, ) -> tuple[list[Mapping[str, Any]], str]: from faster_whisper import BatchedInferencePipeline @@ -124,13 +127,10 @@ def transcribe_video( report_progress( progress, "preparing_transcription_model", - f"Preparing transcription model: faster-whisper {settings.whisper_model}.", - ) - whisper_model = get_whisper_model( - runtime, - settings.whisper_model, - settings.whisper_revision, + "Preparing transcription model: faster-whisper " + f"{FASTER_WHISPER_MODEL.model_id}.", ) + whisper_model = get_whisper_model(runtime) report_progress( progress, "transcribing_audio", @@ -200,9 +200,9 @@ def index_dialogue( source: VideoSource, *, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, cancellation: CancellationToken, - runtime: ModelRuntime, + runtime: ModelRuntimePort, progress: ProgressCallback | None = None, ) -> dict[str, Any]: if config.video_id is None: @@ -235,15 +235,11 @@ def index_dialogue( report_progress( progress, "preparing_dialogue_model", - f"Preparing dialogue model: {settings.sentence_model}.", + f"Preparing dialogue model: {QWEN3_EMBEDDING_MODEL.model_id}.", 0, len(phrases), ) - encoder = get_embedder( - runtime, - settings.sentence_model, - settings.sentence_revision, - ) + encoder = get_embedder(runtime) report_progress( progress, "dialogue_indexing", @@ -255,7 +251,7 @@ def index_dialogue( for offset in range(0, len(phrases), settings.embedding_batch_size): cancellation.raise_if_cancelled() group = phrases[offset:offset + settings.embedding_batch_size] - vectors = encoder.encode( + vectors = encoder.encode_document( [phrase.text for phrase in group], batch_size=len(group), convert_to_numpy=True, diff --git a/src/vidxp/capabilities/dialogue/models.py b/src/vidxp/capabilities/dialogue/models.py index 72011f5..b3a739f 100644 --- a/src/vidxp/capabilities/dialogue/models.py +++ b/src/vidxp/capabilities/dialogue/models.py @@ -2,51 +2,56 @@ from typing import Any -from vidxp.runtime import ModelKey, ModelRuntime +from vidxp.ports import ModelRuntimePort +from vidxp.model_contracts import loaded_compute_precision +from vidxp.capabilities.dialogue.specs import ( + FASTER_WHISPER_MODEL, + QWEN3_EMBEDDING_MODEL, + whisper_compute_type, +) def get_embedder( - runtime: ModelRuntime, - model_id: str, - revision: str, + runtime: ModelRuntimePort, ) -> Any: device = runtime.device_for("dialogue.embedding") - key = ModelKey("dialogue", "sentence-transformers", model_id, revision, device) + key = QWEN3_EMBEDDING_MODEL.key(device) def load() -> Any: from sentence_transformers import SentenceTransformer - return SentenceTransformer( - model_id, + snapshot = runtime.resolve_model(QWEN3_EMBEDDING_MODEL) + model = SentenceTransformer( + str(snapshot), device=device, - cache_folder=str(runtime.settings.model_cache), - revision=revision, - local_files_only=not runtime.settings.allow_model_downloads, + cache_folder=str(runtime.model_cache), + local_files_only=True, ) + runtime.record_compute_precision( + QWEN3_EMBEDDING_MODEL.capability, + loaded_compute_precision( + model, + fallback=QWEN3_EMBEDDING_MODEL.weights_precision, + ), + ) + return model return runtime.get_or_load(key, load) def get_whisper_model( - runtime: ModelRuntime, - model_id: str, - revision: str, + runtime: ModelRuntimePort, ) -> Any: device = runtime.device_for("dialogue.transcription") - compute_type = "float16" if device.startswith("cuda") else "int8" - key = ModelKey( - "dialogue", - "faster-whisper", - model_id, - revision, - f"{device}:{compute_type}", - ) + compute_type = whisper_compute_type(device) + key = FASTER_WHISPER_MODEL.key(f"{device}:{compute_type}") def load() -> Any: from faster_whisper import WhisperModel - return WhisperModel( - model_id, + snapshot = runtime.resolve_model(FASTER_WHISPER_MODEL) + model = WhisperModel( + str(snapshot), device=device.split(":", 1)[0], device_index=( int(device.split(":", 1)[1]) @@ -54,9 +59,14 @@ def load() -> Any: else 0 ), compute_type=compute_type, - download_root=str(runtime.settings.model_cache), - local_files_only=not runtime.settings.allow_model_downloads, - revision=revision, + cpu_threads=runtime.cpu_thread_budget, + download_root=str(runtime.model_cache), + local_files_only=True, + ) + runtime.record_compute_precision( + FASTER_WHISPER_MODEL.capability, + compute_type, ) + return model return runtime.get_or_load(key, load) diff --git a/src/vidxp/capabilities/dialogue/operations.py b/src/vidxp/capabilities/dialogue/operations.py index 6036fbe..6645e3a 100644 --- a/src/vidxp/capabilities/dialogue/operations.py +++ b/src/vidxp/capabilities/dialogue/operations.py @@ -18,8 +18,7 @@ VideoSource, ) from vidxp.core.indexing_common import ProgressCallback -from vidxp.core.storage import IndexStorage -from vidxp.runtime import ModelRuntime +from vidxp.ports import IndexStore, ModelRuntimePort REQUIRED_METADATA = frozenset( @@ -42,10 +41,10 @@ def index_capability( source: VideoSource, *, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, cancellation: CancellationToken, registry: CapabilityRegistry, - runtime: ModelRuntime, + runtime: ModelRuntimePort, progress: ProgressCallback | None = None, modalities: tuple[str, ...] = ("dialogue",), ) -> CapabilityIndexResult: @@ -66,20 +65,12 @@ def index_capability( def dialogue_embedding( query: str, config: IndexConfig, - runtime: ModelRuntime, + runtime: ModelRuntimePort, ) -> list[float]: settings = dialogue_config(config) - encoder = get_embedder( - runtime, - settings.sentence_model, - settings.sentence_revision, - ) - encoded = encoder.encode( + encoder = get_embedder(runtime) + encoded = encoder.encode_query( [query], - prompt=( - "Instruct: Retrieve spoken video passages relevant to the query\n" - "Query: " - ), convert_to_numpy=True, normalize_embeddings=settings.normalize_embeddings, ) @@ -90,12 +81,12 @@ def search_dialogue( query: str, *, config: IndexConfig, - runtime: ModelRuntime, + runtime: ModelRuntimePort, top_k: int = 10, video_id: str | None = None, query_id: str | None = None, filters: Mapping[str, Any] | None = None, - storage: IndexStorage | None = None, + storage: IndexStore, ) -> SearchResult: cleaned = query.strip() if not cleaned: @@ -127,4 +118,5 @@ def search_operation( top_k=request.top_k, video_id=config.video_id, runtime=context.runtime, + storage=context.require_storage(), ) diff --git a/src/vidxp/capabilities/dialogue/requirements.txt b/src/vidxp/capabilities/dialogue/requirements.txt index b8b3993..0841436 100644 --- a/src/vidxp/capabilities/dialogue/requirements.txt +++ b/src/vidxp/capabilities/dialogue/requirements.txt @@ -1,2 +1,3 @@ faster-whisper>=1.2.1,<2 sentence-transformers>=5.6.1,<6 +huggingface-hub>=1.25.1,<2 diff --git a/src/vidxp/capabilities/dialogue/specs.py b/src/vidxp/capabilities/dialogue/specs.py new file mode 100644 index 0000000..ced668e --- /dev/null +++ b/src/vidxp/capabilities/dialogue/specs.py @@ -0,0 +1,32 @@ +from vidxp.model_contracts import ModelSpec + + +QWEN3_EMBEDDING_MODEL = ModelSpec( + capability="dialogue.embedding", + provider="sentence-transformers", + model_id="Qwen/Qwen3-Embedding-0.6B", + revision="97b0c614be4d77ee51c0cef4e5f07c00f9eb65b3", + weights_file="model.safetensors", + weights_sha256=( + "0437e45c94563b09e13cb7a64478fc406947a93cb34a7e05870fc8dcd48e23fd" + ), + license="Apache-2.0", + weights_precision="bfloat16", +) + +FASTER_WHISPER_MODEL = ModelSpec( + capability="dialogue.transcription", + provider="faster-whisper", + model_id="dropbox-dash/faster-whisper-large-v3-turbo", + revision="0a363e9161cbc7ed1431c9597a8ceaf0c4f78fcf", + weights_file="model.bin", + weights_sha256=( + "e76620f83d5f5b69efd3d87e3dc180c1bd21df9fbebacfd4335e5e1efcc018da" + ), + license="MIT", + weights_precision="float16", +) + + +def whisper_compute_type(device: str) -> str: + return "float16" if device.startswith("cuda") else "int8" diff --git a/src/vidxp/capabilities/registry.py b/src/vidxp/capabilities/registry.py index f83a1fd..ded91b5 100644 --- a/src/vidxp/capabilities/registry.py +++ b/src/vidxp/capabilities/registry.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass from importlib.metadata import EntryPoint, entry_points from threading import RLock from types import MappingProxyType @@ -11,11 +12,19 @@ from vidxp.capabilities.contracts import ( CAPABILITY_CONTRACT_VERSION, CapabilityDefinition, + CapabilityDependencyError, CapabilityExecutor, CapabilityPlugin, + CapabilityProvenance, + CapabilityRequestError, RuntimeCheck, + RuntimeCheckBinding, capability_install_hint, ) +from vidxp.application_models import ( + CapabilityDependencyCheck, + DependencyKind, +) from vidxp.core.contracts import VideoSource from vidxp.dependencies import ( active_requirements, @@ -23,31 +32,60 @@ installed_base_requirements, packaged_requirements, ) +from vidxp.model_contracts import ArtifactSpec, ModelSpec ENTRY_POINT_GROUP = "vidxp.capabilities" +@dataclass(frozen=True) +class _RequirementBinding: + capability: str + provenance: CapabilityProvenance | None + requirement: Requirement + + class CapabilityRegistry: """Validated capability metadata with lazily constructed executors.""" - def __init__(self, plugins: Iterable[CapabilityPlugin]) -> None: + def __init__( + self, + plugins: Iterable[CapabilityPlugin], + *, + platform_runtime_checks: Iterable[RuntimeCheckBinding] = (), + ) -> None: indexed: dict[str, CapabilityPlugin] = {} for plugin in plugins: if plugin.contract_version != CAPABILITY_CONTRACT_VERSION: raise RuntimeError( - f"Capability {plugin.definition.name!r} targets contract " + f"Capability {plugin.definition.name!r} from " + f"{self._label(plugin)} targets contract " f"{plugin.contract_version}; VidXP requires " f"{CAPABILITY_CONTRACT_VERSION}." ) name = plugin.definition.name if name in indexed: - raise RuntimeError(f"Duplicate capability name: {name!r}.") + existing = self._label(indexed[name]) + incoming = self._label(plugin) + raise RuntimeError( + f"Duplicate capability name {name!r}: " + f"{existing} conflicts with {incoming}." + ) indexed[name] = plugin self._plugins = MappingProxyType(indexed) + self._platform_runtime_checks = tuple(platform_runtime_checks) self._executors: dict[str, CapabilityExecutor] = {} self._executor_lock = RLock() + @staticmethod + def _label(plugin: CapabilityPlugin) -> str: + if plugin.provenance is None: + return "built-in VidXP capability" + return ( + f"{plugin.provenance.distribution}:" + f"{plugin.provenance.entry_point}" + ) + @property def definitions(self) -> Mapping[str, CapabilityDefinition]: return MappingProxyType( @@ -57,6 +95,10 @@ def definitions(self) -> Mapping[str, CapabilityDefinition]: def names(self) -> tuple[str, ...]: return tuple(self._plugins) + def provenance(self, name: str) -> CapabilityProvenance | None: + self.get(name) + return self._plugins[name].provenance + def index_names(self) -> tuple[str, ...]: return tuple( name @@ -71,12 +113,25 @@ def preparable_names(self) -> tuple[str, ...]: if definition.prepares_models ) + def model_specs( + self, + names: Iterable[str] | None = None, + ) -> tuple[ModelSpec | ArtifactSpec, ...]: + selected = self.names() if names is None else self.validate_names(names) + return tuple( + dict.fromkeys( + spec + for name in selected + for spec in self.get(name).model_specs + ) + ) + def get(self, name: str) -> CapabilityDefinition: try: return self._plugins[name].definition except KeyError as exc: available = ", ".join(self.names()) - raise ValueError( + raise CapabilityRequestError( f"Unknown capability {name!r}. " f"Available capabilities: {available}." ) from exc @@ -89,20 +144,23 @@ def executor(self, name: str) -> CapabilityExecutor: operation_names = set(definition.operations) if set(executor.operations) != operation_names: raise RuntimeError( - f"Capability {name!r} operation handlers do not match " - "its declared operations." + f"Capability {name!r} from " + f"{self._label(self._plugins[name])} has operation " + "handlers that do not match its declaration." ) if (executor.indexer is not None) != ( definition.collection_name is not None ): raise RuntimeError( - f"Capability {name!r} indexing metadata and executor " - "do not match." + f"Capability {name!r} from " + f"{self._label(self._plugins[name])} has indexing " + "metadata that does not match its executor." ) if (executor.prepare is not None) != definition.prepares_models: raise RuntimeError( - f"Capability {name!r} preparation metadata and executor " - "do not match." + f"Capability {name!r} from " + f"{self._label(self._plugins[name])} has preparation " + "metadata that does not match its executor." ) self._executors[name] = executor return self._executors[name] @@ -110,7 +168,7 @@ def executor(self, name: str) -> CapabilityExecutor: def validate_names(self, names: Iterable[str]) -> tuple[str, ...]: selected = tuple(dict.fromkeys(str(name).strip() for name in names)) if not selected: - raise ValueError("At least one capability is required.") + raise CapabilityRequestError("At least one capability is required.") for name in selected: self.get(name) return selected @@ -135,7 +193,7 @@ def validate_options( supplied = dict(options or {}) unknown = sorted(set(supplied) - set(selected)) if unknown: - raise ValueError( + raise CapabilityRequestError( "Options were supplied for disabled capabilities: " + ", ".join(unknown) ) @@ -152,55 +210,169 @@ def requirements_for( *, source: VideoSource | None = None, ) -> tuple[Requirement, ...]: + bindings = self._requirement_bindings(names, source=source) + unique = { + str(binding.requirement): binding.requirement for binding in bindings + } + return tuple(unique.values()) + + def _requirement_bindings( + self, + names: Iterable[str], + *, + source: VideoSource | None = None, + ) -> tuple[_RequirementBinding, ...]: selected = self.validate_names(names) - requirements = list( - active_requirements( - packaged_requirements("vidxp", "requirements/storage.txt") + bindings = [ + _RequirementBinding("storage", None, requirement) + for requirement in ( + active_requirements( + packaged_requirements( + "vidxp", + "requirements/storage.txt", + ) + ) + if any(self.get(name).collection_name for name in selected) + else () ) - if any(self.get(name).collection_name for name in selected) - else () - ) + ] for name in selected: + plugin = self._plugins[name] capability_requirements = active_requirements( - packaged_requirements(f"vidxp.capabilities.{name}") + tuple(Requirement(value) for value in plugin.requirements) + if plugin.provenance is not None + else packaged_requirements(f"vidxp.capabilities.{name}") ) executor = self.executor(name) - requirements.extend( + selected_requirements = ( capability_requirements if source is None - else executor.source_requirements( - source, - capability_requirements, - ) + else executor.source_requirements(source, capability_requirements) + ) + bindings.extend( + _RequirementBinding(name, plugin.provenance, requirement) + for requirement in selected_requirements ) - unique = {str(requirement): requirement for requirement in requirements} + unique = { + ( + binding.capability, + str(binding.requirement), + ): binding + for binding in bindings + } return tuple(unique.values()) + def install_hint(self, names: Iterable[str]) -> str: + selected = self.validate_names(names) + external = [ + plugin.provenance.distribution + for name in selected + if (plugin := self._plugins[name]).provenance is not None + ] + builtins = [ + self.get(name).extra + for name in selected + if self._plugins[name].provenance is None + ] + commands = [] + if builtins: + commands.append( + capability_install_hint(",".join(dict.fromkeys(builtins))) + ) + if external: + commands.append( + "Install external capability distributions with: pip install " + + " ".join(dict.fromkeys(external)) + ) + return " ".join(commands) + def runtime_checks_for( self, names: Iterable[str], *, source: VideoSource | None = None, ) -> tuple[RuntimeCheck, ...]: - checks = ( - check - for name in self.validate_names(names) - for check in self.executor(name).runtime_checks - if check.applies(source) + return tuple( + binding.check + for binding in self._runtime_check_bindings( + names, + source=source, + ) ) - return tuple({check.label: check for check in checks}.values()) + + def _runtime_check_bindings( + self, + names: Iterable[str], + *, + source: VideoSource | None = None, + ) -> tuple[RuntimeCheckBinding, ...]: + selected = self.validate_names(names) + bindings = [ + binding + for binding in self._platform_runtime_checks + if any(self.get(name).collection_name for name in selected) + and binding.check.applies(source) + ] + for name in selected: + plugin = self._plugins[name] + bindings.extend( + RuntimeCheckBinding( + capability=name, + provenance=plugin.provenance, + check=check, + ) + for check in self.executor(name).runtime_checks + if check.applies(source) + ) + unique = { + ( + binding.capability, + binding.check.label, + ( + binding.provenance.distribution, + binding.provenance.entry_point, + ) + if binding.provenance is not None + else None, + ): binding + for binding in bindings + } + return tuple(unique.values()) def dependency_checks( self, names: Iterable[str], - ) -> tuple[dict[str, Any], ...]: - return tuple( - inspect_requirement(requirement) - for requirement in self.requirements_for(names) - ) + tuple( - check.inspect() - for check in self.runtime_checks_for(names) - ) + *, + source: VideoSource | None = None, + ) -> tuple[CapabilityDependencyCheck, ...]: + selected = self.validate_names(names) + checks = [] + for binding in self._requirement_bindings(selected, source=source): + result = inspect_requirement(binding.requirement) + checks.append( + CapabilityDependencyCheck( + capability=binding.capability, + provenance=binding.provenance, + kind=DependencyKind.distribution, + **result, + ) + ) + for binding in self._runtime_check_bindings( + selected, + source=source, + ): + result = binding.check.inspect() + checks.append( + CapabilityDependencyCheck( + capability=binding.capability, + provenance=binding.provenance, + kind=DependencyKind.runtime, + name=result["name"], + ok=result["ok"], + error=result["error"], + ) + ) + return tuple(checks) def require_dependencies( self, @@ -209,25 +381,18 @@ def require_dependencies( source: VideoSource, ) -> None: selected = self.validate_names(names) - failures = [ - result - for requirement in self.requirements_for(selected, source=source) - if not (result := inspect_requirement(requirement))["ok"] - ] - failures.extend( - result - for check in self.runtime_checks_for(selected, source=source) - if not (result := check.inspect())["ok"] + failures = tuple( + check + for check in self.dependency_checks(selected, source=source) + if not check.ok ) if failures: - details = "; ".join( - f"{failure['name']}: {failure['error']}" - for failure in failures - ) - extras = ",".join(self.get(name).extra for name in selected) - raise RuntimeError( - f"Capability dependencies are unavailable: {details}. " - + capability_install_hint(extras) + raise CapabilityDependencyError( + selected, + tuple( + check.model_dump(mode="json") + for check in failures + ), ) def runtime_distributions(self) -> tuple[str, ...]: @@ -251,18 +416,22 @@ def _builtin_plugins() -> tuple[CapabilityPlugin, ...]: def _external_entry_points(allowlist: tuple[str, ...]) -> tuple[EntryPoint, ...]: - allowed = {canonicalize_name(value) for value in allowlist} + allowed = { + tuple(canonicalize_name(part) for part in value.split(":", 1)) + for value in allowlist + } candidates = entry_points(group=ENTRY_POINT_GROUP) return tuple( sorted( ( entry_point for entry_point in candidates - if canonicalize_name(entry_point.name) in allowed - or ( - entry_point.dist is not None - and canonicalize_name(entry_point.dist.name) in allowed + if entry_point.dist is not None + and ( + canonicalize_name(entry_point.dist.name), + canonicalize_name(entry_point.name), ) + in allowed ), key=lambda item: ( canonicalize_name( @@ -278,10 +447,17 @@ def create_capability_registry( *, external: bool = False, allowlist: tuple[str, ...] = (), + platform_runtime_checks: tuple[RuntimeCheckBinding, ...] = (), ) -> CapabilityRegistry: plugins = list(_builtin_plugins()) if external: for entry_point in _external_entry_points(allowlist): + distribution = entry_point.dist + identity = ( + f"{distribution.name}:{entry_point.name}" + if distribution is not None + else entry_point.name + ) try: loaded = entry_point.load() plugin = ( @@ -293,12 +469,32 @@ def create_capability_registry( except Exception as exc: raise RuntimeError( f"Could not load capability entry point " - f"{entry_point.name!r}." + f"{identity!r}." ) from exc if not isinstance(plugin, CapabilityPlugin): raise RuntimeError( - f"Capability entry point {entry_point.name!r} did not " + f"Capability entry point {identity!r} did not " "return a CapabilityPlugin." ) + plugin = plugin.model_copy( + update={ + "provenance": CapabilityProvenance( + distribution=( + distribution.name + if distribution is not None + else "unknown" + ), + entry_point=entry_point.name, + version=( + distribution.version + if distribution is not None + else None + ), + ) + } + ) plugins.append(plugin) - return CapabilityRegistry(plugins) + return CapabilityRegistry( + plugins, + platform_runtime_checks=platform_runtime_checks, + ) diff --git a/src/vidxp/capabilities/scene/config.py b/src/vidxp/capabilities/scene/config.py index fb38c26..e8749f5 100644 --- a/src/vidxp/capabilities/scene/config.py +++ b/src/vidxp/capabilities/scene/config.py @@ -8,14 +8,6 @@ class SceneConfig(CapabilityConfig): batch_size: int = Field(default=32, gt=0) - model: str = Field( - default="google/siglip2-base-patch16-224", - min_length=1, - ) - revision: str = Field( - default="75de2d55ec2d0b4efc50b3e9ad70dba96a7b2fa2", - min_length=1, - ) def scene_config(config: IndexConfig) -> SceneConfig: diff --git a/src/vidxp/capabilities/scene/definition.py b/src/vidxp/capabilities/scene/definition.py index 69fe0d4..b7388fc 100644 --- a/src/vidxp/capabilities/scene/definition.py +++ b/src/vidxp/capabilities/scene/definition.py @@ -8,10 +8,12 @@ CapabilityPlugin, OperationDefinition, PreparationContext, + module_import_check, ) -from vidxp.capabilities.scene.config import SceneConfig, scene_config +from vidxp.capabilities.scene.config import SceneConfig from vidxp.capabilities.scene.indexing import VISUAL_PROCESSOR from vidxp.capabilities.scene.models import get_scene_model +from vidxp.capabilities.scene.specs import SIGLIP2_MODEL from vidxp.capabilities.scene.operations import search_operation from vidxp.capabilities.schemas import SearchInput, SearchResult from vidxp.capabilities.visual import index_capabilities @@ -22,35 +24,28 @@ def prepare_models( context: PreparationContext, progress: ProgressCallback | None, ) -> tuple[str, ...]: - settings = SceneConfig.model_validate(context.settings) + SceneConfig.model_validate(context.settings) if progress is not None: progress( { "state": "preparing", "stage": "scene_model", "message": ( - f"Preparing scene model: SigLIP2 {settings.model}" + f"Preparing scene model: SigLIP2 {SIGLIP2_MODEL.model_id}" ), } ) - get_scene_model( - context.runtime, - settings.model, - settings.revision, - ) - return (settings.model,) + get_scene_model(context.runtime) + return (SIGLIP2_MODEL.model_id,) def model_manifest( config: IndexConfig, _sources: tuple[VideoSource, ...], ) -> Mapping[str, Any]: - settings = scene_config(config) return { "scene": { - "provider": "transformers", - "model": settings.model, - "revision": settings.revision, + **SIGLIP2_MODEL.identity(), } } @@ -64,6 +59,7 @@ def model_manifest( index_stage="visual_indexing", execution_group="visual", prepares_models=True, + model_specs=(SIGLIP2_MODEL,), operations={ "search": OperationDefinition( input_model=SearchInput, @@ -80,6 +76,21 @@ def create_executor() -> CapabilityExecutor: operations={"search": search_operation}, prepare=prepare_models, model_manifest=model_manifest, + runtime_checks=( + module_import_check("OpenCV import", "cv2", "VideoCapture"), + module_import_check("Torch import", "torch"), + module_import_check( + "Transformers import", + "transformers", + "AutoModel", + "AutoProcessor", + ), + module_import_check( + "Hugging Face Hub import", + "huggingface_hub", + "snapshot_download", + ), + ), ) diff --git a/src/vidxp/capabilities/scene/indexing.py b/src/vidxp/capabilities/scene/indexing.py index e501b00..8486d8d 100644 --- a/src/vidxp/capabilities/scene/indexing.py +++ b/src/vidxp/capabilities/scene/indexing.py @@ -5,6 +5,7 @@ from vidxp.capabilities.scene.config import scene_config from vidxp.capabilities.scene.models import SceneModel, get_scene_model +from vidxp.capabilities.scene.specs import SIGLIP2_MODEL from vidxp.core.contracts import ( CancellationToken, IndexConfig, @@ -13,8 +14,7 @@ stable_source_id, ) from vidxp.core.indexing_common import ProgressCallback, report_progress -from vidxp.core.storage import IndexStorage -from vidxp.runtime import ModelRuntime +from vidxp.ports import IndexStore, ModelRuntimePort @dataclass @@ -33,7 +33,7 @@ def encode_scene_batch(samples, provider: SceneModel): ) inputs = {name: value.to(provider.device) for name, value in inputs.items()} with torch.inference_mode(): - features = provider.model.get_image_features(**inputs) + features = provider.model.get_image_features(**inputs).pooler_output features = torch.nn.functional.normalize(features, dim=-1) return features.cpu().numpy().tolist() @@ -82,7 +82,7 @@ def process_scene_samples( state: SceneIndexState, info, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, cancellation: CancellationToken, ) -> None: settings = scene_config(config) @@ -107,20 +107,15 @@ def batch_size(self, config: IndexConfig) -> int: def prepare( self, config: IndexConfig, - runtime: ModelRuntime, + runtime: ModelRuntimePort, progress: ProgressCallback | None, ) -> SceneIndexState: - settings = scene_config(config) report_progress( progress, "preparing_scene_model", - f"Preparing scene model: SigLIP2 {settings.model}.", - ) - provider = get_scene_model( - runtime, - settings.model, - settings.revision, + f"Preparing scene model: SigLIP2 {SIGLIP2_MODEL.model_id}.", ) + provider = get_scene_model(runtime) return SceneIndexState(provider) def process( @@ -130,7 +125,7 @@ def process( state: SceneIndexState, info, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, cancellation: CancellationToken, ) -> None: process_scene_samples( @@ -147,7 +142,7 @@ def finalize( state: SceneIndexState, *, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, ) -> tuple[dict[str, Any], int]: return {"scene_frames": state.stored_frames}, state.stored_frames diff --git a/src/vidxp/capabilities/scene/models.py b/src/vidxp/capabilities/scene/models.py index 9714abd..e8a3fb2 100644 --- a/src/vidxp/capabilities/scene/models.py +++ b/src/vidxp/capabilities/scene/models.py @@ -3,7 +3,9 @@ from dataclasses import dataclass from typing import Any -from vidxp.runtime import ModelKey, ModelRuntime +from vidxp.ports import ModelRuntimePort +from vidxp.model_contracts import loaded_compute_precision +from vidxp.capabilities.scene.specs import SIGLIP2_MODEL @dataclass(frozen=True) @@ -14,26 +16,31 @@ class SceneModel: def get_scene_model( - runtime: ModelRuntime, - model_id: str, - revision: str, + runtime: ModelRuntimePort, ) -> SceneModel: device = runtime.device_for("scene") - key = ModelKey("scene", "transformers", model_id, revision, device) + key = SIGLIP2_MODEL.key(device) def load() -> SceneModel: from transformers import AutoModel, AutoProcessor + snapshot = runtime.resolve_model(SIGLIP2_MODEL) common = { - "cache_dir": str(runtime.settings.model_cache), - "revision": revision, - "local_files_only": not runtime.settings.allow_model_downloads, + "cache_dir": str(runtime.model_cache), + "local_files_only": True, } - model = AutoModel.from_pretrained(model_id, **common).to(device) + model = AutoModel.from_pretrained(snapshot, **common).to(device) model.eval() + runtime.record_compute_precision( + SIGLIP2_MODEL.capability, + loaded_compute_precision( + model, + fallback=SIGLIP2_MODEL.weights_precision, + ), + ) return SceneModel( model=model, - processor=AutoProcessor.from_pretrained(model_id, **common), + processor=AutoProcessor.from_pretrained(snapshot, **common), device=device, ) diff --git a/src/vidxp/capabilities/scene/operations.py b/src/vidxp/capabilities/scene/operations.py index b01047d..9a3d776 100644 --- a/src/vidxp/capabilities/scene/operations.py +++ b/src/vidxp/capabilities/scene/operations.py @@ -3,13 +3,11 @@ from typing import Any, Mapping from vidxp.capabilities.contracts import CapabilityContext -from vidxp.capabilities.scene.config import scene_config from vidxp.capabilities.scene.models import get_scene_model from vidxp.capabilities.schemas import SearchInput, SearchResult from vidxp.capabilities.search import search_embeddings from vidxp.core.contracts import IndexConfig -from vidxp.core.storage import IndexStorage -from vidxp.runtime import ModelRuntime +from vidxp.ports import IndexStore, ModelRuntimePort REQUIRED_METADATA = frozenset( @@ -32,13 +30,11 @@ def scene_embedding( query: str, - config: IndexConfig, - runtime: ModelRuntime, + runtime: ModelRuntimePort, ) -> list[float]: import torch - settings = scene_config(config) - provider = get_scene_model(runtime, settings.model, settings.revision) + provider = get_scene_model(runtime) inputs = provider.processor( text=[query], padding="max_length", @@ -46,7 +42,7 @@ def scene_embedding( ) inputs = {name: value.to(provider.device) for name, value in inputs.items()} with torch.inference_mode(): - features = provider.model.get_text_features(**inputs) + features = provider.model.get_text_features(**inputs).pooler_output features = torch.nn.functional.normalize(features, dim=-1) return features.cpu().numpy().tolist()[0] @@ -55,12 +51,12 @@ def search_scene( query: str, *, config: IndexConfig, - runtime: ModelRuntime, + runtime: ModelRuntimePort, top_k: int = 10, video_id: str | None = None, query_id: str | None = None, filters: Mapping[str, Any] | None = None, - storage: IndexStorage | None = None, + storage: IndexStore, ) -> SearchResult: cleaned = query.strip() if not cleaned: @@ -70,7 +66,7 @@ def search_scene( return search_embeddings( cleaned, "scene", - scene_embedding(cleaned, config, runtime), + scene_embedding(cleaned, runtime), config=config, required_metadata=REQUIRED_METADATA, top_k=top_k, @@ -92,4 +88,5 @@ def search_operation( top_k=request.top_k, video_id=config.video_id, runtime=context.runtime, + storage=context.require_storage(), ) diff --git a/src/vidxp/capabilities/scene/requirements.txt b/src/vidxp/capabilities/scene/requirements.txt index 44473b3..ac5dfd9 100644 --- a/src/vidxp/capabilities/scene/requirements.txt +++ b/src/vidxp/capabilities/scene/requirements.txt @@ -3,3 +3,4 @@ opencv-python-headless>=5.0.0.93,<6 Pillow>=12.3,<13 torch>=2.13,<3 transformers>=5.14.1,<6 +huggingface-hub>=1.25.1,<2 diff --git a/src/vidxp/capabilities/scene/specs.py b/src/vidxp/capabilities/scene/specs.py new file mode 100644 index 0000000..c9d8a3c --- /dev/null +++ b/src/vidxp/capabilities/scene/specs.py @@ -0,0 +1,15 @@ +from vidxp.model_contracts import ModelSpec + + +SIGLIP2_MODEL = ModelSpec( + capability="scene", + provider="transformers", + model_id="google/siglip2-base-patch16-224", + revision="75de2d55ec2d0b4efc50b3e9ad70dba96a7b2fa2", + weights_file="model.safetensors", + weights_sha256=( + "612923381c76ec5a9bed335d1c48827e3f2e506ac31b044b63b2031fadee6a0b" + ), + license="Apache-2.0", + weights_precision="float32", +) diff --git a/src/vidxp/capabilities/schemas.py b/src/vidxp/capabilities/schemas.py index a70b948..e026159 100644 --- a/src/vidxp/capabilities/schemas.py +++ b/src/vidxp/capabilities/schemas.py @@ -10,7 +10,7 @@ class SearchInput(CapabilityInput): query: str = Field(min_length=1) - top_k: int = Field(default=10, gt=0) + top_k: int = Field(default=10, gt=0, le=100) class SearchHit(CapabilityOutput): @@ -29,16 +29,14 @@ def to_dict(self) -> dict[str, Any]: class SearchResult(CapabilityOutput): + schema_version: int = INDEX_SCHEMA_VERSION query_id: str = Field(min_length=1) query: str = Field(min_length=1) modality: str = Field(min_length=1) hits: tuple[SearchHit, ...] = () def to_dict(self) -> dict[str, Any]: - return { - "schema_version": INDEX_SCHEMA_VERSION, - **self.model_dump(mode="json"), - } + return self.model_dump(mode="json") def to_prediction(self) -> dict[str, list[dict[str, Any]]]: return { diff --git a/src/vidxp/capabilities/search.py b/src/vidxp/capabilities/search.py index 4b8a6ff..4741522 100644 --- a/src/vidxp/capabilities/search.py +++ b/src/vidxp/capabilities/search.py @@ -11,7 +11,7 @@ IndexConfig, IndexSchemaError, ) -from vidxp.core.storage import IndexStorage +from vidxp.ports import IndexStore def distance_to_score(raw_distance: float) -> float: @@ -98,7 +98,7 @@ def search_embeddings( video_id: str | None = None, query_id: str | None = None, filters: Mapping[str, Any] | None = None, - storage: IndexStorage | None = None, + storage: IndexStore, ) -> SearchResult: query = query.strip() if not query: @@ -109,19 +109,13 @@ def search_embeddings( raise ValueError( f"The {modality} modality is not present in this index run." ) - owns_storage = storage is None - store = storage or IndexStorage(config) - try: - rows = store.query( - modality, - embedding, - top_k=top_k, - video_id=video_id, - filters=filters, - ) - finally: - if owns_storage: - store.close() + rows = storage.query( + modality, + embedding, + top_k=top_k, + video_id=video_id, + filters=filters, + ) return SearchResult( query_id=query_id or stable_query_id(query, modality, config), query=query, diff --git a/src/vidxp/capabilities/visual.py b/src/vidxp/capabilities/visual.py index a4b27c5..6cf3daf 100644 --- a/src/vidxp/capabilities/visual.py +++ b/src/vidxp/capabilities/visual.py @@ -12,8 +12,7 @@ VideoSource, ) from vidxp.core.indexing_common import ProgressCallback, report_progress -from vidxp.core.storage import IndexStorage -from vidxp.runtime import ModelRuntime +from vidxp.ports import IndexStore, ModelRuntimePort from vidxp.core.video import ( FrameSample, FrameStreamStats, @@ -28,7 +27,7 @@ def batch_size(self, config: IndexConfig) -> int: ... def prepare( self, config: IndexConfig, - runtime: ModelRuntime, + runtime: ModelRuntimePort, progress: ProgressCallback | None, ) -> Any: ... @@ -39,7 +38,7 @@ def process( state: Any, info: Any, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, cancellation: CancellationToken, ) -> None: ... @@ -48,7 +47,7 @@ def finalize( state: Any, *, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, ) -> tuple[dict[str, Any], int]: ... @@ -77,7 +76,7 @@ def _participants( *, config: IndexConfig, registry: CapabilityRegistry, - runtime: ModelRuntime, + runtime: ModelRuntimePort, progress: ProgressCallback | None, timings: dict[str, float], ) -> list[_Participant]: @@ -102,7 +101,7 @@ def _consume_visual_stream( expected: int, info: Any, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, cancellation: CancellationToken, progress: ProgressCallback | None, timings: dict[str, float], @@ -158,7 +157,7 @@ def _finalize( participants: Sequence[_Participant], *, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, timings: dict[str, float], ) -> tuple[dict[str, Any], int]: summary: dict[str, Any] = {} @@ -186,10 +185,10 @@ def index_visuals( source: VideoSource, *, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, cancellation: CancellationToken, registry: CapabilityRegistry, - runtime: ModelRuntime, + runtime: ModelRuntimePort, progress: ProgressCallback | None = None, modalities: Sequence[str] | None = None, ) -> CapabilityIndexResult: @@ -266,10 +265,10 @@ def index_capabilities( source: VideoSource, *, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, cancellation: CancellationToken, registry: CapabilityRegistry, - runtime: ModelRuntime, + runtime: ModelRuntimePort, progress: ProgressCallback | None = None, modalities: Sequence[str] | None = None, ) -> CapabilityIndexResult: diff --git a/src/vidxp/cli.py b/src/vidxp/cli.py index ebe828e..e003098 100644 --- a/src/vidxp/cli.py +++ b/src/vidxp/cli.py @@ -10,22 +10,14 @@ from vidxp import __version__ from vidxp.application_models import ApplicationError -from vidxp.capabilities.actor.results import ActorClusterNotFoundError from vidxp.capabilities.actor.cli import app as actor_app from vidxp.cli_commands.index import app as index_app from vidxp.cli_commands.repositories import app as repositories_app from vidxp.cli_commands.runtime import doctor, prepare, ui -from vidxp.cli_commands.search import app as search_app +from vidxp.cli_commands.search import search from vidxp.cli_support import CLIState, OutputFormat -from vidxp.composition import create_application -from vidxp.core.contracts import IndexSchemaError +from vidxp.composition import create_local_application from vidxp.dependencies import requirements_available -from vidxp.index_state import ( - IndexingInProgressError, - IndexNotReadyError, -) -from vidxp.repositories import resolve_repository -from vidxp.settings import VidXPSettings app = typer.Typer( @@ -33,7 +25,7 @@ help="Index and search video with installable capabilities.", ) app.add_typer(index_app, name="index") -app.add_typer(search_app, name="search") +app.command("search")(search) app.add_typer(repositories_app, name="repositories") app.add_typer(actor_app, name="actors") @@ -116,21 +108,16 @@ def app_options( typer.Option("--quiet", "-q", help="Suppress progress output."), ] = False, ) -> None: - registry, repository = resolve_repository( + local = create_local_application( registry_path=config_file, - name=repository_name, + repository_name=repository_name, index_directory=index_directory, device=device, ) ctx.obj = CLIState( - service=create_application( - VidXPSettings( - repository_root=repository.index_directory, - runtime_backend=repository.device or "auto", - ) - ), - registry=registry, - repository=repository, + service=local.application, + registry=local.repositories, + repository=local.repository, output_format=output_format, quiet=quiet, ) @@ -203,19 +190,7 @@ def main() -> None: exc, "format_message", ) - is_expected_runtime_error = isinstance( - exc, - ( - ActorClusterNotFoundError, - FileNotFoundError, - IndexNotReadyError, - IndexingInProgressError, - IndexSchemaError, - RuntimeError, - ValueError, - ), - ) - if not is_command_error and not is_expected_runtime_error: + if not is_command_error: raise _emit_error(exc, json_output=_wants_json()) raise SystemExit(_exit_code(exc)) from exc diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py index 6532c2c..4d8e448 100644 --- a/src/vidxp/cli_commands/runtime.py +++ b/src/vidxp/cli_commands/runtime.py @@ -1,15 +1,15 @@ from __future__ import annotations -import os from typing import Annotated import typer from vidxp.application_models import ( + ApplicationError, DependencyCheckCommand, + ErrorCategory, PrepareModelsCommand, ) -from vidxp.capabilities.registry import create_capability_registry from vidxp.cli_support import ( OutputFormat, effective_output_format, @@ -19,23 +19,16 @@ state_from_context, ) -_COMMAND_REGISTRY = create_capability_registry() -ALL_CAPABILITIES = ",".join(_COMMAND_REGISTRY.names()) -PREPARABLE_CAPABILITIES = ",".join( - _COMMAND_REGISTRY.preparable_names() -) - - def doctor( ctx: typer.Context, modalities: Annotated[ - str, + str | None, typer.Option( "--modalities", "-m", help="Only validate dependencies for these modalities.", ), - ] = ALL_CAPABILITIES, + ] = None, json_output: Annotated[ bool, typer.Option("--json", help="Emit machine-readable JSON."), @@ -44,7 +37,11 @@ def doctor( """Validate selected indexing dependencies without downloading models.""" state = state_from_context(ctx) - selected = parse_modalities(modalities, state.service.registry) + selected = ( + state.service.registry.names() + if modalities is None + else parse_modalities(modalities, state.service.registry) + ) result = state.service.check_dependencies( DependencyCheckCommand(modalities=selected) ) @@ -53,15 +50,20 @@ def doctor( emit_json(payload) else: for check in payload["checks"]: + owner = check["capability"] + if check["provenance"] is not None: + owner = ( + f"{check['provenance']['distribution']}:" + f"{check['provenance']['entry_point']}" + ) if check["ok"]: - detail = f": {check['path']}" if check.get("path") else "" typer.secho( - f"OK {check['name']}{detail}", + f"OK [{owner}] {check['name']}", fg=typer.colors.GREEN, ) else: typer.secho( - f"FAILED {check['name']}: {check['error']}", + f"FAILED [{owner}] {check['name']}: {check['error']}", fg=typer.colors.RED, ) if not result.ok: @@ -77,13 +79,13 @@ def doctor( def prepare( ctx: typer.Context, modalities: Annotated[ - str, + str | None, typer.Option( "--modalities", "-m", help="Only prepare models for these modalities.", ), - ] = PREPARABLE_CAPABILITIES, + ] = None, capability_options: Annotated[ list[str] | None, typer.Option( @@ -102,7 +104,11 @@ def prepare( """Download and cache selected runtime models before indexing.""" state = state_from_context(ctx) - selected = parse_modalities(modalities, state.service.registry) + selected = ( + state.service.registry.preparable_names() + if modalities is None + else parse_modalities(modalities, state.service.registry) + ) result = state.service.prepare_models( PrepareModelsCommand( modalities=selected, @@ -145,21 +151,15 @@ def ui( """Launch Streamlit with the selected repository configuration.""" state = state_from_context(ctx) - os.environ["VIDXP_CONFIG_FILE"] = str(state.registry.path) - os.environ["VIDXP_REPOSITORY"] = state.repository.name - os.environ["VIDXP_INDEX_DIR"] = str(state.service.layout.root) - if state.service.device is None: - os.environ.pop("VIDXP_DEVICE", None) - else: - os.environ["VIDXP_DEVICE"] = state.service.device - try: from vidxp import frontend except ModuleNotFoundError as exc: if exc.name == "streamlit": - raise RuntimeError( - "The browser interface requires the frontend extra. " - "Install vidxp[frontend]." + raise ApplicationError( + "frontend_unavailable", + ErrorCategory.unavailable, + "The browser interface is unavailable. " + 'Install the "frontend" extra.', ) from exc raise @@ -168,4 +168,7 @@ def ui( streamlit_arguments.append(f"--server.address={host}") if port is not None: streamlit_arguments.append(f"--server.port={port}") - frontend.main(streamlit_arguments) + frontend.main( + streamlit_arguments, + settings=state.service.settings, + ) diff --git a/src/vidxp/cli_commands/search.py b/src/vidxp/cli_commands/search.py index 15f6a8d..a0131fb 100644 --- a/src/vidxp/cli_commands/search.py +++ b/src/vidxp/cli_commands/search.py @@ -1,11 +1,10 @@ from __future__ import annotations -from typing import Annotated, Callable +from typing import Annotated import typer from vidxp.application_models import SearchCommand -from vidxp.capabilities.registry import create_capability_registry from vidxp.capabilities.schemas import SearchResult from vidxp.cli_support import ( CLIState, @@ -15,9 +14,6 @@ ) -app = typer.Typer(no_args_is_help=True, help="Search the active index.") - - def run_search( state: CLIState, capability: str, @@ -40,40 +36,36 @@ def run_search( return result -def _search_command(capability: str) -> Callable: - def command( - ctx: typer.Context, - query: Annotated[ - str, - typer.Argument(help="Text query to find."), - ], - top_k: Annotated[ - int, - typer.Option( - "--top-k", - "-k", - min=1, - help="Maximum ranked hits.", - ), - ] = 10, - json_output: Annotated[ - bool, - typer.Option("--json", help="Emit machine-readable JSON."), - ] = False, - ) -> None: - run_search( - state_from_context(ctx), - capability, - query, - top_k=top_k, - json_output=json_output, - ) - - command.__name__ = f"search_{capability}" - command.__doc__ = create_capability_registry().get(capability).description - return command - - -for _name, _capability in create_capability_registry().definitions.items(): - if "search" in _capability.operations: - app.command(_name)(_search_command(_name)) +def search( + ctx: typer.Context, + capability: Annotated[ + str, + typer.Argument(help="Registered capability to query."), + ], + query: Annotated[ + str, + typer.Argument(help="Text query to find."), + ], + top_k: Annotated[ + int, + typer.Option( + "--top-k", + "-k", + min=1, + max=100, + help="Maximum ranked hits.", + ), + ] = 10, + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + state = state_from_context(ctx) + run_search( + state, + capability, + query, + top_k=top_k, + json_output=json_output, + ) diff --git a/src/vidxp/composition.py b/src/vidxp/composition.py index c8adb7b..55d8458 100644 --- a/src/vidxp/composition.py +++ b/src/vidxp/composition.py @@ -1,12 +1,41 @@ from __future__ import annotations +from dataclasses import dataclass +from pathlib import Path + +from pydantic import ValidationError + from vidxp.application import VidXPApplication +from vidxp.application_models import ApplicationError, ErrorCategory from vidxp.capabilities.registry import create_capability_registry -from vidxp.infrastructure.local_index import LocalIndexBackend -from vidxp.runtime import ModelRuntime +from vidxp.infrastructure.local_index import ( + LOCAL_INDEX_RUNTIME_CHECKS, + LocalIndexBackend, +) +from vidxp.runtime import ModelRuntime, RuntimeBackendUnavailableError +from vidxp.repositories import ( + RepositoryConfig, + RepositoryConfigError, + RepositoryRegistry, + resolve_repository, +) from vidxp.settings import VidXPSettings +@dataclass(frozen=True) +class LocalApplicationContext: + application: VidXPApplication + repositories: RepositoryRegistry + repository: RepositoryConfig + + +def settings_for_repository(repository: RepositoryConfig) -> VidXPSettings: + values = {"repository_root": repository.index_directory} + if repository.device is not None: + values["runtime_backend"] = repository.device + return VidXPSettings(**values) + + def create_application( settings: VidXPSettings | None = None, ) -> VidXPApplication: @@ -14,12 +43,52 @@ def create_application( registry = create_capability_registry( external=active_settings.external_capabilities, allowlist=active_settings.capability_allowlist, + platform_runtime_checks=LOCAL_INDEX_RUNTIME_CHECKS, ) - runtime = ModelRuntime(active_settings) + try: + runtime = ModelRuntime( + active_settings, + allowed_specs=registry.model_specs(), + ) + except RuntimeBackendUnavailableError as exc: + raise ApplicationError( + "runtime_unavailable", + ErrorCategory.unavailable, + "The requested runtime backend is unavailable.", + ) from exc backend = LocalIndexBackend(registry, runtime) return VidXPApplication( + settings=active_settings, layout=active_settings.layout, registry=registry, runtime=runtime, index_backend=backend, ) + + +def create_local_application( + *, + registry_path: str | Path | None = None, + repository_name: str | None = None, + index_directory: str | Path | None = None, + device: str | None = None, +) -> LocalApplicationContext: + try: + repositories, repository = resolve_repository( + registry_path=registry_path, + name=repository_name, + index_directory=index_directory, + device=device, + ) + settings = settings_for_repository(repository) + except (RepositoryConfigError, ValidationError) as exc: + raise ApplicationError( + "configuration_invalid", + ErrorCategory.validation, + "The repository or runtime configuration is invalid.", + ) from exc + return LocalApplicationContext( + application=create_application(settings), + repositories=repositories, + repository=repository, + ) diff --git a/src/vidxp/core/manifest.py b/src/vidxp/core/manifest.py index cff3c03..d2b99cc 100644 --- a/src/vidxp/core/manifest.py +++ b/src/vidxp/core/manifest.py @@ -13,7 +13,6 @@ from vidxp.capabilities.registry import ( CapabilityRegistry, - create_capability_registry, ) from vidxp.core.contracts import ( INDEX_SCHEMA_VERSION, @@ -21,7 +20,7 @@ IndexConfig, VideoSource, ) -from vidxp.runtime import ModelRuntime +from vidxp.ports import ModelRuntimePort MANIFEST_FILE = "manifest.json" @@ -155,11 +154,10 @@ def implementation_digest() -> str: def dependency_versions( - registry: CapabilityRegistry | None = None, + registry: CapabilityRegistry, ) -> dict[str, str | None]: - active_registry = registry or create_capability_registry() versions: dict[str, str | None] = {} - for package in active_registry.runtime_distributions(): + for package in registry.runtime_distributions(): try: versions[package] = version(package) except PackageNotFoundError: @@ -168,7 +166,7 @@ def dependency_versions( def execution_state( - registry: CapabilityRegistry | None = None, + registry: CapabilityRegistry, ) -> dict[str, Any]: try: package_version = version("vidxp") @@ -203,11 +201,11 @@ def __init__( self, config: IndexConfig, *, - registry: CapabilityRegistry | None = None, - runtime: ModelRuntime | None = None, + registry: CapabilityRegistry, + runtime: ModelRuntimePort, ): self.config = config - self.registry = registry or create_capability_registry() + self.registry = registry self.runtime = runtime self.run_directory = config.run_directory self.manifest_path = self.run_directory / MANIFEST_FILE @@ -230,11 +228,7 @@ def _model_manifest( ) -> dict[str, Any]: models: dict[str, Any] = { "device": self.config.device, - "runtime": ( - self.runtime.describe() - if self.runtime is not None - else {"torch_device": self.config.device} - ), + "runtime": self.runtime.describe(), } source_values = tuple(source for _, source, _, _ in sources) for name in self.config.enabled_modalities: @@ -340,6 +334,9 @@ def read(self) -> dict[str, Any]: def write(self, manifest: Mapping[str, Any]) -> None: write_json_atomic(self.manifest_path, manifest) + def _refresh_runtime(self, manifest: dict[str, Any]) -> None: + manifest["models"]["runtime"] = self.runtime.describe() + def checkpoint(self, video_id: str) -> dict[str, Any] | None: path = self._checkpoint_path(video_id) if not path.is_file(): @@ -451,6 +448,7 @@ def complete_video( if video_id not in manifest["completed_videos"]: manifest["completed_videos"].append(video_id) manifest["completed_videos"].sort() + self._refresh_runtime(manifest) manifest["updated_at"] = utc_now() self.write(manifest) @@ -470,6 +468,7 @@ def fail_video(self, video_id: str, stage: str, error: str) -> None: manifest["failed_videos"].append(video_id) manifest["failed_videos"].sort() manifest["state"] = "failed" + self._refresh_runtime(manifest) manifest["updated_at"] = utc_now() self.write(manifest) @@ -486,11 +485,13 @@ def interrupt_video(self, video_id: str, stage: str) -> None: manifest["interrupted_videos"].append(video_id) manifest["interrupted_videos"].sort() manifest["state"] = "interrupted" + self._refresh_runtime(manifest) manifest["updated_at"] = utc_now() self.write(manifest) def complete_run(self, *, index_size_bytes: int) -> dict[str, Any]: manifest = self.read() + self._refresh_runtime(manifest) expected = set(manifest["inputs"]) completed = set(manifest["completed_videos"]) if expected != completed or manifest["failed_videos"]: diff --git a/src/vidxp/core/runner.py b/src/vidxp/core/runner.py index 6b15462..0637db3 100644 --- a/src/vidxp/core/runner.py +++ b/src/vidxp/core/runner.py @@ -7,10 +7,7 @@ from filelock import FileLock, Timeout -from vidxp.capabilities.registry import ( - CapabilityRegistry, - create_capability_registry, -) +from vidxp.capabilities.registry import CapabilityRegistry from vidxp.core.contracts import ( INDEX_SCHEMA_VERSION, CancellationToken, @@ -25,13 +22,11 @@ source_checksum, source_checksums, ) -from vidxp.core.storage import IndexStorage from vidxp.index_state import ( IndexingInProgressError, write_index_status, ) -from vidxp.runtime import ModelRuntime -from vidxp.settings import VidXPSettings +from vidxp.ports import IndexStore, ModelRuntimePort ProgressCallback = Callable[[dict[str, Any]], None] @@ -81,11 +76,10 @@ def _run_lock_held(run_directory: Path) -> bool: return False -def indexing_in_progress(config: IndexConfig | None = None) -> bool: +def indexing_in_progress(config: IndexConfig) -> bool: if _INDEXING_LOCK.locked(): return True - active_config = config or IndexConfig.local() - return _run_lock_held(active_config.run_directory) + return _run_lock_held(config.run_directory) def local_config_from_status( @@ -153,12 +147,12 @@ def _run_capability_group( names: tuple[str, ...], source: VideoSource, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, manifest: ManifestStore, cancellation: CancellationToken, progress_callback: ProgressCallback | None, registry: CapabilityRegistry, - runtime: ModelRuntime, + runtime: ModelRuntimePort, ) -> dict[str, Any]: definitions = tuple(registry.get(name) for name in names) indexers = tuple(registry.executor(name).indexer for name in names) @@ -289,13 +283,13 @@ def _index_groups( def _run_enabled_modalities( source: VideoSource, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, manifest: ManifestStore, cancellation: CancellationToken, progress_callback: ProgressCallback | None, set_stage: Callable[[str], None], registry: CapabilityRegistry, - runtime: ModelRuntime, + runtime: ModelRuntimePort, ) -> dict[str, Any]: summary: dict[str, Any] = {} for names in _index_groups(config.enabled_modalities, registry): @@ -322,14 +316,14 @@ def _process_video( source: VideoSource, checksum: str, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, manifest: ManifestStore, cancellation: CancellationToken, progress_callback: ProgressCallback | None, *, fail_fast: bool, registry: CapabilityRegistry, - runtime: ModelRuntime, + runtime: ModelRuntimePort, ) -> None: video_config = config.for_video(video_id) manifest.start_video(video_id) @@ -411,66 +405,51 @@ def _run_index_unlocked( resume: bool = True, reset: bool = False, fail_fast: bool = True, - storage: IndexStorage | None = None, - manifest_store: ManifestStore | None = None, - registry: CapabilityRegistry | None = None, - runtime: ModelRuntime | None = None, + storage: IndexStore, + manifest_store: ManifestStore, + registry: CapabilityRegistry, + runtime: ModelRuntimePort, ) -> dict[str, Any]: if not sources: raise ValueError("At least one video or transcript source is required.") cancellation = cancellation or CancellationToken() - registry = registry or create_capability_registry() - runtime = runtime or ModelRuntime( - VidXPSettings(runtime_backend=config.device) - ) resolved = _resolve_sources(sources, config) - owns_storage = storage is None - store = storage or IndexStorage(config) - try: - manifest = manifest_store or ManifestStore( + if reset: + storage.clear() + manifest_store.initialize(resolved, reset=reset) + + for video_id, source, checksum, _ in resolved: + if resume and manifest_store.completed( + video_id, + checksum=checksum, + config_fingerprint=config.fingerprint(), + ): + _report( + progress_callback, + { + "state": "skipped", + "stage": "checkpoint", + "message": f"Skipping completed video {video_id}.", + "video_id": video_id, + }, + ) + continue + + _process_video( + video_id, + source, + checksum, config, + storage, + manifest_store, + cancellation, + progress_callback, + fail_fast=fail_fast, registry=registry, runtime=runtime, ) - if reset: - store.clear() - manifest.initialize(resolved, reset=reset) - - for video_id, source, checksum, _ in resolved: - if resume and manifest.completed( - video_id, - checksum=checksum, - config_fingerprint=config.fingerprint(), - ): - _report( - progress_callback, - { - "state": "skipped", - "stage": "checkpoint", - "message": f"Skipping completed video {video_id}.", - "video_id": video_id, - }, - ) - continue - - _process_video( - video_id, - source, - checksum, - config, - store, - manifest, - cancellation, - progress_callback, - fail_fast=fail_fast, - registry=registry, - runtime=runtime, - ) - return manifest.complete_run(index_size_bytes=store.size_bytes()) - finally: - if owns_storage: - store.close() + return manifest_store.complete_run(index_size_bytes=storage.size_bytes()) def run_index( @@ -482,10 +461,10 @@ def run_index( resume: bool = True, reset: bool = False, fail_fast: bool = True, - storage: IndexStorage | None = None, - manifest_store: ManifestStore | None = None, - registry: CapabilityRegistry | None = None, - runtime: ModelRuntime | None = None, + storage: IndexStore, + manifest_store: ManifestStore, + registry: CapabilityRegistry, + runtime: ModelRuntimePort, ) -> dict[str, Any]: with _RunLock(config.run_directory): return _run_index_unlocked( @@ -518,10 +497,12 @@ def index_video( progress_callback: ProgressCallback | None = None, source_name: str | None = None, *, - config: IndexConfig | None = None, + config: IndexConfig, cancellation: CancellationToken | None = None, - registry: CapabilityRegistry | None = None, - runtime: ModelRuntime | None = None, + storage: IndexStore, + manifest_store: ManifestStore, + registry: CapabilityRegistry, + runtime: ModelRuntimePort, ) -> dict[str, Any]: input_path = Path(path) if not input_path.is_file(): @@ -539,7 +520,7 @@ def index_video( source_name=source.source_name, checksum=checksum, ) - active_config = config or IndexConfig.local(video_id=checksum) + active_config = config video_id = active_config.video_id or checksum video = _video_status( input_path, @@ -583,6 +564,8 @@ def report(event: dict[str, Any]) -> None: active_config, progress_callback=report, cancellation=cancellation, + storage=storage, + manifest_store=manifest_store, registry=registry, runtime=runtime, resume=False, diff --git a/src/vidxp/frontend.py b/src/vidxp/frontend.py index 91c74c1..3bee718 100644 --- a/src/vidxp/frontend.py +++ b/src/vidxp/frontend.py @@ -1,4 +1,6 @@ +import argparse import hashlib +import logging import sys from functools import lru_cache from pathlib import Path @@ -7,7 +9,11 @@ import streamlit as st from vidxp.application import VidXPApplication -from vidxp.application_models import DependencyCheckCommand, SearchCommand +from vidxp.application_models import ( + ApplicationError, + DependencyCheckCommand, + SearchCommand, +) from vidxp.composition import create_application from vidxp.index_state import IndexNotReadyError from vidxp.index_worker import ( @@ -15,19 +21,30 @@ indexing_in_progress, start_indexing, ) -from vidxp.repositories import resolve_repository from vidxp.settings import VidXPSettings +LOGGER = logging.getLogger(__name__) + + @lru_cache(maxsize=1) -def _configured_service() -> VidXPApplication: - _, repository = resolve_repository() - return create_application( - VidXPSettings( - repository_root=repository.index_directory, - runtime_backend=repository.device or "auto", - ) +def _configured_service( + settings: VidXPSettings | None = None, +) -> VidXPApplication: + return create_application(settings or _settings_from_arguments()) + + +def _settings_from_arguments( + arguments: Sequence[str] | None = None, +) -> VidXPSettings: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--vidxp-settings-json") + parsed, _ = parser.parse_known_args( + list(sys.argv[1:] if arguments is None else arguments) ) + if parsed.vidxp_settings_json is None: + return VidXPSettings() + return VidXPSettings.model_validate_json(parsed.vidxp_settings_json) INDEX_REQUESTED_KEY = "_vidxp_index_requested" @@ -158,8 +175,13 @@ def _run_indexing(uploaded_video, status, modalities): service, modalities=modalities, ) - except Exception as exc: - st.session_state[INDEX_ERROR_KEY] = f"{type(exc).__name__}: {exc}" + except ApplicationError as exc: + st.session_state[INDEX_ERROR_KEY] = str(exc) + except Exception: + LOGGER.exception("Unexpected indexing request failure") + st.session_state[INDEX_ERROR_KEY] = ( + "Indexing could not be started. Check the application logs." + ) else: st.session_state.pop(INDEX_ERROR_KEY, None) finally: @@ -212,8 +234,11 @@ def _run_search(search_type, query): } except IndexNotReadyError as exc: return {"error": str(exc)} - except Exception as exc: - return {"error": f"{type(exc).__name__}: {exc}"} + except ApplicationError as exc: + return {"error": str(exc)} + except Exception: + LOGGER.exception("Unexpected search failure") + return {"error": "Search failed. Check the application logs."} def _render_search_result(result): @@ -421,14 +446,25 @@ def poll_index_status(): _run_indexing(uploaded_video, status, selected_modalities) -def main(arguments: Sequence[str] = ()): +def main( + arguments: Sequence[str] = (), + settings: VidXPSettings | None = None, +): from streamlit.web import cli as streamlit_cli + application_arguments = [] + if settings is not None: + application_arguments = [ + "--", + "--vidxp-settings-json", + settings.model_dump_json(), + ] sys.argv = [ "streamlit", "run", str(Path(__file__).resolve()), *arguments, + *application_arguments, ] raise SystemExit(streamlit_cli.main()) diff --git a/src/vidxp/index_worker.py b/src/vidxp/index_worker.py index f21aa32..ae4593f 100644 --- a/src/vidxp/index_worker.py +++ b/src/vidxp/index_worker.py @@ -1,13 +1,13 @@ from multiprocessing import get_context from multiprocessing.process import BaseProcess from threading import Lock +from typing import Any, Mapping from vidxp.application import VidXPApplication from vidxp.application_models import CreateIndexCommand from vidxp.composition import create_application from vidxp.core.contracts import CancellationToken from vidxp.index_state import IndexingInProgressError -from vidxp.repositories import resolve_repository from vidxp.settings import VidXPSettings _process: BaseProcess | None = None @@ -15,29 +15,15 @@ _start_lock = Lock() -def _configured_service() -> VidXPApplication: - _, repository = resolve_repository() - return create_application( - VidXPSettings( - repository_root=repository.index_directory, - runtime_backend=repository.device or "auto", - ) - ) - - def _run_indexing( path: str, source_name: str, cancel_event, - repository_root: str, - runtime_backend: str, + settings_payload: Mapping[str, Any], modalities: tuple[str, ...], ) -> None: create_application( - VidXPSettings( - repository_root=repository_root, - runtime_backend=runtime_backend, - ) + VidXPSettings.model_validate(settings_payload) ).create_index( CreateIndexCommand( path=path, @@ -48,25 +34,23 @@ def _run_indexing( ) -def indexing_in_progress(service: VidXPApplication | None = None) -> bool: - active_service = service or _configured_service() +def indexing_in_progress(service: VidXPApplication) -> bool: return ( _process is not None and _process.is_alive() - ) or active_service.indexing_in_progress() + ) or service.indexing_in_progress() def start_indexing( path: str, source_name: str, - service: VidXPApplication | None = None, + service: VidXPApplication, *, modalities: tuple[str, ...], ) -> None: global _cancel_event, _process - active_service = service or _configured_service() with _start_lock: - if indexing_in_progress(active_service): + if indexing_in_progress(service): raise IndexingInProgressError("Another video is already being indexed.") context = get_context("spawn") @@ -77,8 +61,7 @@ def start_indexing( path, source_name, _cancel_event, - str(active_service.layout.root), - active_service.runtime.backends.requested, + service.settings.model_dump(mode="python"), modalities, ), name="vidxp-indexer", diff --git a/src/vidxp/infrastructure/local_index.py b/src/vidxp/infrastructure/local_index.py index d86b55d..83123f2 100644 --- a/src/vidxp/infrastructure/local_index.py +++ b/src/vidxp/infrastructure/local_index.py @@ -4,6 +4,10 @@ from pathlib import Path from typing import Any +from vidxp.capabilities.contracts import ( + RuntimeCheckBinding, + module_import_check, +) from vidxp.capabilities.registry import CapabilityRegistry from vidxp.core.contracts import CancellationToken, IndexConfig from vidxp.core.indexing_common import ProgressCallback @@ -12,11 +16,43 @@ indexing_in_progress, local_config_from_status, ) +from vidxp.core.manifest import ( + CHECKPOINT_DIRECTORY, + COMPLETION_FILE, + FAILURES_FILE, + MANIFEST_FILE, + TIMINGS_FILE, + ManifestStore, +) from vidxp.core.storage import IndexStorage -from vidxp.index_state import read_index_status, require_ready_index +from vidxp.index_state import ( + INDEX_STATUS_FILE, + read_index_status, + require_ready_index, +) from vidxp.runtime import ModelRuntime +LOCAL_INDEX_RUNTIME_CHECKS = ( + RuntimeCheckBinding( + capability="storage", + check=module_import_check( + "Chroma storage import", + "chromadb", + "PersistentClient", + ), + ), + RuntimeCheckBinding( + capability="storage", + check=module_import_check( + "Host resource monitor import", + "psutil", + "virtual_memory", + ), + ), +) + + class LocalIndexBackend: def __init__( self, @@ -51,19 +87,45 @@ def create( cancellation: CancellationToken | None, source_name: str | None, ) -> dict[str, Any]: - return index_video( - str(path), - progress_callback=progress, - source_name=source_name, - config=config, - cancellation=cancellation, - registry=self.registry, - runtime=self.runtime, - ) + with IndexStorage(config) as storage: + return index_video( + str(path), + progress_callback=progress, + source_name=source_name, + config=config, + cancellation=cancellation, + storage=storage, + manifest_store=ManifestStore( + config, + registry=self.registry, + runtime=self.runtime, + ), + registry=self.registry, + runtime=self.runtime, + ) def indexing_in_progress(self, config: IndexConfig) -> bool: return indexing_in_progress(config) + def open_store(self, config: IndexConfig) -> IndexStorage: + return IndexStorage(config) + def clear(self, config: IndexConfig) -> None: with IndexStorage(config) as storage: storage.clear() + for name in ( + INDEX_STATUS_FILE, + MANIFEST_FILE, + TIMINGS_FILE, + FAILURES_FILE, + COMPLETION_FILE, + ): + (config.run_directory / name).unlink(missing_ok=True) + checkpoint_directory = config.run_directory / CHECKPOINT_DIRECTORY + if checkpoint_directory.is_dir(): + for checkpoint in checkpoint_directory.glob("*.json"): + checkpoint.unlink() + try: + checkpoint_directory.rmdir() + except OSError: + pass diff --git a/src/vidxp/model_contracts.py b/src/vidxp/model_contracts.py new file mode 100644 index 0000000..b862749 --- /dev/null +++ b/src/vidxp/model_contracts.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from dataclasses import dataclass +import re +from typing import Any + + +_IMMUTABLE_REVISION = re.compile(r"^[0-9a-f]{40,64}$") +_SHA256 = re.compile(r"^[0-9a-f]{64}$") + + +def loaded_compute_precision(model: Any, *, fallback: str) -> str: + try: + value = next(model.parameters()).dtype + except (AttributeError, StopIteration, TypeError): + return fallback + return str(value).removeprefix("torch.") + + +class ModelArtifactUnavailableError(RuntimeError): + def __init__(self, capability: str) -> None: + self.capability = capability + super().__init__(f"Model artifacts for {capability} are unavailable.") + + +@dataclass(frozen=True) +class ModelKey: + capability: str + provider: str + model_id: str + revision: str + device: str + + +@dataclass(frozen=True) +class ModelSpec: + capability: str + provider: str + model_id: str + revision: str + weights_file: str + weights_sha256: str + license: str + weights_precision: str + + def __post_init__(self) -> None: + if not _IMMUTABLE_REVISION.fullmatch(self.revision): + raise ValueError("Model revisions must be immutable commit hashes.") + if not _SHA256.fullmatch(self.weights_sha256): + raise ValueError("Model weight checksums must be SHA-256 hashes.") + + def key(self, device: str) -> ModelKey: + return ModelKey( + capability=self.capability, + provider=self.provider, + model_id=self.model_id, + revision=self.revision, + device=device, + ) + + def identity(self, *, cached: bool | None = None) -> dict[str, Any]: + identity = { + "provider": self.provider, + "model": self.model_id, + "revision": self.revision, + "weights": { + "file": self.weights_file, + "sha256": self.weights_sha256, + }, + "license": self.license, + "weights_precision": self.weights_precision, + } + if cached is not None: + identity["cached"] = cached + return identity + + +@dataclass(frozen=True) +class ArtifactSpec: + capability: str + provider: str + model_id: str + revision: str + url: str + filename: str + sha256: str + license: str + weights_precision: str + + def __post_init__(self) -> None: + if not _IMMUTABLE_REVISION.fullmatch(self.revision): + raise ValueError("Artifact revisions must be immutable commit hashes.") + if not _SHA256.fullmatch(self.sha256): + raise ValueError("Artifact checksums must be SHA-256 hashes.") + if not self.url.startswith("https://"): + raise ValueError("Artifact URLs must use HTTPS.") + + def key(self, device: str) -> ModelKey: + return ModelKey( + capability=self.capability, + provider=self.provider, + model_id=self.model_id, + revision=self.revision, + device=device, + ) + + def identity(self, *, cached: bool | None = None) -> dict[str, Any]: + identity = { + "provider": self.provider, + "model": self.model_id, + "revision": self.revision, + "artifact": { + "file": self.filename, + "sha256": self.sha256, + "url": self.url, + }, + "license": self.license, + "weights_precision": self.weights_precision, + } + if cached is not None: + identity["cached"] = cached + return identity diff --git a/src/vidxp/ports.py b/src/vidxp/ports.py index c5c2da6..bd152d4 100644 --- a/src/vidxp/ports.py +++ b/src/vidxp/ports.py @@ -1,10 +1,113 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Protocol +from typing import ( + Any, + Callable, + ContextManager, + Iterable, + Mapping, + Protocol, + runtime_checkable, +) -from vidxp.core.contracts import CancellationToken, IndexConfig +from vidxp.application_models import RuntimeProfile +from vidxp.core.contracts import ( + CancellationToken, + IndexConfig, + StorageRecord, +) from vidxp.core.indexing_common import ProgressCallback +from vidxp.model_contracts import ArtifactSpec, ModelKey, ModelSpec + + +class ResourceLimitError(RuntimeError): + """Raised when configured host capacity cannot admit model work.""" + + +@runtime_checkable +class ResourceSchedulerPort(Protocol): + def indexing(self): ... + + def inference(self): ... + + +@runtime_checkable +class ModelRuntimePort(Protocol): + backends: RuntimeProfile + scheduler: ResourceSchedulerPort + + @property + def model_cache(self) -> Path: ... + + @property + def cpu_thread_budget(self) -> int: ... + + def device_for(self, capability: str) -> str: ... + + def get_or_load( + self, + key: ModelKey, + loader: Callable[[], Any], + ) -> Any: ... + + def resolve_model(self, spec: ModelSpec) -> Path: ... + + def resolve_artifact(self, spec: ArtifactSpec) -> Path: ... + + def record_compute_precision( + self, + capability: str, + precision: str, + ) -> None: ... + + def describe(self) -> dict[str, Any]: ... + + +@runtime_checkable +class IndexStore(Protocol): + """Vector records used by capability handlers.""" + + def clear(self, modalities: Iterable[str] | None = None) -> None: ... + + def delete_video(self, modality: str, video_id: str) -> None: ... + + def delete_records( + self, + modality: str, + *, + video_id: str, + filters: Mapping[str, Any] | None = None, + ) -> None: ... + + def size_bytes(self) -> int: ... + + def upsert( + self, + modality: str, + records: list[StorageRecord], + *, + batch_size: int, + cancellation: CancellationToken, + ) -> int: ... + + def query( + self, + modality: str, + embedding: list[float], + *, + top_k: int, + video_id: str | None = None, + filters: Mapping[str, Any] | None = None, + ) -> list[dict[str, Any]]: ... + + def records( + self, + modality: str, + *, + video_id: str | None = None, + filters: Mapping[str, Any] | None = None, + ) -> list[dict[str, Any]]: ... class IndexBackend(Protocol): @@ -31,4 +134,6 @@ def create( def indexing_in_progress(self, config: IndexConfig) -> bool: ... + def open_store(self, config: IndexConfig) -> ContextManager[IndexStore]: ... + def clear(self, config: IndexConfig) -> None: ... diff --git a/src/vidxp/requirements/server.txt b/src/vidxp/requirements/server.txt new file mode 100644 index 0000000..963f2c1 --- /dev/null +++ b/src/vidxp/requirements/server.txt @@ -0,0 +1,2 @@ +chromadb>=1.5.9,<2 +psutil>=7.2.2,<8 diff --git a/src/vidxp/requirements/storage.txt b/src/vidxp/requirements/storage.txt index ae0fed1..963f2c1 100644 --- a/src/vidxp/requirements/storage.txt +++ b/src/vidxp/requirements/storage.txt @@ -1 +1,2 @@ chromadb>=1.5.9,<2 +psutil>=7.2.2,<8 diff --git a/src/vidxp/runtime.py b/src/vidxp/runtime.py index 1c57fe2..1061f66 100644 --- a/src/vidxp/runtime.py +++ b/src/vidxp/runtime.py @@ -1,15 +1,28 @@ from __future__ import annotations import platform +import hashlib from collections import OrderedDict -from dataclasses import dataclass -from threading import RLock -from typing import Any, Callable +from contextlib import contextmanager +from threading import BoundedSemaphore, Lock, RLock +from typing import Any, Callable, Iterator +from pathlib import Path from vidxp.application_models import RuntimeProfile +from vidxp.model_contracts import ( + ArtifactSpec, + ModelArtifactUnavailableError, + ModelKey, + ModelSpec, +) +from vidxp.ports import ResourceLimitError from vidxp.settings import VidXPSettings +class RuntimeBackendUnavailableError(RuntimeError): + """Raised when an explicitly requested compute backend cannot be used.""" + + def _torch_accelerators() -> tuple[bool, bool]: try: import torch @@ -26,29 +39,34 @@ def resolve_backends(requested: str) -> RuntimeProfile: mps_available, cuda_available = _torch_accelerators() normalized = requested.lower() if normalized == "auto": - if platform.system() == "Darwin" and mps_available: - torch_device = "mps" - else: - torch_device = "cpu" + torch_device = "cpu" elif normalized == "mps": if not mps_available: - raise RuntimeError("MPS was requested but is unavailable.") + raise RuntimeBackendUnavailableError( + "MPS was requested but is unavailable." + ) torch_device = "mps" elif normalized.startswith("cuda"): if not cuda_available: - raise RuntimeError("CUDA was requested but is unavailable.") + raise RuntimeBackendUnavailableError( + "CUDA was requested but is unavailable." + ) if ":" in normalized: import torch index = int(normalized.split(":", 1)[1]) if index >= torch.cuda.device_count(): - raise RuntimeError( + raise RuntimeBackendUnavailableError( f"CUDA device {index} was requested but only " f"{torch.cuda.device_count()} device(s) are available." ) torch_device = normalized - else: + elif normalized == "cpu": torch_device = "cpu" + else: + raise RuntimeBackendUnavailableError( + f"Unsupported runtime backend: {requested!r}." + ) return RuntimeProfile( requested=normalized, torch_device=torch_device, @@ -60,24 +78,99 @@ def resolve_backends(requested: str) -> RuntimeProfile: ) -@dataclass(frozen=True) -class ModelKey: - capability: str - provider: str - model_id: str - revision: str - device: str +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +class ResourceScheduler: + """Bound concurrent model work without owning workflow state.""" + + def __init__( + self, + *, + indexing_slots: int, + inference_slots: int, + minimum_available_memory_mb: int, + ) -> None: + self._indexing = BoundedSemaphore(indexing_slots) + self._inference = BoundedSemaphore(inference_slots) + self._minimum_available_memory_mb = minimum_available_memory_mb + + def _check_memory(self) -> None: + try: + import psutil + except ModuleNotFoundError: + return + available = int(psutil.virtual_memory().available / (1024 * 1024)) + if available < self._minimum_available_memory_mb: + raise ResourceLimitError( + "The configured host-memory admission floor is not available." + ) + + @contextmanager + def indexing(self) -> Iterator[None]: + with self._indexing: + self._check_memory() + yield + + @contextmanager + def inference(self) -> Iterator[None]: + with self._inference: + self._check_memory() + yield class ModelRuntime: """One injected model cache and backend resolver for all capabilities.""" - def __init__(self, settings: VidXPSettings) -> None: + def __init__( + self, + settings: VidXPSettings, + *, + allowed_specs: tuple[ModelSpec | ArtifactSpec, ...] = (), + ) -> None: self.settings = settings + self._allowed_specs = frozenset(allowed_specs) self.backends = resolve_backends(settings.runtime_backend) + self.scheduler = ResourceScheduler( + indexing_slots=settings.max_concurrent_indexing, + inference_slots=settings.max_concurrent_inference, + minimum_available_memory_mb=( + settings.minimum_available_memory_mb + ), + ) self._resources: OrderedDict[ModelKey, Any] = OrderedDict() + self._resolved_models: dict[str, dict[str, Any]] = {} + self._compute_precision: dict[str, str] = {} + self._load_locks: dict[ModelKey, Lock] = {} self._lock = RLock() + @property + def model_cache(self) -> Path: + return self.settings.model_cache + + @property + def cpu_thread_budget(self) -> int: + return self.settings.cpu_thread_budget + + def _configure_cpu_threads(self) -> None: + try: + import torch + except ModuleNotFoundError: + pass + else: + torch.set_num_threads(self.cpu_thread_budget) + try: + import cv2 + except ModuleNotFoundError: + pass + else: + cv2.setNumThreads(self.cpu_thread_budget) + def device_for(self, capability: str) -> str: if capability == "dialogue.transcription": return self.backends.transcription_device @@ -85,6 +178,77 @@ def device_for(self, capability: str) -> str: return self.backends.actor_device return self.backends.torch_device + def resolve_model(self, spec: ModelSpec) -> Path: + if spec not in self._allowed_specs: + raise ModelArtifactUnavailableError(spec.capability) + from huggingface_hub import snapshot_download + + try: + snapshot = Path( + snapshot_download( + repo_id=spec.model_id, + revision=spec.revision, + cache_dir=str(self.settings.model_cache), + local_files_only=not self.settings.allow_model_downloads, + ) + ) + weights = snapshot / spec.weights_file + if not weights.is_file() or _sha256(weights) != spec.weights_sha256: + raise ModelArtifactUnavailableError(spec.capability) + except ModelArtifactUnavailableError: + raise + except Exception as exc: + raise ModelArtifactUnavailableError(spec.capability) from exc + with self._lock: + self._resolved_models[spec.capability] = spec.identity(cached=True) + return snapshot + + def resolve_artifact(self, spec: ArtifactSpec) -> Path: + if spec not in self._allowed_specs: + raise ModelArtifactUnavailableError(spec.capability) + try: + destination = self.settings.model_cache / spec.provider + path = destination / spec.filename + if not self.settings.allow_model_downloads: + if ( + not path.is_file() + or _sha256(path) != spec.sha256 + ): + raise ModelArtifactUnavailableError(spec.capability) + resolved = path + else: + import pooch + + resolved = Path( + pooch.retrieve( + url=spec.url, + known_hash=f"sha256:{spec.sha256}", + fname=spec.filename, + path=destination, + progressbar=False, + ) + ) + if ( + not resolved.is_file() + or _sha256(resolved) != spec.sha256 + ): + raise ModelArtifactUnavailableError(spec.capability) + except ModelArtifactUnavailableError: + raise + except Exception as exc: + raise ModelArtifactUnavailableError(spec.capability) from exc + with self._lock: + self._resolved_models[spec.capability] = spec.identity(cached=True) + return resolved + + def record_compute_precision( + self, + capability: str, + precision: str, + ) -> None: + with self._lock: + self._compute_precision[capability] = precision + def get_or_load( self, key: ModelKey, @@ -95,15 +259,30 @@ def get_or_load( resource = self._resources.pop(key) self._resources[key] = resource return resource + key_lock = self._load_locks.setdefault(key, Lock()) + + with key_lock: + with self._lock: + if key in self._resources: + resource = self._resources.pop(key) + self._resources[key] = resource + return resource + self._configure_cpu_threads() resource = loader() - self._resources[key] = resource - while len(self._resources) > self.settings.max_loaded_models: - self._resources.popitem(last=False) - return resource + with self._lock: + existing = self._resources.pop(key, None) + if existing is not None: + return existing + self._resources[key] = resource + while len(self._resources) > self.settings.max_loaded_models: + self._resources.popitem(last=False) + self._load_locks.pop(key, None) + return resource def clear(self) -> None: with self._lock: self._resources.clear() + self._load_locks.clear() try: import torch except ModuleNotFoundError: @@ -123,14 +302,19 @@ def describe(self) -> dict[str, Any]: **self.backends.model_dump(mode="json"), "model_cache": str(self.settings.model_cache), "allow_model_downloads": self.settings.allow_model_downloads, - "compute": { - "scene": "float32", - "dialogue_embedding": "float32", - "dialogue_transcription": ( - "float16" - if self.backends.transcription_device.startswith("cuda") - else "int8" + "limits": { + "max_loaded_models": self.settings.max_loaded_models, + "max_concurrent_indexing": ( + self.settings.max_concurrent_indexing + ), + "max_concurrent_inference": ( + self.settings.max_concurrent_inference + ), + "cpu_thread_budget": self.settings.cpu_thread_budget, + "minimum_available_memory_mb": ( + self.settings.minimum_available_memory_mb ), - "actor": "float32", }, + "resolved_models": dict(self._resolved_models), + "compute_precision": dict(self._compute_precision), } diff --git a/src/vidxp/settings.py b/src/vidxp/settings.py index bfa4330..d6c3cca 100644 --- a/src/vidxp/settings.py +++ b/src/vidxp/settings.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import re from enum import StrEnum from pathlib import Path @@ -33,6 +34,14 @@ class VidXPSettings(BaseSettings): ) allow_model_downloads: bool = True max_loaded_models: int = Field(default=3, gt=0, le=16) + max_concurrent_indexing: int = Field(default=1, gt=0, le=16) + max_concurrent_inference: int = Field(default=2, gt=0, le=64) + cpu_thread_budget: int = Field( + default_factory=lambda: min(256, max(1, os.cpu_count() or 1)), + gt=0, + le=256, + ) + minimum_available_memory_mb: int = Field(default=1024, ge=0) external_capabilities: bool = False capability_allowlist: tuple[str, ...] = () @@ -50,11 +59,28 @@ def _validate_runtime_backend(cls, value: str) -> str: @field_validator("capability_allowlist") @classmethod def _clean_allowlist(cls, values: tuple[str, ...]) -> tuple[str, ...]: - return tuple(dict.fromkeys(value.strip() for value in values if value.strip())) + cleaned = tuple( + dict.fromkeys(value.strip() for value in values if value.strip()) + ) + invalid = [ + value + for value in cleaned + if value.count(":") != 1 + or not all(part.strip() for part in value.split(":", 1)) + ] + if invalid: + raise ValueError( + "capability_allowlist entries must use " + "DISTRIBUTION:ENTRY_POINT." + ) + return cleaned @model_validator(mode="after") def _require_explicit_server_backend(self) -> "VidXPSettings": - if self.mode == ApplicationMode.server and self.runtime_backend == "auto": + if ( + self.mode == ApplicationMode.server + and not re.fullmatch(r"(cpu|cuda(?::[0-9]+)?)", self.runtime_backend) + ): raise ValueError( "Server mode requires an explicit cpu or cuda runtime backend." ) diff --git a/tests/test_actor_results.py b/tests/test_actor_results.py index d916368..0b6c239 100644 --- a/tests/test_actor_results.py +++ b/tests/test_actor_results.py @@ -82,6 +82,17 @@ def test_render_actor_result_rejects_an_empty_cluster(self): storage=storage, ) + def test_actor_detections_rejects_an_empty_cluster(self): + storage = Mock() + storage.records.return_value = [] + + with self.assertRaises(ActorClusterNotFoundError): + actor_detections( + self.config, + "missing", + storage=storage, + ) + def test_render_actor_result_returns_output_details(self): storage = Mock() storage.records.return_value = [ diff --git a/tests/test_application.py b/tests/test_application.py index 67ac8ab..7d15f26 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1,16 +1,25 @@ +import json import unittest from pathlib import Path from tempfile import TemporaryDirectory -from unittest.mock import Mock +from unittest.mock import MagicMock, Mock, patch + +from pydantic import ValidationError from vidxp.application import VidXPApplication from vidxp.application_models import ( + ApplicationError, + CapabilityDependencyCheck, CreateIndexCommand, + DependencyKind, DependencyCheckCommand, + DependencyUnavailableError, IndexResult, PrepareModelsCommand, SearchCommand, ) +from vidxp.core.contracts import IndexConfig +from vidxp.infrastructure.local_index import LocalIndexBackend from vidxp.capabilities.contracts import ( CapabilityDefinition, CapabilityExecutor, @@ -22,9 +31,12 @@ create_capability_registry, ) from vidxp.capabilities.schemas import SearchInput, SearchResult +from vidxp.composition import create_local_application from vidxp.repository_layout import RepositoryLayout +from vidxp.repositories import RepositoryConfigError from vidxp.runtime import ModelRuntime from vidxp.settings import VidXPSettings +from vidxp.ports import IndexStore class ApplicationTests(unittest.TestCase): @@ -38,11 +50,13 @@ def application( settings = VidXPSettings( repository_root=Path(root), runtime_backend="cpu", + minimum_available_memory_mb=0, ) runtime = ModelRuntime(settings) active_backend = backend or Mock() return ( VidXPApplication( + settings=settings, layout=RepositoryLayout(root=Path(root)), registry=registry or create_capability_registry(), runtime=runtime, @@ -51,6 +65,77 @@ def application( active_backend, ) + def indexed_application( + self, + handler, + manager, + ) -> VidXPApplication: + definition = CapabilityDefinition( + name="indexed", + description="Indexed provider.", + extra="indexed", + collection_name="indexed", + index_stage="indexed", + execution_group="indexed", + operations={ + "search": OperationDefinition( + input_model=SearchInput, + output_model=SearchResult, + ) + }, + ) + + registry = CapabilityRegistry( + ( + CapabilityPlugin( + definition=definition, + executor_factory=lambda: CapabilityExecutor( + indexer=Mock(), + operations={"search": handler}, + ), + ), + ) + ) + application, backend = self.application( + "unused", + registry=registry, + ) + backend.active_config.return_value = ( + IndexConfig.local( + enabled_modalities=("indexed",), + collection_names={"indexed": "indexed"}, + ), + {}, + ) + backend.open_store.return_value = manager + return application + + def test_local_composition_maps_typed_repository_errors_safely(self): + with ( + patch( + "vidxp.composition.resolve_repository", + side_effect=RepositoryConfigError("secret-path"), + ), + self.assertRaises(ApplicationError) as caught, + ): + create_local_application() + + self.assertEqual(caught.exception.code, "configuration_invalid") + self.assertNotIn( + "secret-path", + json.dumps(caught.exception.to_dict()), + ) + + def test_local_composition_does_not_reclassify_unexpected_value_errors(self): + with ( + patch( + "vidxp.composition.resolve_repository", + side_effect=ValueError("programming error"), + ), + self.assertRaisesRegex(ValueError, "programming error"), + ): + create_local_application() + def test_missing_index_has_shared_status_model(self): application, backend = self.application("missing") backend.status.return_value = None @@ -143,11 +228,284 @@ def test_search_is_only_a_typed_projection_over_execute(self): {"query": "yellow taxi", "top_k": 7}, ) + def test_application_boundary_returns_stable_validation_error(self): + application, _ = self.application("unused") + + with self.assertRaises(ApplicationError) as raised: + application.execute("scene", "unknown", {}) + + self.assertEqual(raised.exception.code, "invalid_request") + self.assertEqual( + raised.exception.to_dict()["category"], + "validation", + ) + + def test_validation_error_does_not_expose_input(self): + definition = CapabilityDefinition( + name="validate", + description="Validate input.", + extra="validate", + operations={ + "run": OperationDefinition( + input_model=SearchInput, + output_model=SearchResult, + requires_index=False, + ) + }, + ) + application, _ = self.application( + "unused", + registry=CapabilityRegistry( + ( + CapabilityPlugin( + definition=definition, + executor_factory=lambda: CapabilityExecutor( + operations={ + "run": lambda _context, request: { + "query_id": "validate:1", + "query": request.query, + "modality": "validate", + } + } + ), + ), + ) + ), + ) + + with self.assertRaises(ApplicationError) as raised: + application.execute( + "validate", + "run", + { + "query": "hello", + "secret": "do-not-leak", + }, + ) + + self.assertEqual(raised.exception.code, "invalid_request") + self.assertNotIn( + "do-not-leak", + json.dumps(raised.exception.to_dict()), + ) + + def test_missing_media_error_does_not_expose_path(self): + application, _ = self.application("unused") + secret_path = Path("private/customer/video.mp4") + + with self.assertRaises(ApplicationError) as raised: + application.create_index( + CreateIndexCommand( + path=secret_path, + modalities=("scene",), + ) + ) + + self.assertEqual(raised.exception.code, "resource_not_found") + self.assertNotIn( + str(secret_path), + json.dumps(raised.exception.to_dict()), + ) + + def test_downstream_missing_file_is_not_misclassified_as_media(self): + with TemporaryDirectory() as directory: + path = Path(directory) / "video.mp4" + path.write_bytes(b"video") + application, backend = self.application(directory) + backend.create.side_effect = FileNotFoundError("ffmpeg") + + with self.assertRaises(DependencyUnavailableError) as raised: + application.create_index( + CreateIndexCommand( + path=path, + modalities=("scene",), + ) + ) + + self.assertEqual(raised.exception.code, "dependency_unavailable") + self.assertNotEqual(raised.exception.category, "not_found") + + def test_open_store_dependency_failure_is_stable(self): + application, backend = self.application("unused") + backend.active_config.return_value = ( + IndexConfig.local( + enabled_modalities=("scene",), + collection_names={"scene": "scene"}, + ), + {}, + ) + backend.open_store.side_effect = ModuleNotFoundError("chromadb") + + with self.assertRaises(DependencyUnavailableError) as raised: + application.execute( + "scene", + "search", + {"query": "hello"}, + ) + + payload = json.dumps(raised.exception.to_dict()) + self.assertNotIn("chromadb", payload) + self.assertIsInstance( + raised.exception.__cause__, + ModuleNotFoundError, + ) + + def test_prepare_dependency_failure_is_stable(self): + definition = CapabilityDefinition( + name="prepare-only", + description="Prepare a provider.", + extra="prepare-only", + operations={ + "noop": OperationDefinition( + input_model=SearchInput, + output_model=SearchResult, + requires_index=False, + ) + }, + prepares_models=True, + ) + plugin = CapabilityPlugin( + definition=definition, + executor_factory=lambda: CapabilityExecutor( + operations={ + "noop": lambda _context, request: { + "query_id": "noop:1", + "query": request.query, + "modality": "noop", + } + }, + prepare=Mock( + side_effect=ModuleNotFoundError("provider.internal") + ), + ), + ) + registry = CapabilityRegistry((plugin,)) + registry.dependency_checks = Mock(return_value=()) + application, _ = self.application("unused", registry=registry) + + with self.assertRaises(DependencyUnavailableError) as raised: + application.prepare_models( + PrepareModelsCommand(modalities=("prepare-only",)) + ) + + self.assertNotIn( + "provider.internal", + json.dumps(raised.exception.to_dict()), + ) + + def test_unexpected_handler_error_is_not_misclassified(self): + failure = RuntimeError("implementation bug") + definition = CapabilityDefinition( + name="broken", + description="Broken provider.", + extra="broken", + operations={ + "run": OperationDefinition( + input_model=SearchInput, + output_model=SearchResult, + requires_index=False, + ) + }, + ) + plugin = CapabilityPlugin( + definition=definition, + executor_factory=lambda: CapabilityExecutor( + operations={ + "run": Mock(side_effect=failure), + } + ), + ) + application, _ = self.application( + "unused", + registry=CapabilityRegistry((plugin,)), + ) + + with self.assertRaises(RuntimeError) as raised: + application.execute("broken", "run", {"query": "hello"}) + + self.assertIs(raised.exception, failure) + + def test_indexed_operation_uses_and_closes_exact_injected_store(self): + store = Mock(spec=IndexStore) + manager = MagicMock() + manager.__enter__.return_value = store + seen = [] + + def handle(context, request): + seen.append(context.require_storage()) + return { + "query_id": "indexed:1", + "query": request.query, + "modality": "indexed", + } + + application = self.indexed_application(handle, manager) + + result = application.execute( + "indexed", + "search", + {"query": "hello"}, + ) + + self.assertEqual(result.query_id, "indexed:1") + self.assertEqual(seen, [store]) + manager.__enter__.assert_called_once_with() + manager.__exit__.assert_called_once_with(None, None, None) + + def test_store_closes_when_output_validation_fails(self): + store = Mock(spec=IndexStore) + manager = MagicMock() + manager.__enter__.return_value = store + application = self.indexed_application( + lambda _context, _request: {"query": "missing fields"}, + manager, + ) + + with self.assertRaises(ApplicationError) as raised: + application.execute( + "indexed", + "search", + {"query": "hello"}, + ) + + self.assertEqual(raised.exception.code, "invalid_request") + exit_args = manager.__exit__.call_args.args + self.assertIs(exit_args[0], ValidationError) + + def test_store_closes_when_handler_fails(self): + store = Mock(spec=IndexStore) + manager = MagicMock() + manager.__enter__.return_value = store + failure = RuntimeError("implementation bug") + application = self.indexed_application( + Mock(side_effect=failure), + manager, + ) + + with self.assertRaises(RuntimeError) as raised: + application.execute( + "indexed", + "search", + {"query": "hello"}, + ) + + self.assertIs(raised.exception, failure) + exit_args = manager.__exit__.call_args.args + self.assertIs(exit_args[0], RuntimeError) + self.assertIs(exit_args[1], failure) + def test_dependency_check_returns_shared_result_model(self): registry = create_capability_registry() registry.dependency_checks = Mock( return_value=( - {"name": "transformers", "ok": False, "error": "missing"}, + CapabilityDependencyCheck( + capability="scene", + kind=DependencyKind.distribution, + name="transformers", + requirement="transformers>=5,<6", + ok=False, + error="distribution is not installed", + ), ) ) application, _ = self.application("unused", registry=registry) @@ -202,7 +560,7 @@ def test_prepare_uses_injected_runtime_and_executor(self): application.runtime, ) - def test_clear_delegates_storage_and_removes_known_state_only(self): + def test_clear_delegates_all_persistence_cleanup_to_backend(self): with TemporaryDirectory() as directory: application, backend = self.application(directory) index = application.index_directory @@ -216,9 +574,76 @@ def test_clear_delegates_storage_and_removes_known_state_only(self): self.assertTrue(application.clear_index()) backend.clear.assert_called_once() + self.assertTrue((index / "manifest.json").exists()) + self.assertTrue(unrelated.exists()) + + def test_local_backend_owns_vector_and_metadata_cleanup(self): + with TemporaryDirectory() as directory: + index = Path(directory) / "index" + index.mkdir() + (index / "manifest.json").write_text("{}", encoding="utf-8") + unrelated = index / "keep.txt" + unrelated.write_text("keep", encoding="utf-8") + settings = VidXPSettings( + repository_root=directory, + runtime_backend="cpu", + ) + registry = create_capability_registry() + backend = LocalIndexBackend(registry, ModelRuntime(settings)) + storage = MagicMock() + storage.__enter__.return_value = storage + config = IndexConfig.local(storage_directory=index) + + with patch( + "vidxp.infrastructure.local_index.IndexStorage", + return_value=storage, + ): + backend.clear(config) + + storage.clear.assert_called_once_with() self.assertFalse((index / "manifest.json").exists()) self.assertTrue(unrelated.exists()) + def test_local_backend_injects_and_closes_storage_for_indexing(self): + settings = VidXPSettings( + repository_root="unused", + runtime_backend="cpu", + ) + registry = create_capability_registry() + backend = LocalIndexBackend(registry, ModelRuntime(settings)) + storage = MagicMock() + storage.__enter__.return_value = storage + config = IndexConfig.local( + enabled_modalities=("scene",), + collection_names={"scene": "scene"}, + ) + + with ( + patch( + "vidxp.infrastructure.local_index.IndexStorage", + return_value=storage, + ), + patch( + "vidxp.infrastructure.local_index.index_video", + return_value={"scene_frames": 1}, + ) as index_video, + ): + result = backend.create( + Path("video.mp4"), + config=config, + progress=None, + cancellation=None, + source_name=None, + ) + + self.assertEqual(result, {"scene_frames": 1}) + self.assertIs(index_video.call_args.kwargs["storage"], storage) + self.assertIs( + index_video.call_args.kwargs["manifest_store"].runtime, + backend.runtime, + ) + storage.__exit__.assert_called_once() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_benchmark_cli.py b/tests/test_benchmark_cli.py index 35f8d5a..f413fb0 100644 --- a/tests/test_benchmark_cli.py +++ b/tests/test_benchmark_cli.py @@ -7,6 +7,8 @@ from typer.testing import CliRunner from vidxp import cli +from vidxp.composition import LocalApplicationContext +from vidxp.repositories import RepositoryConfig, RepositoryRegistry class BenchmarkCliTests(unittest.TestCase): @@ -31,8 +33,17 @@ def test_benchmark_uses_global_device_and_json_output(self): with ( patch.object( cli, - "create_application", - return_value=service, + "create_local_application", + return_value=LocalApplicationContext( + application=service, + repositories=RepositoryRegistry(config), + repository=RepositoryConfig( + "default", + Path("chroma_data"), + device="cuda", + configured=False, + ), + ), ), patch( "vidxp.benchmarks.cli.run_didemo", diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 51ab60b..cb7cd80 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -1,4 +1,7 @@ import unittest +from importlib.metadata import PackageNotFoundError +from types import SimpleNamespace +from unittest.mock import Mock, patch from pydantic import BaseModel, ValidationError @@ -10,7 +13,9 @@ CapabilityInput, CapabilityOutput, CapabilityPlugin, + CapabilityProvenance, OperationDefinition, + RuntimeCheck, ) from vidxp.capabilities.dialogue.config import DialogueConfig from vidxp.capabilities.registry import ( @@ -91,10 +96,15 @@ def test_built_in_settings_are_owned_and_validated(self): options = self.registry.validate_options( ("scene",), - {"scene": {"batch_size": 4, "model": "test-model"}}, + {"scene": {"batch_size": 4}}, ) self.assertEqual(options["scene"]["batch_size"], 4) - self.assertEqual(options["scene"]["model"], "test-model") + self.assertNotIn("model", options["scene"]) + with self.assertRaises(ValidationError): + self.registry.validate_options( + ("scene",), + {"scene": {"model": "unapproved/model"}}, + ) with self.assertRaises(ValidationError): self.registry.validate_options( ("actor",), @@ -170,6 +180,231 @@ def test_core_config_has_no_provider_specific_fields(self): ): self.assertNotIn(name, fields) + def test_external_plugins_require_exact_distribution_entry_point_pair(self): + plugin = CapabilityPlugin( + definition=CapabilityDefinition( + name="ocr", + description="OCR.", + extra="ocr", + operations={ + "run": OperationDefinition( + input_model=ExampleInput, + output_model=ExampleOutput, + requires_index=False, + ) + }, + ), + executor_factory=lambda: CapabilityExecutor( + operations={ + "run": lambda _context, request: { + "doubled": request.value * 2 + } + } + ), + requirements=("example-runtime>=1,<2",), + ) + distribution = SimpleNamespace( + name="acme-capabilities", + version="1.2.3", + ) + entry_point = SimpleNamespace( + name="ocr", + dist=distribution, + load=Mock(return_value=plugin), + ) + with patch( + "vidxp.capabilities.registry.entry_points", + return_value=(entry_point,), + ): + rejected = create_capability_registry( + external=True, + allowlist=("acme-capabilities:other",), + ) + accepted = create_capability_registry( + external=True, + allowlist=("acme-capabilities:ocr",), + ) + + self.assertNotIn("ocr", rejected.names()) + self.assertIn("ocr", accepted.names()) + self.assertEqual( + accepted.provenance("ocr"), + CapabilityProvenance( + distribution="acme-capabilities", + entry_point="ocr", + version="1.2.3", + ), + ) + self.assertEqual( + [item.name for item in accepted.requirements_for(("ocr",))], + ["example-runtime"], + ) + with patch( + "vidxp.dependencies.version", + side_effect=PackageNotFoundError("example-runtime"), + ): + check = accepted.dependency_checks(("ocr",))[0] + self.assertFalse(check.ok) + self.assertEqual(check.capability, "ocr") + self.assertEqual(check.provenance.distribution, "acme-capabilities") + self.assertEqual(entry_point.load.call_count, 1) + + def test_plugin_contract_and_collision_errors_include_provenance(self): + provenance = CapabilityProvenance( + distribution="acme-capabilities", + entry_point="ocr", + version="1.2.3", + ) + invalid = CapabilityPlugin( + definition=CapabilityDefinition( + name="ocr", + description="OCR.", + extra="ocr", + operations={ + "run": OperationDefinition( + input_model=ExampleInput, + output_model=ExampleOutput, + requires_index=False, + ) + }, + ), + executor_factory=lambda: CapabilityExecutor( + operations={"run": lambda _context, _request: {"doubled": 2}} + ), + contract_version=999, + provenance=provenance, + ) + with self.assertRaisesRegex( + RuntimeError, + "acme-capabilities:ocr", + ): + CapabilityRegistry((invalid,)) + + collision = invalid.model_copy( + update={ + "definition": self.registry.get("scene"), + "contract_version": 1, + } + ) + built_in = CapabilityPlugin( + definition=self.registry.get("scene"), + executor_factory=lambda: CapabilityExecutor(), + ) + with self.assertRaisesRegex( + RuntimeError, + "built-in VidXP capability.*acme-capabilities:ocr", + ): + CapabilityRegistry((built_in, collision)) + + def test_external_plugin_loading_is_deterministic_and_errors_are_chained(self): + def plugin(name): + return CapabilityPlugin( + definition=CapabilityDefinition( + name=name, + description=f"{name} capability.", + extra=name, + operations={ + "run": OperationDefinition( + input_model=ExampleInput, + output_model=ExampleOutput, + requires_index=False, + ) + }, + ), + executor_factory=lambda: CapabilityExecutor( + operations={ + "run": lambda _context, request: { + "doubled": request.value * 2 + } + } + ), + ) + + distribution = SimpleNamespace(name="acme", version="1") + zeta = SimpleNamespace( + name="zeta", + dist=distribution, + load=Mock(return_value=plugin("zeta")), + ) + alpha = SimpleNamespace( + name="alpha", + dist=distribution, + load=Mock(return_value=plugin("alpha")), + ) + with patch( + "vidxp.capabilities.registry.entry_points", + return_value=(zeta, alpha), + ): + registry = create_capability_registry( + external=True, + allowlist=("acme:zeta", "acme:alpha"), + ) + self.assertEqual(registry.names()[-2:], ("alpha", "zeta")) + + failure = RuntimeError("factory failure") + broken = SimpleNamespace( + name="broken", + dist=distribution, + load=Mock(side_effect=failure), + ) + with ( + patch( + "vidxp.capabilities.registry.entry_points", + return_value=(broken,), + ), + self.assertRaisesRegex(RuntimeError, "acme:broken") as raised, + ): + create_capability_registry( + external=True, + allowlist=("acme:broken",), + ) + self.assertIs(raised.exception.__cause__, failure) + + def test_external_runtime_check_errors_are_sanitized_with_provenance(self): + provenance = CapabilityProvenance( + distribution="acme-capabilities", + entry_point="ocr", + version="1.2.3", + ) + plugin = CapabilityPlugin( + definition=CapabilityDefinition( + name="ocr", + description="OCR.", + extra="ocr", + operations={ + "run": OperationDefinition( + input_model=ExampleInput, + output_model=ExampleOutput, + requires_index=False, + ) + }, + ), + executor_factory=lambda: CapabilityExecutor( + operations={ + "run": lambda _context, request: { + "doubled": request.value * 2 + } + }, + runtime_checks=( + RuntimeCheck( + label="ocr runtime", + check=Mock( + side_effect=RuntimeError( + "token=do-not-leak" + ) + ), + ), + ), + ), + provenance=provenance, + ) + + check = CapabilityRegistry((plugin,)).dependency_checks(("ocr",))[0] + + self.assertEqual(check.error, "runtime check failed") + self.assertNotIn("do-not-leak", check.model_dump_json()) + self.assertEqual(check.provenance, provenance) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py index 7ed6368..2fb460d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,5 @@ import json +import os import unittest from pathlib import Path from tempfile import TemporaryDirectory @@ -7,6 +8,7 @@ from typer.testing import CliRunner from vidxp import cli +from vidxp.composition import LocalApplicationContext, settings_for_repository from vidxp.application_models import ( CreateIndexCommand, DependencyCheckResult, @@ -38,16 +40,13 @@ def setUp(self): ) def invoke(self, arguments): - with ( - patch.object( - cli, - "resolve_repository", - return_value=(self.registry, self.repository), - ), - patch.object( - cli, - "create_application", - return_value=self.service, + with patch.object( + cli, + "create_local_application", + return_value=LocalApplicationContext( + application=self.service, + repositories=self.registry, + repository=self.repository, ), ): return self.runner.invoke(cli.app, arguments) @@ -159,6 +158,21 @@ def test_invalid_capability_is_a_cli_parameter_error(self): self.assertNotEqual(result.exit_code, 0) self.assertIn("Unknown capability", result.output) + def test_repository_without_device_preserves_runtime_environment(self): + repository = RepositoryConfig( + "default", + Path("repo"), + device=None, + configured=False, + ) + with patch.dict( + os.environ, + {"VIDXP_RUNTIME_BACKEND": "cpu"}, + ): + settings = settings_for_repository(repository) + + self.assertEqual(settings.runtime_backend, "cpu") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_frontend.py b/tests/test_frontend.py index d8c18a4..2058b06 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -1,13 +1,13 @@ import unittest from pathlib import Path from tempfile import TemporaryDirectory -from types import SimpleNamespace from unittest.mock import Mock, patch from vidxp import frontend from vidxp.application_models import DependencyCheckResult, IndexStatus from vidxp.capabilities.registry import create_capability_registry from vidxp.capabilities.schemas import SearchHit, SearchResult +from vidxp.settings import VidXPSettings class UploadedVideo: @@ -33,29 +33,35 @@ def service(self, root: Path) -> Mock: return service def test_service_is_composed_lazily_and_cached(self): - repository = SimpleNamespace( - index_directory=Path("repository"), - device="cpu", - ) application = Mock() - with ( - patch.object( - frontend, - "resolve_repository", - return_value=(Mock(), repository), - ), - patch.object( - frontend, - "create_application", - return_value=application, - ) as create, - ): - first = frontend._configured_service() - second = frontend._configured_service() + with patch.object( + frontend, + "create_application", + return_value=application, + ) as create: + expected = VidXPSettings( + repository_root=Path("repository"), + runtime_backend="cpu", + model_cache=Path("model-cache"), + allow_model_downloads=False, + max_loaded_models=5, + max_concurrent_indexing=2, + max_concurrent_inference=3, + cpu_thread_budget=6, + minimum_available_memory_mb=2048, + external_capabilities=True, + capability_allowlist=("acme:ocr",), + ) + settings = frontend._settings_from_arguments( + ("--vidxp-settings-json", expected.model_dump_json()) + ) + first = frontend._configured_service(settings) + second = frontend._configured_service(settings) self.assertIs(first, application) self.assertIs(second, application) create.assert_called_once() + self.assertEqual(settings, expected) def test_uploaded_video_identity_controls_search_readiness(self): with TemporaryDirectory() as directory: diff --git a/tests/test_index_worker.py b/tests/test_index_worker.py index f5f3459..9dd3f8e 100644 --- a/tests/test_index_worker.py +++ b/tests/test_index_worker.py @@ -4,6 +4,7 @@ from vidxp import index_worker from vidxp.index_state import IndexingInProgressError +from vidxp.settings import VidXPSettings class FakeProcess: @@ -47,8 +48,19 @@ def setUp(self): index_worker._process = None index_worker._cancel_event = None self.service = Mock() - self.service.layout.root = Path("selected-repository") - self.service.runtime.backends.requested = "cuda" + self.service.settings = VidXPSettings( + repository_root=Path("selected-repository"), + runtime_backend="cpu", + model_cache=Path("selected-model-cache"), + allow_model_downloads=False, + max_loaded_models=5, + max_concurrent_indexing=2, + max_concurrent_inference=4, + cpu_thread_budget=6, + minimum_available_memory_mb=2048, + external_capabilities=True, + capability_allowlist=("acme:ocr",), + ) self.service.indexing_in_progress.return_value = False def tearDown(self): @@ -75,8 +87,7 @@ def test_worker_starts_indexing_in_a_separate_process(self): "video.mp4", "source.mp4", context.event, - "selected-repository", - "cuda", + self.service.settings.model_dump(mode="python"), ("scene",), ), ) @@ -93,17 +104,12 @@ def test_worker_process_reconstructs_the_selected_service(self): "video.mp4", "source.mp4", FakeEvent(), - "selected-repository", - "cuda", + self.service.settings.model_dump(mode="python"), ("scene",), ) settings = create.call_args.args[0] - self.assertEqual( - settings.repository_root, - Path("selected-repository"), - ) - self.assertEqual(settings.runtime_backend, "cuda") + self.assertEqual(settings, self.service.settings) service.create_index.assert_called_once() self.assertEqual( service.create_index.call_args.args[0].source_name, diff --git a/tests/test_indexing.py b/tests/test_indexing.py index d415f98..55dd175 100644 --- a/tests/test_indexing.py +++ b/tests/test_indexing.py @@ -1,4 +1,5 @@ import unittest +from types import SimpleNamespace from unittest.mock import Mock, patch import numpy as np @@ -13,6 +14,8 @@ build_dialogue_phrases, index_dialogue, ) +from vidxp.capabilities.scene.indexing import encode_scene_batch +from vidxp.capabilities.scene.models import SceneModel from vidxp.capabilities.registry import ( CapabilityRegistry, create_capability_registry, @@ -42,7 +45,7 @@ class FakeEncoder: def __init__(self): self.batches = [] - def encode(self, texts, **_): + def encode_document(self, texts, **_): self.batches.append(list(texts)) return np.asarray( [[float(index), 1.0] for index, _ in enumerate(texts)] @@ -80,6 +83,27 @@ def test_word_timestamps_are_grouped_without_interpolation(self): [("one two", 0.1, 1.0), ("three", 1.2, 1.8)], ) + def test_siglip2_uses_transformers_five_pooled_image_output(self): + import torch + + processor = Mock( + return_value={"pixel_values": torch.ones((1, 3, 2, 2))} + ) + model = Mock() + model.get_image_features.return_value = SimpleNamespace( + pooler_output=torch.tensor([[3.0, 4.0]]) + ) + provider = SceneModel(model=model, processor=processor, device="cpu") + sample = FrameSample( + frame_index=0, + timestamp=0.0, + frame=np.zeros((2, 2, 3), dtype=np.uint8), + ) + + vectors = encode_scene_batch([sample], provider) + + self.assertEqual(vectors, [[0.6000000238418579, 0.800000011920929]]) + def test_segment_text_uses_interpolated_timestamps_as_fallback(self): phrases = build_dialogue_phrases( [ diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 6db7d5f..0b855e2 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -2,7 +2,7 @@ import unittest from pathlib import Path from tempfile import TemporaryDirectory -from unittest.mock import patch +from unittest.mock import Mock, patch from vidxp.core.contracts import IndexConfig, VideoSource from vidxp.core.manifest import ( @@ -10,6 +10,9 @@ source_checksums, write_json_atomic, ) +from vidxp.capabilities.registry import create_capability_registry +from vidxp.runtime import ModelRuntime +from vidxp.settings import VidXPSettings class ManifestIdentityTests(unittest.TestCase): @@ -55,7 +58,16 @@ def test_checkpoint_filenames_do_not_embed_dataset_video_ids(self): output_root=directory, enabled_modalities=("scene",), ) - store = ManifestStore(config) + store = ManifestStore( + config, + registry=create_capability_registry(), + runtime=ModelRuntime( + VidXPSettings( + repository_root=config.run_directory, + runtime_backend="cpu", + ) + ), + ) video_id = "folder/name:video" expected = hashlib.sha256(video_id.encode("utf-8")).hexdigest() @@ -68,6 +80,75 @@ def test_checkpoint_filenames_do_not_embed_dataset_video_ids(self): / f"{expected}.json", ) + def test_terminal_manifests_refresh_resolved_runtime_identity(self): + for terminal in ("fail_video", "interrupt_video"): + with self.subTest(terminal=terminal): + with TemporaryDirectory() as directory: + config = IndexConfig( + dataset="sample", + split="test", + run_id="run-1", + output_root=directory, + enabled_modalities=("scene",), + ) + runtime = Mock() + runtime.describe.return_value = { + "resolved_models": {}, + } + store = ManifestStore( + config, + registry=create_capability_registry(), + runtime=runtime, + ) + source = VideoSource( + video_id="video-1", + transcript=( + {"text": "hello", "start": 0.0, "end": 1.0}, + ), + ) + with patch( + "vidxp.core.manifest.execution_state", + return_value={ + "git": {"commit": None, "dirty": None}, + "implementation_sha256": "test", + "package_version": "test", + "python": "test", + "platform": "test", + "dependencies": {}, + }, + ): + store.initialize( + [ + ( + "video-1", + source, + "a" * 64, + {"declared": "a" * 64}, + ) + ] + ) + store.start_video("video-1") + runtime.describe.return_value = { + "resolved_models": {"scene": {"cached": True}}, + "compute_precision": {"scene": "float32"}, + } + + if terminal == "fail_video": + store.fail_video("video-1", "scene", "failed") + else: + store.interrupt_video("video-1", "scene") + + self.assertEqual( + store.read()["models"]["runtime"]["resolved_models"], + {"scene": {"cached": True}}, + ) + self.assertEqual( + store.read()["models"]["runtime"][ + "compute_precision" + ], + {"scene": "float32"}, + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_models.py b/tests/test_models.py index 2895b21..f0774d1 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,30 +1,56 @@ +import hashlib import sys import types import unittest +from concurrent.futures import ThreadPoolExecutor +from dataclasses import replace from importlib.metadata import PackageNotFoundError from pathlib import Path from tempfile import TemporaryDirectory -from unittest.mock import Mock, patch +from threading import Barrier +from unittest.mock import Mock, call, patch from packaging.requirements import Requirement +from pydantic import ValidationError from vidxp.capabilities.contracts import CapabilityDefinition from vidxp.capabilities.dialogue import models as dialogue_models +from vidxp.capabilities.dialogue.specs import ( + FASTER_WHISPER_MODEL, + QWEN3_EMBEDDING_MODEL, +) +from vidxp.capabilities.actor import models as actor_models +from vidxp.capabilities.actor.definition import model_manifest as actor_manifest +from vidxp.capabilities.actor.specs import SFACE_MODEL, YUNET_MODEL from vidxp.capabilities.registry import create_capability_registry -from vidxp.core.contracts import VideoSource +from vidxp.application_models import DependencyKind +from vidxp.core.contracts import IndexConfig, VideoSource from vidxp.dependencies import inspect_requirement -from vidxp.runtime import ModelRuntime +from vidxp.infrastructure.local_index import LOCAL_INDEX_RUNTIME_CHECKS +from vidxp.model_contracts import ModelArtifactUnavailableError, ModelKey +from vidxp.ports import ResourceLimitError +from vidxp.runtime import ModelRuntime, resolve_backends from vidxp.settings import VidXPSettings class ModelTests(unittest.TestCase): - def runtime(self, root: str) -> ModelRuntime: + def runtime( + self, + root: str, + *, + allowed_specs=None, + ) -> ModelRuntime: return ModelRuntime( VidXPSettings( repository_root=root, model_cache=Path(root) / "models", runtime_backend="cpu", - ) + ), + allowed_specs=( + create_capability_registry().model_specs() + if allowed_specs is None + else tuple(allowed_specs) + ), ) def test_model_runtime_reuses_one_provider_instance(self): @@ -34,28 +60,227 @@ def test_model_runtime_reuses_one_provider_instance(self): fake_module = types.SimpleNamespace( SentenceTransformer=constructor ) + snapshot = Path(directory) / "snapshot" + runtime.resolve_model = Mock(return_value=snapshot) with patch.dict( sys.modules, {"sentence_transformers": fake_module}, ): - first = dialogue_models.get_embedder( - runtime, - "model-id", - "revision", - ) - second = dialogue_models.get_embedder( - runtime, - "model-id", - "revision", - ) + first = dialogue_models.get_embedder(runtime) + second = dialogue_models.get_embedder(runtime) self.assertIs(first, second) constructor.assert_called_once_with( - "model-id", + str(snapshot), device="cpu", cache_folder=str(Path(directory) / "models"), - revision="revision", - local_files_only=False, + local_files_only=True, + ) + self.assertEqual( + runtime.describe()["compute_precision"]["dialogue.embedding"], + "bfloat16", + ) + + def test_scheduler_rejects_work_below_memory_admission_floor(self): + with TemporaryDirectory() as directory: + runtime = ModelRuntime( + VidXPSettings( + repository_root=directory, + runtime_backend="cpu", + minimum_available_memory_mb=1024, + ) + ) + fake_psutil = types.SimpleNamespace( + virtual_memory=lambda: types.SimpleNamespace( + available=512 * 1024 * 1024 + ) + ) + with ( + patch.dict(sys.modules, {"psutil": fake_psutil}), + self.assertRaises(ResourceLimitError), + runtime.scheduler.inference(), + ): + pass + + def test_unrelated_model_loads_do_not_hold_the_global_cache_lock(self): + runtime = self.runtime("unused") + barrier = Barrier(2) + + def load(value): + barrier.wait(timeout=2) + return value + + keys = ( + ModelKey("scene", "one", "one", "1", "cpu"), + ModelKey("dialogue", "two", "two", "1", "cpu"), + ) + with ThreadPoolExecutor(max_workers=2) as pool: + futures = [ + pool.submit(runtime.get_or_load, key, lambda v=value: load(v)) + for value, key in enumerate(keys) + ] + self.assertEqual([future.result() for future in futures], [0, 1]) + + def test_actor_download_uses_pinned_media_object_not_lfs_pointer(self): + with TemporaryDirectory() as directory: + content = b"verified model" + downloaded = Path(directory) / "model.onnx" + downloaded.write_bytes(content) + spec = replace( + YUNET_MODEL, + filename=downloaded.name, + sha256=hashlib.sha256(content).hexdigest(), + ) + runtime = self.runtime(directory, allowed_specs=(spec,)) + retrieve = Mock(return_value=str(downloaded)) + fake_pooch = types.SimpleNamespace(retrieve=retrieve) + with patch.dict(sys.modules, {"pooch": fake_pooch}): + result = runtime.resolve_artifact(spec) + + self.assertEqual(result, downloaded) + url = retrieve.call_args.kwargs["url"] + self.assertIn("media.githubusercontent.com/media/", url) + self.assertIn(spec.revision, url) + self.assertNotIn("raw.githubusercontent.com", url) + self.assertEqual( + retrieve.call_args.kwargs["known_hash"], + f"sha256:{spec.sha256}", + ) + self.assertEqual( + runtime.describe()["resolved_models"]["actor.detector"]["model"], + "yunet", + ) + + def test_runtime_rejects_specs_not_declared_by_enabled_capabilities(self): + with TemporaryDirectory() as directory: + runtime = self.runtime(directory) + untrusted = replace(YUNET_MODEL, model_id="untrusted-detector") + + with self.assertRaises(ModelArtifactUnavailableError): + runtime.resolve_artifact(untrusted) + + self.assertEqual(runtime.describe()["resolved_models"], {}) + + def test_actor_artifact_resolves_verified_offline_cache(self): + with TemporaryDirectory() as directory: + content = b"verified cached model" + spec = replace( + YUNET_MODEL, + filename="cached.onnx", + sha256=hashlib.sha256(content).hexdigest(), + ) + cache = Path(directory) / "models" + path = cache / spec.provider / spec.filename + path.parent.mkdir(parents=True) + path.write_bytes(content) + runtime = ModelRuntime( + VidXPSettings( + repository_root=directory, + model_cache=cache, + runtime_backend="cpu", + allow_model_downloads=False, + ), + allowed_specs=(spec,), + ) + fake_pooch = types.SimpleNamespace( + retrieve=Mock(side_effect=AssertionError("downloaded")) + ) + with patch.dict(sys.modules, {"pooch": fake_pooch}): + resolved = runtime.resolve_artifact(spec) + + self.assertEqual(resolved, path) + self.assertTrue( + runtime.describe()["resolved_models"]["actor.detector"]["cached"] + ) + + def test_bad_or_missing_offline_artifact_is_not_recorded(self): + for content in (None, b"corrupt"): + with self.subTest(content=content): + with TemporaryDirectory() as directory: + cache = Path(directory) / "models" + spec = replace( + YUNET_MODEL, + filename="cached.onnx", + sha256=hashlib.sha256(b"expected").hexdigest(), + ) + if content is not None: + path = cache / spec.provider / spec.filename + path.parent.mkdir(parents=True) + path.write_bytes(content) + runtime = ModelRuntime( + VidXPSettings( + repository_root=directory, + model_cache=cache, + runtime_backend="cpu", + allow_model_downloads=False, + ), + allowed_specs=(spec,), + ) + with self.assertRaises(ModelArtifactUnavailableError): + runtime.resolve_artifact(spec) + + self.assertNotIn( + "actor.detector", + runtime.describe()["resolved_models"], + ) + + def test_actor_models_resolve_both_specs_through_runtime(self): + runtime = Mock() + runtime.device_for.return_value = "cpu" + runtime.get_or_load.side_effect = lambda _key, loader: loader() + runtime.resolve_artifact.side_effect = ( + Path("detector.onnx"), + Path("recognizer.onnx"), + ) + detector = Mock() + recognizer = Mock() + fake_cv2 = types.SimpleNamespace( + FaceDetectorYN=types.SimpleNamespace(create=detector), + FaceRecognizerSF=types.SimpleNamespace(create=recognizer), + ) + + with patch.dict(sys.modules, {"cv2": fake_cv2}): + actor_models.get_actor_models(runtime) + + self.assertEqual( + [call.args[0] for call in runtime.resolve_artifact.call_args_list], + [YUNET_MODEL, SFACE_MODEL], + ) + detector.assert_called_once() + recognizer.assert_called_once() + self.assertEqual( + runtime.record_compute_precision.call_args_list, + [ + call("actor.detector", "float32"), + call("actor.recognizer", "float32"), + ], + ) + + def test_actor_manifest_derives_provider_identity_from_specs(self): + manifest = actor_manifest(IndexConfig.local(), ()) + actor = manifest["actor"] + + self.assertEqual( + actor["models"], + { + "detector": YUNET_MODEL.identity(), + "recognizer": SFACE_MODEL.identity(), + }, + ) + for repeated in ("provider", "revision", "license", "precision"): + self.assertNotIn(repeated, actor) + + def test_model_spec_metadata_is_canonical_and_unambiguous(self): + self.assertEqual(YUNET_MODEL.license, "MIT") + self.assertEqual(SFACE_MODEL.license, "Apache-2.0") + self.assertEqual(QWEN3_EMBEDDING_MODEL.weights_precision, "bfloat16") + self.assertEqual( + FASTER_WHISPER_MODEL.model_id, + "dropbox-dash/faster-whisper-large-v3-turbo", + ) + self.assertEqual( + FASTER_WHISPER_MODEL.weights_precision, + "float16", ) def test_scene_dependency_check_is_capability_scoped(self): @@ -66,9 +291,11 @@ def installed(name): inspected.append(name) return { "chromadb": "1.5.9", + "psutil": "7.2.2", "numpy": "2.5.1", "opencv-python-headless": "5.0.0.93", "Pillow": "12.3.0", + "huggingface-hub": "1.25.1", "torch": "2.13.0", "transformers": "5.14.1", }[name] @@ -76,11 +303,38 @@ def installed(name): with patch("vidxp.dependencies.version", side_effect=installed): checks = registry.dependency_checks(("scene",)) - self.assertTrue(all(check["ok"] for check in checks)) + self.assertTrue( + all( + check.ok + for check in checks + if check.kind == DependencyKind.distribution + ) + ) self.assertNotIn("faster-whisper", inspected) self.assertNotIn("sentence-transformers", inspected) self.assertNotIn("pooch", inspected) + def test_platform_runtime_checks_are_storage_owned_and_not_duplicated(self): + registry = create_capability_registry( + platform_runtime_checks=LOCAL_INDEX_RUNTIME_CHECKS + ) + + checks = registry.dependency_checks(registry.names()) + storage_checks = [ + check + for check in checks + if check.kind == DependencyKind.runtime + and check.capability == "storage" + ] + + self.assertEqual( + [check.name for check in storage_checks], + [ + "Chroma storage import", + "Host resource monitor import", + ], + ) + def test_transcript_only_excludes_transcription_provider(self): registry = create_capability_registry() source = VideoSource( @@ -116,9 +370,12 @@ def test_runtime_distributions_come_from_registry(self): def test_requirement_files_are_dependency_contract(self): self.assertNotIn("dependencies", CapabilityDefinition.model_fields) registry = create_capability_registry() - self.assertEqual( - registry.runtime_checks_for(("dialogue",)), - (), + self.assertIn( + "faster-whisper import", + { + check.label + for check in registry.runtime_checks_for(("dialogue",)) + }, ) def test_requirement_check_uses_distribution_metadata(self): @@ -133,6 +390,28 @@ def test_requirement_check_uses_distribution_metadata(self): ): self.assertFalse(inspect_requirement(requirement)["ok"]) + def test_server_runtime_and_external_allowlist_are_explicit(self): + with self.assertRaises(ValidationError): + VidXPSettings(mode="server", runtime_backend="mps") + with self.assertRaises(ValidationError): + VidXPSettings(capability_allowlist=("distribution-only",)) + settings = VidXPSettings( + mode="server", + runtime_backend="cuda:0", + capability_allowlist=("acme-capabilities:ocr",), + ) + self.assertEqual(settings.runtime_backend, "cuda:0") + + def test_auto_runtime_remains_cpu_until_acceleration_parity_is_enabled(self): + with patch( + "vidxp.runtime._torch_accelerators", + return_value=(True, True), + ): + profile = resolve_backends("auto") + + self.assertEqual(profile.torch_device, "cpu") + self.assertEqual(profile.transcription_device, "cpu") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 149b565..d04f1c9 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -1,5 +1,8 @@ import unittest from pathlib import Path +import tomllib + +from packaging.requirements import Requirement from vidxp.capabilities.registry import create_capability_registry @@ -8,6 +11,30 @@ class PackagingTests(unittest.TestCase): + def test_cpu_lock_uses_explicit_pytorch_index_without_cuda_packages(self): + lock = (ROOT / "uv.lock").read_text(encoding="utf-8") + project = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + + self.assertIn("https://download.pytorch.org/whl/cpu", lock) + self.assertNotIn('name = "nvidia-', lock) + self.assertIn('explicit = true', project) + self.assertIn("sys_platform == 'linux'", project) + self.assertIn("sys_platform == 'win32'", project) + + def test_publishable_metadata_contains_no_direct_url_requirements(self): + project = tomllib.loads( + (ROOT / "pyproject.toml").read_text(encoding="utf-8") + )["project"] + requirements = list(project["dependencies"]) + requirements.extend( + line.strip() + for path in (ROOT / "src" / "vidxp").rglob("requirements.txt") + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + ) + for value in requirements: + self.assertIsNone(Requirement(value).url, value) + def test_capability_extras_read_capability_owned_requirements(self): pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8") storage = "src/vidxp/requirements/storage.txt" @@ -36,13 +63,20 @@ def test_capability_extras_read_capability_owned_requirements(self): self.assertIn(f'"{relative}"', all_block) self.assertIn(f'storage = {{ file = ["{storage}"] }}', pyproject) - self.assertEqual( - sum( + chroma_contracts = { + path.relative_to(ROOT).as_posix() + for path in (ROOT / "src" / "vidxp").rglob("*.txt") + if any( line.strip().startswith("chromadb") - for path in (ROOT / "src" / "vidxp").rglob("*.txt") for line in path.read_text(encoding="utf-8").splitlines() - ), - 1, + ) + } + self.assertEqual( + chroma_contracts, + { + "src/vidxp/requirements/server.txt", + "src/vidxp/requirements/storage.txt", + }, ) self.assertNotIn("benchmarks/requirements.txt", all_block) self.assertNotIn("requirements/frontend.txt", all_block) @@ -68,6 +102,26 @@ def test_base_dependencies_exclude_capability_runtimes(self): ): self.assertNotIn(distribution, base_dependencies) + def test_install_profiles_keep_server_free_of_ml_dependencies(self): + pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + self.assertIn("local-worker = { file = [", pyproject) + self.assertIn( + 'server = { file = ["src/vidxp/requirements/server.txt"] }', + pyproject, + ) + server = ( + ROOT / "src" / "vidxp" / "requirements" / "server.txt" + ).read_text(encoding="utf-8") + self.assertIn("chromadb", server) + for distribution in ( + "faster-whisper", + "opencv-python-headless", + "sentence-transformers", + "torch", + "transformers", + ): + self.assertNotIn(distribution, server) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_runner.py b/tests/test_runner.py index 52899e4..06f5277 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -11,17 +11,20 @@ IndexSchemaError, VideoSource, ) -from vidxp.core.manifest import COMPLETION_FILE +from vidxp.core.manifest import COMPLETION_FILE, ManifestStore from vidxp.capabilities.contracts import CapabilityIndexResult -from vidxp.capabilities.dialogue.config import dialogue_config +from vidxp.capabilities.dialogue.specs import FASTER_WHISPER_MODEL from vidxp.core.runner import ( _RunLock, - index_video, + index_video as _index_video, indexing_in_progress, local_config_from_status, - run_index, + run_index as _run_index, ) +from vidxp.capabilities.registry import create_capability_registry from vidxp.index_state import IndexingInProgressError +from vidxp.runtime import ModelRuntime +from vidxp.settings import VidXPSettings EXECUTION_STATE = { @@ -34,6 +37,50 @@ } +def _dependencies(config): + return { + "registry": create_capability_registry(), + "runtime": ModelRuntime( + VidXPSettings( + repository_root=config.run_directory, + runtime_backend=config.device, + ) + ), + } + + +def run_index(sources, config, **options): + dependencies = _dependencies(config) + options.setdefault("registry", dependencies["registry"]) + options.setdefault("runtime", dependencies["runtime"]) + options.setdefault("storage", FakeStorage()) + options.setdefault( + "manifest_store", + ManifestStore( + config, + registry=options["registry"], + runtime=options["runtime"], + ), + ) + return _run_index(sources, config, **options) + + +def index_video(path, *args, config, **options): + dependencies = _dependencies(config) + options.setdefault("registry", dependencies["registry"]) + options.setdefault("runtime", dependencies["runtime"]) + options.setdefault("storage", FakeStorage()) + options.setdefault( + "manifest_store", + ManifestStore( + config, + registry=options["registry"], + runtime=options["runtime"], + ), + ) + return _index_video(path, *args, config=config, **options) + + def visual_result(summary, timings=None): normalized = dict(summary) scene_frames = int(normalized.get("scene_frames", 0)) @@ -325,7 +372,7 @@ def test_manifest_adds_transcription_model_when_run_later_needs_it(self): self.assertNotIn("transcription", first["models"]) self.assertEqual( second["models"]["transcription"]["model"], - dialogue_config(config).whisper_model, + FASTER_WHISPER_MODEL.model_id, ) def test_changed_input_is_not_silently_accepted_by_checkpoint(self): diff --git a/tests/test_search.py b/tests/test_search.py index 25fa844..4a8fe4f 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -3,8 +3,11 @@ from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import patch +from unittest.mock import Mock +import numpy as np from vidxp.capabilities.dialogue.operations import search_dialogue +from vidxp.capabilities.dialogue.operations import dialogue_embedding from vidxp.capabilities.schemas import SearchResult from vidxp.capabilities.search import ( distance_to_score, @@ -100,6 +103,22 @@ def test_score_is_strictly_monotonic_and_not_a_probability(self): self.assertGreater(distance_to_score(0.1), distance_to_score(0.2)) self.assertEqual(distance_to_score(2.5), -2.5) + def test_dialogue_query_uses_model_owned_query_prompt(self): + encoder = Mock() + encoder.encode_query.return_value = np.asarray([[0.5, 0.25]]) + with patch( + "vidxp.capabilities.dialogue.operations.get_embedder", + return_value=encoder, + ): + vector = dialogue_embedding("fresh bread", self.config, self.runtime) + + self.assertEqual(vector, [0.5, 0.25]) + encoder.encode_query.assert_called_once_with( + ["fresh bread"], + convert_to_numpy=True, + normalize_embeddings=True, + ) + def test_generated_query_ids_are_scoped_to_the_benchmark_run(self): first = stable_query_id("fresh bread", "dialogue", self.config) second = stable_query_id( diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..e2628f1 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3761 @@ +version = 1 +revision = 3 +requires-python = ">=3.11, <3.15" +resolution-markers = [ + "(python_full_version >= '3.14' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform == 'win32')", + "(python_full_version == '3.13.*' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform == 'win32')", + "(python_full_version == '3.12.*' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform == 'win32')", + "(python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')", + "python_full_version >= '3.14' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform != 'linux' and sys_platform != 'win32'", +] + +[[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", marker = "python_full_version < '3.13'" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +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 = "altair" +version = "6.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "narwhals" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/a1/5e6cc638a66da48cfc89a79c2f4810dfec00b63385f9b009ab1f069779bb/altair-6.2.2.tar.gz", hash = "sha256:a1ff9d9cfe81c75414641826312b9471780e19d39293ba0b012933f6b6cba0fe", size = 766606, upload-time = "2026-06-23T12:47:13.384Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/99/d6031f4f146298951c46b1bf1cc160c2a63f6e44b3c13a30054add100d5f/altair-6.2.2-py3-none-any.whl", hash = "sha256:94014f8ad8617c3cb163d1137359cd6db5ba134b9b46d93cfd8b609fd245a583", size = 797613, upload-time = "2026-06-23T12:47:11.451Z" }, +] + +[[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 = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[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 = "av" +version = "18.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/a4/570a5a35c8638aba01e739925846c35fdd6b0756a15526766d0a4dd3b7df/av-18.0.0.tar.gz", hash = "sha256:4ef7e72c3d3a872584a1215173b16e0226811037f40dcdbf75992631098df1ba", size = 4340222, upload-time = "2026-07-02T06:37:58.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/4a/9e3463df030e063d757fa12f0f39be6541b45b06b5bad48c2ce361b924bf/av-18.0.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:149289d40e732a6e49c9530bc245b49d9964cfd1c8c9e06778703b7d5bba6b25", size = 22499354, upload-time = "2026-07-02T06:36:58.751Z" }, + { url = "https://files.pythonhosted.org/packages/77/b3/2576a44b4f39c7462ced4c17fec04c756f7b0f3c5cb940d124173e417d6a/av-18.0.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:35274c20d2ad3b4774fe632bcef2e34af79858ddf899352339cc3babbc13a484", size = 18175248, upload-time = "2026-07-02T06:37:01.741Z" }, + { url = "https://files.pythonhosted.org/packages/84/74/6732f17b96dc23fd23b876b2805435855abdc8a3b397142be4e581165de8/av-18.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4d683b7747a0ba9222b8a5f81e41db5f796e7f64473454ec4fe2548e083c2fa0", size = 33387843, upload-time = "2026-07-02T06:37:05.097Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b9/7708c43fed7ae28b4a1bad060b4221e3334cd827cec24f7165902a6ac1f4/av-18.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ae56b40b6f8b067a8ad2dac664fbfbabac7f7a55b9a7bb031eb99289252bc017", size = 35536910, upload-time = "2026-07-02T06:37:08.806Z" }, + { url = "https://files.pythonhosted.org/packages/5a/94/eba99691d184f6a395a242d54dc370e2fd2265e95bbc98e2963a0fdbdd6c/av-18.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ea2e8ebbce521f21b55df9400e00d721623c9020ef158f5a188a96130be0743f", size = 38984619, upload-time = "2026-07-02T06:37:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/c9/cf/0d7aee07fe16aa9ffdf96043c14bed5485a52c0dea4259de87aa306ecab4/av-18.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef96dabb3e50dac249913145dff5424b302b257fd95dcb64be3c7b7a8aef16d1", size = 34451176, upload-time = "2026-07-02T06:37:15.154Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/810da80b12680d4c4fe235bd1b4003289be9213ac7f114b77b8ecf0e3b3e/av-18.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0f65518a184613e41536f29e8758c8e3d8293e46bf5bef108f04f925bbfa3f44", size = 36619869, upload-time = "2026-07-02T06:37:18.495Z" }, + { url = "https://files.pythonhosted.org/packages/11/85/0f121ff43dc5a70696676c98a8f1674e2fa787614c2abaacb15fa1a9bc99/av-18.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:aaf4d354d2beaa6651e4f92e54409a578bde64f79c0beef9a30b388d06f7c629", size = 27556236, upload-time = "2026-07-02T06:37:21.388Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f6/2509754d4d2356abc6fc0ea3d57c12ade29bac23a1fb7fc215a53ca518fb/av-18.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:adac2b3833b6cb9bd6cb52664a522b94db453615b3675b1dbb26e13fe1c80da6", size = 20221133, upload-time = "2026-07-02T06:37:23.88Z" }, + { url = "https://files.pythonhosted.org/packages/e2/25/4ee23a7f1609adf9b2f140c7a8ffade64a1449d89ab431d922a809eebf19/av-18.0.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:88dd8e35e9242662b409a6a05fd24a6775d949eb05da0ba31cab4f250eacbab5", size = 22740741, upload-time = "2026-07-02T06:37:26.659Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f0/b9f8363d07aa4521913e483f6a30c7c164973ef01de62769bf9b97049cd8/av-18.0.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f8f454349c402e2c8d6fa80b54eb2a3f86c00f414d2b399f01ae6dab075c6fd8", size = 18384189, upload-time = "2026-07-02T06:37:29.518Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e5/69397019aed280a72a43e97a252dee4295df1a9e608848452e5300ec4dab/av-18.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:88ce194c2201c6a6d40336adee8a5ddde46ed743eacb500e3ae9368d1c6d889e", size = 36749881, upload-time = "2026-07-02T06:37:33.096Z" }, + { url = "https://files.pythonhosted.org/packages/37/3a/1614d74f0d676ea6745eb59553c9ad01ca25db523cba808d522e838f4f5b/av-18.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:aa15e567a018cc94a26b0ab45da676dee70c4146ace6e92e47d30cc9689cbfbe", size = 38645927, upload-time = "2026-07-02T06:37:37.086Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3c/5f54710d69b0ea93634134f92b49c7a2a7fd27da5486a8a7e6251ac1cfb4/av-18.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:613153e48cefc91700746dde0ad0282d4677b194cba22cc771de14c78411cf8b", size = 40454783, upload-time = "2026-07-02T06:37:40.904Z" }, + { url = "https://files.pythonhosted.org/packages/26/92/8293e6a267e0591b543abd96ae01e7e8ed228509bdb4e4644a8a8395d90f/av-18.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:30404f53ca1ea7f350ac86ff22a2c04f903014758e9b33f398c5a62de34bd84f", size = 37573117, upload-time = "2026-07-02T06:37:44.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/0c/38ed7601277ae57dfe857d040be4762530fd728efff45c2fb8f035fef96a/av-18.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6882a48f7aec2863c96cddee3256ff2da98f7fb6cbed83cee9d7e70a8f186a6b", size = 39669026, upload-time = "2026-07-02T06:37:48.761Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/0636ca04d5d89d01c49bd366d2b660cc85d1f8117c476b2be62eb0c70855/av-18.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:55a646e9afce9fdc5de5224205a8a12c7ed1ba9803145dcc876c40bfc03a109b", size = 28448336, upload-time = "2026-07-02T06:37:52.477Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/1e24450ea981c44ed328691496fd2774dfa9fa3c3b00fd07f72fd5614abe/av-18.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:96f594ff506a09475e5549359352332049a25d37a08f00b4623f7f6e92e45b9c", size = 21377289, upload-time = "2026-07-02T06:37:55.935Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, + { url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" }, + { url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "build" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/e0/df5e171f685f82f37b12e1f208064e24244911079d7b767447d1af7e0d70/build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647", size = 89796, upload-time = "2026-04-30T03:18:25.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f", size = 26018, upload-time = "2026-04-30T03:18:23.644Z" }, +] + +[[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.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "chromadb" +version = "1.5.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bcrypt" }, + { name = "build" }, + { name = "grpcio" }, + { name = "httpx" }, + { name = "importlib-resources" }, + { name = "jsonschema" }, + { name = "kubernetes" }, + { name = "mmh3" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "onnxruntime" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-sdk" }, + { name = "orjson" }, + { name = "overrides" }, + { name = "pybase64" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pypika" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "tenacity" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/d1/5e33b26985f0c7046a0be1cee2158ada1748ee700d2545057fde1468d74d/chromadb-1.5.9.tar.gz", hash = "sha256:5c20e62a455c28bacac927f26116a73fd8e1799e0d908be8e8a4f02197a54731", size = 2595635, upload-time = "2026-05-05T05:54:51.713Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/5b/3cced915244f43ed14b53fe9f63a37f05f865064f4e4fe7d9448d3f2a352/chromadb-1.5.9-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:60701011b5e6409647fa40d12c7c5a66b2b0bfcf33a52db2ad53a30a2abc4957", size = 22564540, upload-time = "2026-05-05T05:54:48.906Z" }, + { url = "https://files.pythonhosted.org/packages/34/4c/adcef1f4e82a2ef69ccd3711d55fc289193d54c4c0ff7a0292a3631db46f/chromadb-1.5.9-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:814b9c95617377f6501e5757d63dfddb554a283a7739c87b9fa573850174e6f3", size = 21699698, upload-time = "2026-05-05T05:54:45.078Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/937bc4d2e6f8ab9664ec79931fbbd69efff47e513ec2924b071e4b0ff774/chromadb-1.5.9-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9192d111bd662241625867962333d99369a00769a50f8b2f58cb388731274d7e", size = 22680924, upload-time = "2026-05-05T05:54:36.25Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ec/0c42039e80b9acc534f67b73b7a42471948042859b3a64867b50a4a77fa3/chromadb-1.5.9-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc09b3df76e5a5cb386aed2715a2eea152e3949f9e1ba93c7119505377749929", size = 23316203, upload-time = "2026-05-05T05:54:41.157Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ce/0f7be6e5d0feafa2cda54b12e6542afeea7dea89d2d411e14da90f8abb96/chromadb-1.5.9-cp39-abi3-win_amd64.whl", hash = "sha256:4fd0b560e56761b7f3cb4d5c6205fd5f20814484b4a3e4e9af9038c2b428fc6c", size = 23542454, upload-time = "2026-05-05T05:54:54.942Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[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 = "ctranslate2" +version = "4.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pyyaml" }, + { name = "setuptools" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/c4/0e450796f90e54f3325697fc67db4f4ecd397aef96d7b3924e26fb8bd04b/ctranslate2-4.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c2db633a06e3b34bbfb72fd26eee58053d9df1f9c1610ac4df3a6a1e25af7d7", size = 1270559, upload-time = "2026-07-03T12:39:01.154Z" }, + { url = "https://files.pythonhosted.org/packages/b7/54/7b6db16470d0788fb8ab43a99e3e18ba9d41a9b50b7fef7dec353eafbe20/ctranslate2-4.8.1-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:079976cbce3a68de04bf9948d08c96beb86df44e5cd2974e4187bc9c9bb388f3", size = 11928069, upload-time = "2026-07-03T12:39:02.6Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/8fee1366631d224bf26b34db9063a0c88ce358d58331c2393689b0ea27ff/ctranslate2-4.8.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74bae0a8dc9f98c5a6100bf1c17a91782b384ea53b83e2606030ebf9f25318fe", size = 16707971, upload-time = "2026-07-03T12:39:05.09Z" }, + { url = "https://files.pythonhosted.org/packages/30/84/f610e90bb419707632b9b668476b9fd4cdb090c9b53c119ce017699b58ca/ctranslate2-4.8.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0a584c17f21779eb9035bcbc1ec280998f90b36725b70a5ff911f33e343199a", size = 39351971, upload-time = "2026-07-03T12:39:08.555Z" }, + { url = "https://files.pythonhosted.org/packages/76/6c/7230ecbdd23ab867715e1b6ffe99211c39c11cae8ec2d6c3ec9208c38ee2/ctranslate2-4.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:82982f07a7d615d2248d17d6ec4c43cd50e534b094aa27cda62125a5e3a6e3fc", size = 19219248, upload-time = "2026-07-03T12:39:11.329Z" }, + { url = "https://files.pythonhosted.org/packages/6d/09/9a50eeab00db68aeac08f6ab7f98b5c36abd26b89cbd707ea39e70656500/ctranslate2-4.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9de0dddd91ae68da0a7323441e90708d14b31d31cd443004dda0e1198b5bf11e", size = 1270522, upload-time = "2026-07-03T12:39:13.368Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/6c41c4d3ae539ec76b1943c362184677befd7c1d5290d2ec361182cdb1e0/ctranslate2-4.8.1-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:82e0e6eb7d4301fd79a714495c8faf34242e09542cef04c9e9794c3fe90014a1", size = 11930367, upload-time = "2026-07-03T12:39:14.896Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d4/03428106134a0a58922461074f8942f92c5ed0bb3a8d018677ad64a9c476/ctranslate2-4.8.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ca144b93035b9f53e6d67b7cdf5802c3fffca9aa0247940eecbd4592c68ce2f", size = 16882768, upload-time = "2026-07-03T12:39:17.425Z" }, + { url = "https://files.pythonhosted.org/packages/47/c9/976a565398a03fb2973cbe5edd5ca03c4332d86b634799e0ee562420d3bc/ctranslate2-4.8.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dacc408f716ebc73b3b3c6ddd937700e776c4c68b6d9c81862990150ff0f6af6", size = 39529060, upload-time = "2026-07-03T12:39:20.468Z" }, + { url = "https://files.pythonhosted.org/packages/c0/82/0a5f7f2b03b4e10aacb3146715724e1b96bb993cc7d199be28c9825aa120/ctranslate2-4.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:49f96e861b57301f0b76a082109bde2cac8204a6b4fedc870883008271e82251", size = 19220789, upload-time = "2026-07-03T12:39:23.356Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a7/3101c3a0785253a8ef386f39744ad19c28c75b7f227e7c232aee7a5c416a/ctranslate2-4.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ba628835e6ad4ad399261ab6cb51bf152de563e6b122a9e8eb0c61e69f925931", size = 1270478, upload-time = "2026-07-03T12:39:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/89/b9/e50c7558e96a054d6b1e6a6c5e729dda4a4f05584e065f2902aa5f1bc4c8/ctranslate2-4.8.1-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:85ef15ce0b2172ec471975b8a30d5c5bc71e7cffcd163ad6c07ea32f1943d940", size = 11930241, upload-time = "2026-07-03T12:39:26.927Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2f/ea7a19c6d7e949b731fb034664633184bbfc7882846d107f4d790693fb76/ctranslate2-4.8.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0030670278a73cae09dff9bca72cdd248af61f9367257f18db9b3b94fbb3a50d", size = 16883512, upload-time = "2026-07-03T12:39:29.302Z" }, + { url = "https://files.pythonhosted.org/packages/99/4a/21f325a9d0925d8ad24b04249adf29bf9909442967603634f7f6d4acbb79/ctranslate2-4.8.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4242a7f8e285f922525f4cffd5b1fb43cbacc61d0611cf54832e9c447d030840", size = 39529085, upload-time = "2026-07-03T12:39:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e7/37da1a7500b57496a5269318c4f57962ea0c26dcac06b85222d7831acf00/ctranslate2-4.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:d52499f05a60a791aeadee28d609efa130142f376d1ea76b2b1c593bb01f8827", size = 19220784, upload-time = "2026-07-03T12:39:35.74Z" }, + { url = "https://files.pythonhosted.org/packages/c6/66/39111224e418400d97fd79fbc9e72329c51f91a3e7a9c9a1a182e4f88022/ctranslate2-4.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b4c3246aa4a7f309109a841ca743a72cc4abad4f93c0bf7da691023323215621", size = 1271321, upload-time = "2026-07-03T12:39:37.907Z" }, + { url = "https://files.pythonhosted.org/packages/ef/89/13f827fae226eea51315729c00111f716813d7736ebb827fecb8f361fe0d/ctranslate2-4.8.1-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:c989f747789e8619cbc2e06443b3674c31bc71bad0369652485bd894b627360a", size = 11930735, upload-time = "2026-07-03T12:39:39.534Z" }, + { url = "https://files.pythonhosted.org/packages/c9/94/4b73f9bbaba29df4227cc65114f11d83fe6d696ef3705cb1ade79eb118fd/ctranslate2-4.8.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90eb0bd67b6bb183712cc3fd14bf01ec4f622cd625c5b33cc6c56be7d1c9c34", size = 16872460, upload-time = "2026-07-03T12:39:42.272Z" }, + { url = "https://files.pythonhosted.org/packages/ee/d0/9816494d5ff0745bdf9abe5af04e57a103a416444e604cbe83a6eb0aed7b/ctranslate2-4.8.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e3e3aef4670a6c8dcea367401675f82b49b02c18f5837221bcd7cca90b1707a8", size = 39494736, upload-time = "2026-07-03T12:39:45.733Z" }, + { url = "https://files.pythonhosted.org/packages/6c/dc/22a2c874ca8bb6caa7018dfefdff92dddd487db31cf169891c4c6d408091/ctranslate2-4.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:a2dcce0a57beee984a691d9daa8fc3fd389f5b6cada2644c34571011833bd5b1", size = 19477164, upload-time = "2026-07-03T12:39:48.952Z" }, + { url = "https://files.pythonhosted.org/packages/77/39/7b8d47bf49748ba73182742683eef74b46608beb879765d9d4efc46bc345/ctranslate2-4.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a28c5889585cd17ee3649dfd46d9002ddf50204173f8bff476b9f76d6585795", size = 1293935, upload-time = "2026-07-03T12:39:50.924Z" }, + { url = "https://files.pythonhosted.org/packages/c1/20/434e30c752c433eaef5deccd4de54775bc1f205a6fe6c9e756b737018209/ctranslate2-4.8.1-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:911a5cdef8a405c1804330613a1865f616eb9c092a0e932ee4648128eb20b627", size = 11951789, upload-time = "2026-07-03T12:39:52.886Z" }, + { url = "https://files.pythonhosted.org/packages/85/f2/d716426220b462bbb5bb354b9c6c8d9a41285f067203c860cc79f9f19917/ctranslate2-4.8.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84723cae6f802551bbf2438e5e4810722631a2183b89a82c31df26566b54821d", size = 16860414, upload-time = "2026-07-03T12:39:55.54Z" }, + { url = "https://files.pythonhosted.org/packages/69/11/cdab0e7e2ad4e547f15ab227c09207569f1272abae05816900ecebb0797a/ctranslate2-4.8.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1910752ec541980644191fa3b407bc61dee00e88070b0aed29b4cef75010b3ea", size = 39465200, upload-time = "2026-07-03T12:39:59.017Z" }, + { url = "https://files.pythonhosted.org/packages/c0/03/126e963fc3237a416f3085b8a663ebd8ab449ed6c37195b4e0b49597ba0c/ctranslate2-4.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dc9f1abef55579cc02cdc74b3a55df38491ec56d177d6e6039609d61d09ed30e", size = 19499597, upload-time = "2026-07-03T12:40:01.68Z" }, +] + +[[package]] +name = "durationpy" +version = "0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335, upload-time = "2025-05-17T13:52:37.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, +] + +[[package]] +name = "faster-whisper" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "av" }, + { name = "ctranslate2" }, + { name = "huggingface-hub" }, + { name = "onnxruntime" }, + { name = "tokenizers" }, + { name = "tqdm" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/99/49ee85903dee060d9f08297b4a342e5e0bcfca2f027a07b4ee0a38ab13f9/faster_whisper-1.2.1-py3-none-any.whl", hash = "sha256:79a66ad50688c0b794dd501dc340a736992a6342f7f95e5811be60b5224a26a7", size = 1118909, upload-time = "2025-10-31T11:35:47.794Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/80/8232b582c4b318b817cf1274ba74976b07b34d35ef439b3eb948f98645a1/filelock-3.32.0.tar.gz", hash = "sha256:7be2ad23a14607ccc71808e68fe30848aeace7058ace17852f68e2a68e310402", size = 213757, upload-time = "2026-07-21T13:17:42.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/79/b4c714bef36bc4ec2beeae1e0c124f0223888cd8c6feb1cdc56038116920/filelock-3.32.0-py3-none-any.whl", hash = "sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3", size = 97732, upload-time = "2026-07-21T13:17:41.55Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + +[[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/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { 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 = "fsspec" +version = "2026.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88", size = 317040, upload-time = "2026-07-28T16:34:51.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.57" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/0d/132ed135c871b6bf91adf16a0e43797cd535b81d4973b5d09291c54fc5ee/gitpython-3.1.57.tar.gz", hash = "sha256:c493ec57c0ef6b19743798b6a5af859c71814b524e7e6f97baa2f8e658961488", size = 225898, upload-time = "2026-07-26T07:33:26.351Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/6e/2139de986d9c7c3ac86f1f8be43858ce90bdfe2f7175e6c80c650ba15242/gitpython-3.1.57-py3-none-any.whl", hash = "sha256:4ccf7d73c10f5c9e76043fbb2675ac5a1b3ff5b41e648f56bcbed5f63792ecaf", size = 217151, upload-time = "2026-07-26T07:33:24.838Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + +[[package]] +name = "grpcio" +version = "1.83.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/f6/3b781cd07a715ea5f5125ae264226e7fc4d87603d6d3955022cabfdc5da2/grpcio-1.83.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:8ff0b8767ddd62704e0d9571c1890af08d84a3a689ebba1807e62519d0b3277f", size = 6338720, upload-time = "2026-07-23T15:19:13.177Z" }, + { url = "https://files.pythonhosted.org/packages/21/cc/d14833d15d5984e366f1b027fa78bd038c9b028c66880bffb0f5a4d25ee2/grpcio-1.83.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:4772402f43517b4824980be4b3b2274a81eec0004a70009473c31b340d43e223", size = 12178773, upload-time = "2026-07-23T15:19:15.401Z" }, + { url = "https://files.pythonhosted.org/packages/6b/98/8acbb416544e7871132d8e42a07ed70c802d70e6a16c6009e505a34d32a4/grpcio-1.83.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f4cee5fc86e84a0cf7ad1574b454c3320e087c07f55b7df5dc0ac6a873fb90c0", size = 6921203, upload-time = "2026-07-23T15:19:17.824Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/0fdbfaf4fc54e5c88f6bce4008a065092fe7fbc4460eb5617ae8b20fd505/grpcio-1.83.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f5e822a7e7d03282f6ad225e710493c48b9057a353358344a5f7c42b2b37618d", size = 7648508, upload-time = "2026-07-23T15:19:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ea/107b9dbb2ed3ad14dd774fd3dde7d29ff9938a6c198654becb2c3a0e9a6a/grpcio-1.83.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5f410d7c2903eabb34789dfd6342eef04af1ad459943936b7e09a9f5bd417b9", size = 7079466, upload-time = "2026-07-23T15:19:21.478Z" }, + { url = "https://files.pythonhosted.org/packages/3b/06/9fa9941089e6fae83b060b6ce61c1e81053e52decae43197245f45e07d36/grpcio-1.83.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee94a4016fdf8699fb1fd8a38652475ff677f1c72074cee44deeeb9a7e95e745", size = 7605583, upload-time = "2026-07-23T15:19:23.74Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/f10fb56062dc2771c630827a82d9ad0ecd05cad572ea3b08d49f6631680a/grpcio-1.83.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6444666317338e903093c7c756e6cc88eee59f798cb8dd41e87725bf54e1617", size = 8637810, upload-time = "2026-07-23T15:19:25.536Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/f84927258f6a1b6ea6dea661fdc6de859b35e560c96f3012d15ccd39f85e/grpcio-1.83.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa074041231f03959cb097dd5517b0677b8ea49215bae01d5710a7b69dd59969", size = 8008021, upload-time = "2026-07-23T15:19:27.863Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/cdf72161397ccd29d4ca2192f641524536c9cf54ad948c9dd0e0e01138fa/grpcio-1.83.0-cp311-cp311-win32.whl", hash = "sha256:cb056f6e171c42639a50460b2929c82241fda51f71cf3dcdd68090fe45095a45", size = 4404376, upload-time = "2026-07-23T15:19:30.137Z" }, + { url = "https://files.pythonhosted.org/packages/df/ed/e0ffeb4c848699c194dc9fb6a29ab29bcb2b6aac8c416bf18c51bfe8242c/grpcio-1.83.0-cp311-cp311-win_amd64.whl", hash = "sha256:7416952ca770477990257206276999056f8316d79196f2f25942393e58a20b49", size = 5164469, upload-time = "2026-07-23T15:19:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/15/2b/51e32514a4e9b715375c99721aadff0f24164cc2049b8269eda4de82a814/grpcio-1.83.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:28f6c35ac8fcf10e4594f138e468f194360089dde40d126a7033e863fc479930", size = 6303167, upload-time = "2026-07-23T15:19:33.78Z" }, + { url = "https://files.pythonhosted.org/packages/39/33/b5b50fc2c6fbe350e04814047bb2d409feec7b36ef8b170254c050e06bc0/grpcio-1.83.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:33898e6a28e4ae598f1577cb1c4fec2a15c033d0ec52b9b45a09610dd045b9da", size = 12160538, upload-time = "2026-07-23T15:19:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5f/734e72e7b9f79bcf0b2c270b8d3bca0e4ebb97a27a50d06240b145f6d41e/grpcio-1.83.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6fb8a1dd0c6f0f931e69e9d0dc6d1c406ed2a44fa963414eafba07b7fb685d16", size = 6869310, upload-time = "2026-07-23T15:19:38.607Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/a1735f215b2a5cd43c38b79eac072ad197e61be9829905b6b29550abd0db/grpcio-1.83.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2b5e75c34842cd9c1b95285ca395c6a569664b81e3ffa6b714125922942abaaf", size = 7613472, upload-time = "2026-07-23T15:19:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/b2/78/c9e81f806ac704b6b145cb01628db398985b1f8dfdc10e23b55fb0902b3d/grpcio-1.83.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeb339838db07600481ef869507279b75326c75eac6d10f7afa62a0da1d2bcdd", size = 7040616, upload-time = "2026-07-23T15:19:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ba/94cd5af859876049d340480acbb61a959096c84b567f215534faa78d0424/grpcio-1.83.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f47d62808b4c0a97b78bff88a6d4ca283a2a492b9a04a87d814af95ca3b9c19c", size = 7570491, upload-time = "2026-07-23T15:19:44.357Z" }, + { url = "https://files.pythonhosted.org/packages/3e/15/108d30d5a5c964312ae8b9cb0e8cc5b3c1cc68d8f757cca52b3565534d26/grpcio-1.83.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62003babc444a606dcd1f009cd16391ce23669ae4ad6ec267a873da7937a69f5", size = 8605036, upload-time = "2026-07-23T15:19:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/ea/23/3828ae13c3db8233d123ad612747665817b952d8a954f32390230b582336/grpcio-1.83.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1aa567f8c3f19850ffd5d2858c9a8ea7c80f0db6c01186b71eb31e923ec984f5", size = 7981587, upload-time = "2026-07-23T15:19:48.913Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/77af31228f55f55a2a5112bb0077ad0a1c4d23dbb0c2853a62475bbdcc14/grpcio-1.83.0-cp312-cp312-win32.whl", hash = "sha256:cb2906c61db4f9c64cc360054b5df70eeb81846228e9e56a4944bd415a63dadc", size = 4394004, upload-time = "2026-07-23T15:19:50.618Z" }, + { url = "https://files.pythonhosted.org/packages/c0/da/f706e39550e7a3732ce2b9c5926107a93d74a802775b19b642a6df27dc96/grpcio-1.83.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c699bbb20f143c8f2bff219de578aa2dc1f919399d67dc702b038b986ee62df", size = 5158525, upload-time = "2026-07-23T15:19:52.246Z" }, + { url = "https://files.pythonhosted.org/packages/56/eb/135daaa713f32d33b8f99b4153b3f8dc3b2a124996ac15581bf9ebdad3c3/grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0", size = 6304480, upload-time = "2026-07-23T15:19:53.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a1/121806ce69f23138dabe06aa595b0e5f1ae051a37e4c1954eed7d692c800/grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1", size = 12154419, upload-time = "2026-07-23T15:19:56.3Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e8/d0389e09cd6b4c4d3089b92967ae4e3ffd64795bd349bf2f85cd6656d3da/grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867", size = 6873200, upload-time = "2026-07-23T15:19:58.701Z" }, + { url = "https://files.pythonhosted.org/packages/f8/51/f464c1d211fa50d5adbabe1b2e519948d99c13757052bfc9ea7afa28e284/grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881", size = 7618811, upload-time = "2026-07-23T15:20:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c0/539fe0832f2dd6500a28f5263071623fb34e8d4867aec632ccf81bd21156/grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f", size = 7042310, upload-time = "2026-07-23T15:20:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/ccf617d37ffa72567fa8e005ec7090c99da922799be2fb9847c8b21ca18c/grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61", size = 7575412, upload-time = "2026-07-23T15:20:04.712Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b9/fd8d5245f823a8e0fd35d90e20ea3aa4acd47f8d5318fa8df307df52dec6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45", size = 8604248, upload-time = "2026-07-23T15:20:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/14/1e/f37632fc11db72dfa4bba86c3a43e54358e53030df111ecae5e91a733ad6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c", size = 7977458, upload-time = "2026-07-23T15:20:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/93/b6/d70b69ae5c0cfc341b9ba474980e4ed99cbf05c0e4a14e9eee8cb73db0a5/grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf", size = 4393993, upload-time = "2026-07-23T15:20:11.017Z" }, + { url = "https://files.pythonhosted.org/packages/0f/13/45d4cccb555cf4c476226979bf3d2fd0b0254216f7564c3a053e35117efc/grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735", size = 5159650, upload-time = "2026-07-23T15:20:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/9c/60/f2cca8147ea213d3e43ae9158d03ad04e020fdf32ff027253e1fe93f921d/grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa", size = 6305607, upload-time = "2026-07-23T15:20:15.353Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ab/d3874931d123a95e83a3ebf8aa04537988fb62425cedb8bf3cefc5ad41b2/grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c", size = 12166617, upload-time = "2026-07-23T15:20:17.435Z" }, + { url = "https://files.pythonhosted.org/packages/92/ff/6f18f9426b69306f4e00a9add3b0ee2748da8aad53836ef80cab0d62d04f/grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b", size = 6880213, upload-time = "2026-07-23T15:20:19.98Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/706d1147c6b93b98f179240c13991fbcc56880eba0c868abb1ad40d8a0a6/grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c", size = 7618335, upload-time = "2026-07-23T15:20:22.161Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/1a8443c889115ec9e213a213e86bc93a71ee9088027e5befa09aaa0edd9d/grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df", size = 7043416, upload-time = "2026-07-23T15:20:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/c6/94e0fee5b12bc1da1370185b680988db6f739d19b42d9959db01a7ea50bf/grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b", size = 7583253, upload-time = "2026-07-23T15:20:26.313Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/de1ccb671fb85575bc5192faedf9ecdbdf5b390d2e6584dcf552bcbd370e/grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404", size = 8605102, upload-time = "2026-07-23T15:20:28.437Z" }, + { url = "https://files.pythonhosted.org/packages/17/0f/0e0ec749a7034ffcbaa050e39779872950ead90c22e7e0116be3f28b2b46/grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af", size = 7979826, upload-time = "2026-07-23T15:20:31.182Z" }, + { url = "https://files.pythonhosted.org/packages/83/fa/c3fda157287f64bc65acee6c5aa90c41acf9e0d3a8e69a265eecff6d00a1/grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33", size = 4471765, upload-time = "2026-07-23T15:20:33.195Z" }, + { url = "https://files.pythonhosted.org/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9", size = 5298932, upload-time = "2026-07-23T15:20:35.48Z" }, +] + +[[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 = "hf-xet" +version = "1.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/39/67be8d71f900d9a55761b6022821d6679fb56c64f1b6063d5af2c2606727/hf_xet-1.5.2.tar.gz", hash = "sha256:73044bd31bae33c984af832d19c752a0dffb67518fee9ddbd91d616e1101cf47", size = 903674, upload-time = "2026-07-16T17:29:56.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/be/525eabac5d1736b679c39e342ecd4292534012546a2d18f0043c8e3b6021/hf_xet-1.5.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4a5ecb9cda8512ba2aa8ee5d37c87a1422992165892d653098c7b90247481c3b", size = 4064284, upload-time = "2026-07-16T17:29:29.907Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3f/699749dd78442480eda4e4fca494284b0e3542e4063cc37654d5fdc929e6/hf_xet-1.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8764488197c1d7b1378c8438c18d2eea902e150dbca0b0f0d2d32603fb9b5576", size = 3828537, upload-time = "2026-07-16T17:29:31.549Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/2658ac0a5b9f4664ca27ce31bd015044fe9dea50ed455fb5197aba819c11/hf_xet-1.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d7446f72abbf7e01ca5ff131786bc2e74a56393462c17a6bf1e303fbab81db4", size = 4417133, upload-time = "2026-07-16T17:29:33.391Z" }, + { url = "https://files.pythonhosted.org/packages/d9/58/8343f3cb63c8fa058d576136df3871550f7d5214a8f048a7ea2eab6ac906/hf_xet-1.5.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:580e59e29bf37aece1f2b68537de1e3fb04f43a23d910dcf6f128280b5bfbba4", size = 4212613, upload-time = "2026-07-16T17:29:34.989Z" }, + { url = "https://files.pythonhosted.org/packages/0c/33/a968f4e4535037b36941ec00714625fb60e026302407e7e26ca9f3e65f4e/hf_xet-1.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bee28c619622d36968056532fd49cf2b35ca75099b1d616c31a618a893491380", size = 4412710, upload-time = "2026-07-16T17:29:36.646Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/9e33981173dbaf194ba0015202b02d467b624d44d4eba89e1bf06c0d2995/hf_xet-1.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e396ab0faf6298199ad7a95305c3ca8498cb825978a6485be6d00587ee4ec577", size = 4628455, upload-time = "2026-07-16T17:29:38.352Z" }, + { url = "https://files.pythonhosted.org/packages/e9/4b/cc682832de4264a03880a2d1b5ec3e1fab3bf307f508817250baafdb9996/hf_xet-1.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fd3add255549e8ef58fa35b2e42dc016961c050600444e7d77d030ba6b57120e", size = 3979044, upload-time = "2026-07-16T17:29:40.329Z" }, + { url = "https://files.pythonhosted.org/packages/ea/09/b2cdf2a0fb39a08af3222b96092a36bd3b40c54123eef07de4422e870971/hf_xet-1.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d6f9c58549407b84b9a5383afd68db0acc42345326a3159990b36a5ca8a20e4e", size = 3808037, upload-time = "2026-07-16T17:29:42.357Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/2b70603c7552db82baeb2623e2336898304a17328845151be4fe1f48d420/hf_xet-1.5.2-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f922b8f5fb84f1dd3d7ab7a1316354a1bca9b1c73ecfc19c76e51a2a49d29799", size = 4033760, upload-time = "2026-07-16T17:29:43.884Z" }, + { url = "https://files.pythonhosted.org/packages/60/ac/b097a86a1e4a6098f3a79382643ab09d5733d87ccc864877ad1e12b49b70/hf_xet-1.5.2-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:045f84440c55cdeb659cf1a1dd48c77bcd0d2e93632e2fea8f2c3bdee79f38ed", size = 3841438, upload-time = "2026-07-16T17:29:45.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/35/db860aa3a0780660324a506ad4b3d322ddc6ecbba4b9340aed0942cbf21c/hf_xet-1.5.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db78c39c83d6279daddc98e2238f373ab8980685556d42472b4ec51abcf03e8c", size = 4428006, upload-time = "2026-07-16T17:29:46.996Z" }, + { url = "https://files.pythonhosted.org/packages/af/6b/832dd980af4b0c3ae0660e309285f2ffcdff2faa38129390dbb47aa4a3f9/hf_xet-1.5.2-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7db73c810500c54c6760be8c39d4b2e476974de85424c50063efc22fdda13025", size = 4221099, upload-time = "2026-07-16T17:29:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/9e/05/ae50f0d34e3254e6c3e208beb2519f6b8673016fc4b3643badaf6450d186/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6395cfe3c9cbead4f16b31808b0e67eac428b66c656f856e99636adaddea878f", size = 4420766, upload-time = "2026-07-16T17:29:50.092Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/c050bc2743a2bcd68928bfee157b08681667a164a24ec95fbfcfcd717e08/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cde8cd167126bb6109b2ceb19b844433a4988643e8f3e01dd9dd0e4a34535097", size = 4636716, upload-time = "2026-07-16T17:29:51.62Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/68b01c5c2edb56ac9a67b3d076ffddcb90867abaee923923eb34e7a14e76/hf_xet-1.5.2-cp38-abi3-win_amd64.whl", hash = "sha256:ecf63d1cb69a9a7319910f8f83fcf9b46e7a32dfcf4b8f8eeddb55f647306e65", size = 3988373, upload-time = "2026-07-16T17:29:53.395Z" }, + { url = "https://files.pythonhosted.org/packages/39/c6/988383e9dc17294d536fcbcd6fd16eed882e411ad16c954984a53e47b09c/hf_xet-1.5.2-cp38-abi3-win_arm64.whl", hash = "sha256:1da28519496eb7c8094c11e4d25509b4a468457a0302d58136099db2fd9a671d", size = 3816957, upload-time = "2026-07-16T17:29:54.991Z" }, +] + +[[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" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/50/db3771a6e4fad4bd28fb055d4363b51cb0ae98c1aa504b79d41fdcab5483/huggingface_hub-1.25.1.tar.gz", hash = "sha256:21129595ca7a753be479b319913e22cc8808361ac118bd76cc413db831b28a99", size = 928426, upload-time = "2026-07-27T09:24:10.117Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/3f/21e816831c6d16f88a6c784974413fa0421ce8a5d04380c2666ed5b503e5/huggingface_hub-1.25.1-py3-none-any.whl", hash = "sha256:004d4e70350517e24c68a7dbb7dc5e40b2b6aefef8f94bf7a85f6f9835102ea5", size = 774909, upload-time = "2026-07-27T09:24:08.079Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "importlib-resources" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/06/b56dfa750b44e86157093bc8fca0ab81dccbf5260510de4eaf1cb69b5b99/importlib_resources-7.1.0.tar.gz", hash = "sha256:0722d4c6212489c530f2a145a34c0a7a3b4721bc96a15fada5930e2a0b760708", size = 44985, upload-time = "2026-04-12T16:36:09.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[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 = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "kubernetes" +version = "36.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "certifi" }, + { name = "durationpy" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "six" }, + { name = "urllib3" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/57/b07b96353f902aa1bdbe00e878e3a12a137977d03a962479785576aa8ec9/kubernetes-36.0.3.tar.gz", hash = "sha256:36993ed25ce59b789c9341473a228fcf268504a2fec7c2b2b1531d73072e5ce7", size = 2337528, upload-time = "2026-07-13T20:38:12.128Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/30/a96d47df739689ac0001ade0afefc16e3b477fc2fb426b568515fdc8afce/kubernetes-36.0.3-py2.py3-none-any.whl", hash = "sha256:8fde9241c4b298e6374a069dcf728359b4e72c2fb29489a975ba4e1c047cf10f", size = 4618066, upload-time = "2026-07-13T20:38:10.172Z" }, +] + +[[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" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[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 = "mmh3" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/1a/edb23803a168f070ded7a3014c6d706f63b90c84ccc024f89d794a3b7a6d/mmh3-5.2.1.tar.gz", hash = "sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad", size = 33775, upload-time = "2026-03-05T15:55:57.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/d7/3312a59df3c1cdd783f4cf0c4ee8e9decff9c5466937182e4cc7dbbfe6c5/mmh3-5.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dae0f0bd7d30c0ad61b9a504e8e272cb8391eed3f1587edf933f4f6b33437450", size = 56082, upload-time = "2026-03-05T15:53:59.702Z" }, + { url = "https://files.pythonhosted.org/packages/61/96/6f617baa098ca0d2989bfec6d28b5719532cd8d8848782662f5b755f657f/mmh3-5.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9aeaf53eaa075dd63e81512522fd180097312fb2c9f476333309184285c49ce0", size = 40458, upload-time = "2026-03-05T15:54:01.548Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b4/9cd284bd6062d711e13d26c04d4778ab3f690c1c38a4563e3c767ec8802e/mmh3-5.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0634581290e6714c068f4aa24020acf7880927d1f0084fa753d9799ae9610082", size = 40079, upload-time = "2026-03-05T15:54:02.743Z" }, + { url = "https://files.pythonhosted.org/packages/f6/09/a806334ce1d3d50bf782b95fcee8b3648e1e170327d4bb7b4bad2ad7d956/mmh3-5.2.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080c0637aea036f35507e803a4778f119a9b436617694ae1c5c366805f1e997", size = 97242, upload-time = "2026-03-05T15:54:04.536Z" }, + { url = "https://files.pythonhosted.org/packages/ee/93/723e317dd9e041c4dc4566a2eb53b01ad94de31750e0b834f1643905e97c/mmh3-5.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db0562c5f71d18596dcd45e854cf2eeba27d7543e1a3acdafb7eef728f7fe85d", size = 103082, upload-time = "2026-03-05T15:54:06.387Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/f96121e69cc48696075071531cf574f112e1ffd08059f4bffb41210e6fc5/mmh3-5.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d9f9a3ce559a5267014b04b82956993270f63ec91765e13e9fd73daf2d2738e", size = 106054, upload-time = "2026-03-05T15:54:07.506Z" }, + { url = "https://files.pythonhosted.org/packages/82/49/192b987ec48d0b2aecf8ac285a9b11fbc00030f6b9c694664ae923458dde/mmh3-5.2.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:960b1b3efa39872ac8b6cc3a556edd6fb90ed74f08c9c45e028f1005b26aa55d", size = 112910, upload-time = "2026-03-05T15:54:09.403Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a1/03e91fd334ed0144b83343a76eb11f17434cd08f746401488cfeafb2d241/mmh3-5.2.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d30b650595fdbe32366b94cb14f30bb2b625e512bd4e1df00611f99dc5c27fd4", size = 120551, upload-time = "2026-03-05T15:54:10.587Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/b89a71d2ff35c3a764d1c066c7313fc62c7cc48fa48a4b3b0304a4a0146f/mmh3-5.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82f3802bfc4751f420d591c5c864de538b71cea117fce67e4595c2afede08a15", size = 99096, upload-time = "2026-03-05T15:54:11.76Z" }, + { url = "https://files.pythonhosted.org/packages/36/b5/613772c1c6ed5f7b63df55eb131e887cc43720fec392777b95a79d34e640/mmh3-5.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:915e7a2418f10bd1151b1953df06d896db9783c9cfdb9a8ee1f9b3a4331ab503", size = 98524, upload-time = "2026-03-05T15:54:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/1524566fe8eaf871e4f7bc44095929fcd2620488f402822d848df19d679c/mmh3-5.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fc78739b5ec6e4fb02301984a3d442a91406e7700efbe305071e7fd1c78278f2", size = 106239, upload-time = "2026-03-05T15:54:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/04/94/21adfa7d90a7a697137ad6de33eeff6445420ca55e433a5d4919c79bc3b5/mmh3-5.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:41aac7002a749f08727cb91babff1daf8deac317c0b1f317adc69be0e6c375d1", size = 109797, upload-time = "2026-03-05T15:54:15.819Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e6/1aacc3a219e1aa62fa65669995d4a3562b35be5200ec03680c7e4bec9676/mmh3-5.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9d8089d853c7963a8ce87fff93e2a67075c0bc08684a08ea6ad13577c38ffc38", size = 97228, upload-time = "2026-03-05T15:54:16.992Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b9/5e4cca8dcccf298add0a27f3c357bc8cf8baf821d35cdc6165e4bd5a48b0/mmh3-5.2.1-cp311-cp311-win32.whl", hash = "sha256:baeb47635cb33375dee4924cd93d7f5dcaa786c740b08423b0209b824a1ee728", size = 40751, upload-time = "2026-03-05T15:54:18.714Z" }, + { url = "https://files.pythonhosted.org/packages/72/fc/5b11d49247f499bcda591171e9cf3b6ee422b19e70aa2cef2e0ae65ca3b9/mmh3-5.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:1e4ecee40ba19e6975e1120829796770325841c2f153c0e9aecca927194c6a2a", size = 41517, upload-time = "2026-03-05T15:54:19.764Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/2a511ee8a1c2a527c77726d5231685b72312c5a1a1b7639ad66a9652aa84/mmh3-5.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:c302245fd6c33d96bd169c7ccf2513c20f4c1e417c07ce9dce107c8bc3f8411f", size = 39287, upload-time = "2026-03-05T15:54:20.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/94/bc5c3b573b40a328c4d141c20e399039ada95e5e2a661df3425c5165fd84/mmh3-5.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0cc21533878e5586b80d74c281d7f8da7932bc8ace50b8d5f6dbf7e3935f63f1", size = 56087, upload-time = "2026-03-05T15:54:21.92Z" }, + { url = "https://files.pythonhosted.org/packages/f6/80/64a02cc3e95c3af0aaa2590849d9ed24a9f14bb93537addde688e039b7c3/mmh3-5.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4eda76074cfca2787c8cf1bec603eaebdddd8b061ad5502f85cddae998d54f00", size = 40500, upload-time = "2026-03-05T15:54:22.953Z" }, + { url = "https://files.pythonhosted.org/packages/8b/72/e6d6602ce18adf4ddcd0e48f2e13590cc92a536199e52109f46f259d3c46/mmh3-5.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eee884572b06bbe8a2b54f424dbd996139442cf83c76478e1ec162512e0dd2c7", size = 40034, upload-time = "2026-03-05T15:54:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/59/c2/bf4537a8e58e21886ef16477041238cab5095c836496e19fafc34b7445d2/mmh3-5.2.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0b7e803191db5f714d264044e06189c8ccd3219e936cc184f07106bd17fd7b", size = 97292, upload-time = "2026-03-05T15:54:25.335Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e2/51ed62063b44d10b06d975ac87af287729eeb5e3ed9772f7584a17983e90/mmh3-5.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e6c219e375f6341d0959af814296372d265a8ca1af63825f65e2e87c618f006", size = 103274, upload-time = "2026-03-05T15:54:26.44Z" }, + { url = "https://files.pythonhosted.org/packages/75/ce/12a7524dca59eec92e5b31fdb13ede1e98eda277cf2b786cf73bfbc24e81/mmh3-5.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26fb5b9c3946bf7f1daed7b37e0c03898a6f062149127570f8ede346390a0825", size = 106158, upload-time = "2026-03-05T15:54:28.578Z" }, + { url = "https://files.pythonhosted.org/packages/86/1f/d3ba6dd322d01ab5d44c46c8f0c38ab6bbbf9b5e20e666dfc05bf4a23604/mmh3-5.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c38d142c706201db5b2345166eeef1e7740e3e2422b470b8ba5c8727a9b4c7a", size = 113005, upload-time = "2026-03-05T15:54:29.767Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a9/15d6b6f913294ea41b44d901741298e3718e1cb89ee626b3694625826a43/mmh3-5.2.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50885073e2909251d4718634a191c49ae5f527e5e1736d738e365c3e8be8f22b", size = 120744, upload-time = "2026-03-05T15:54:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/70b73923fd0284c439860ff5c871b20210dfdbe9a6b9dd0ee6496d77f174/mmh3-5.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3f99e1756fc48ad507b95e5d86f2fb21b3d495012ff13e6592ebac14033f166", size = 99111, upload-time = "2026-03-05T15:54:32.353Z" }, + { url = "https://files.pythonhosted.org/packages/dd/38/99f7f75cd27d10d8b899a1caafb9d531f3903e4d54d572220e3d8ac35e89/mmh3-5.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62815d2c67f2dd1be76a253d88af4e1da19aeaa1820146dec52cf8bee2958b16", size = 98623, upload-time = "2026-03-05T15:54:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/fd/68/6e292c0853e204c44d2f03ea5f090be3317a0e2d9417ecb62c9eb27687df/mmh3-5.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8f767ba0911602ddef289404e33835a61168314ebd3c729833db2ed685824211", size = 106437, upload-time = "2026-03-05T15:54:35.177Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c6/fedd7284c459cfb58721d461fcf5607a4c1f5d9ab195d113d51d10164d16/mmh3-5.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:67e41a497bac88cc1de96eeba56eeb933c39d54bc227352f8455aa87c4ca4000", size = 110002, upload-time = "2026-03-05T15:54:36.673Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ac/ca8e0c19a34f5b71390171d2ff0b9f7f187550d66801a731bb68925126a4/mmh3-5.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d74a03fb57757ece25aa4b3c1c60157a1cece37a020542785f942e2f827eed5", size = 97507, upload-time = "2026-03-05T15:54:37.804Z" }, + { url = "https://files.pythonhosted.org/packages/df/94/6ebb9094cfc7ac5e7950776b9d13a66bb4a34f83814f32ba2abc9494fc68/mmh3-5.2.1-cp312-cp312-win32.whl", hash = "sha256:7374d6e3ef72afe49697ecd683f3da12f4fc06af2d75433d0580c6746d2fa025", size = 40773, upload-time = "2026-03-05T15:54:40.077Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/cd3527198cf159495966551c84a5f36805a10ac17b294f41f67b83f6a4d6/mmh3-5.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9fed49c6ce4ed7e73f13182760c65c816da006debe67f37635580dfb0fae00", size = 41560, upload-time = "2026-03-05T15:54:41.148Z" }, + { url = "https://files.pythonhosted.org/packages/15/96/6fe5ebd0f970a076e3ed5512871ce7569447b962e96c125528a2f9724470/mmh3-5.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfcb95d9a744e6e2827dfc66ad10e1020e0cac255eb7f85652832d5a264c2fc", size = 39313, upload-time = "2026-03-05T15:54:42.171Z" }, + { url = "https://files.pythonhosted.org/packages/25/a5/9daa0508a1569a54130f6198d5462a92deda870043624aa3ea72721aa765/mmh3-5.2.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:723b2681ed4cc07d3401bbea9c201ad4f2a4ca6ba8cddaff6789f715dd2b391e", size = 40832, upload-time = "2026-03-05T15:54:43.212Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6b/3230c6d80c1f4b766dedf280a92c2241e99f87c1504ff74205ec8cebe451/mmh3-5.2.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:3619473a0e0d329fd4aec8075628f8f616be2da41605300696206d6f36920c3d", size = 41964, upload-time = "2026-03-05T15:54:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/62/fb/648bfddb74a872004b6ee751551bfdda783fe6d70d2e9723bad84dbe5311/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e48d4dbe0f88e53081da605ae68644e5182752803bbc2beb228cca7f1c4454d6", size = 39114, upload-time = "2026-03-05T15:54:45.205Z" }, + { url = "https://files.pythonhosted.org/packages/95/c2/ab7901f87af438468b496728d11264cb397b3574d41506e71b92128e0373/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a482ac121de6973897c92c2f31defc6bafb11c83825109275cffce54bb64933f", size = 39819, upload-time = "2026-03-05T15:54:46.509Z" }, + { url = "https://files.pythonhosted.org/packages/2f/ed/6f88dda0df67de1612f2e130ffea34cf84aaee5bff5b0aff4dbff2babe34/mmh3-5.2.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:17fbb47f0885ace8327ce1235d0416dc86a211dcd8cc1e703f41523be32cfec8", size = 40330, upload-time = "2026-03-05T15:54:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/3d/66/7516d23f53cdf90f43fce24ab80c28f45e6851d78b46bef8c02084edf583/mmh3-5.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d51fde50a77f81330523562e3c2734ffdca9c4c9e9d355478117905e1cfe16c6", size = 56078, upload-time = "2026-03-05T15:54:48.9Z" }, + { url = "https://files.pythonhosted.org/packages/bc/34/4d152fdf4a91a132cb226b671f11c6b796eada9ab78080fb5ce1e95adaab/mmh3-5.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19bbd3b841174ae6ed588536ab5e1b1fe83d046e668602c20266547298d939a9", size = 40498, upload-time = "2026-03-05T15:54:49.942Z" }, + { url = "https://files.pythonhosted.org/packages/d4/4c/8e3af1b6d85a299767ec97bd923f12b06267089c1472c27c1696870d1175/mmh3-5.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be77c402d5e882b6fbacfd90823f13da8e0a69658405a39a569c6b58fdb17b03", size = 40033, upload-time = "2026-03-05T15:54:50.994Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/966ea560e32578d453c9e9db53d602cbb1d0da27317e232afa7c38ceba11/mmh3-5.2.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fd96476f04db5ceba1cfa0f21228f67c1f7402296f0e73fee3513aa680ad237b", size = 97320, upload-time = "2026-03-05T15:54:52.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/0d/2c5f9893b38aeb6b034d1a44ecd55a010148054f6a516abe53b5e4057297/mmh3-5.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:707151644085dd0f20fe4f4b573d28e5130c4aaa5f587e95b60989c5926653b5", size = 103299, upload-time = "2026-03-05T15:54:53.569Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fc/2ebaef4a4d4376f89761274dc274035ffd96006ab496b4ee5af9b08f21a9/mmh3-5.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3737303ca9ea0f7cb83028781148fcda4f1dac7821db0c47672971dabcf63593", size = 106222, upload-time = "2026-03-05T15:54:55.092Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/ea7ffe126d0ba0406622602a2d05e1e1a6841cc92fc322eb576c95b27fad/mmh3-5.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2778fed822d7db23ac5008b181441af0c869455b2e7d001f4019636ac31b6fe4", size = 113048, upload-time = "2026-03-05T15:54:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/85/57/9447032edf93a64aa9bef4d9aa596400b1756f40411890f77a284f6293ca/mmh3-5.2.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d57dea657357230cc780e13920d7fa7db059d58fe721c80020f94476da4ca0a1", size = 120742, upload-time = "2026-03-05T15:54:57.453Z" }, + { url = "https://files.pythonhosted.org/packages/53/82/a86cc87cc88c92e9e1a598fee509f0409435b57879a6129bf3b3e40513c7/mmh3-5.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:169e0d178cb59314456ab30772429a802b25d13227088085b0d49b9fe1533104", size = 99132, upload-time = "2026-03-05T15:54:58.583Z" }, + { url = "https://files.pythonhosted.org/packages/54/f7/6b16eb1b40ee89bb740698735574536bc20d6cdafc65ae702ea235578e05/mmh3-5.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e4e1f580033335c6f76d1e0d6b56baf009d1a64d6a4816347e4271ba951f46d", size = 98686, upload-time = "2026-03-05T15:55:00.078Z" }, + { url = "https://files.pythonhosted.org/packages/e8/88/a601e9f32ad1410f438a6d0544298ea621f989bd34a0731a7190f7dec799/mmh3-5.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2bd9f19f7f1fcebd74e830f4af0f28adad4975d40d80620be19ffb2b2af56c9f", size = 106479, upload-time = "2026-03-05T15:55:01.532Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/ce29ae3dfc4feec4007a437a1b7435fb9507532a25147602cd5b52be86db/mmh3-5.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c88653877aeb514c089d1b3d473451677b8b9a6d1497dbddf1ae7934518b06d2", size = 110030, upload-time = "2026-03-05T15:55:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/13/30/ae444ef2ff87c805d525da4fa63d27cda4fe8a48e77003a036b8461cfd5c/mmh3-5.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fceef7fe67c81e1585198215e42ad3fdba3a25644beda8fbdaf85f4d7b93175a", size = 97536, upload-time = "2026-03-05T15:55:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f9/dc3787ee5c813cc27fe79f45ad4500d9b5437f23a7402435cc34e07c7718/mmh3-5.2.1-cp313-cp313-win32.whl", hash = "sha256:54b64fb2433bc71488e7a449603bf8bd31fbcf9cb56fbe1eb6d459e90b86c37b", size = 40769, upload-time = "2026-03-05T15:55:05.277Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/850e0b5a1e97799822ebfc4ca0e8c6ece3ed8baf7dcdf64de817dfdda2ca/mmh3-5.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:cae6383181f1e345317742d2ddd88f9e7d2682fa4c9432e3a74e47d92dce0229", size = 41563, upload-time = "2026-03-05T15:55:06.283Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/98c90b28e1da5458e19fbfaf4adb5289208d3bfccd45dd14eab216a2f0bb/mmh3-5.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:022aa1a528604e6c83d0a7705fdef0b5355d897a9e0fa3a8d26709ceaa06965d", size = 39310, upload-time = "2026-03-05T15:55:07.323Z" }, + { url = "https://files.pythonhosted.org/packages/63/b4/65bc1fb2bb7f83e91c30865023b1847cf89a5f237165575e8c83aa536584/mmh3-5.2.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d771f085fcdf4035786adfb1d8db026df1eb4b41dac1c3d070d1e49512843227", size = 40794, upload-time = "2026-03-05T15:55:09.773Z" }, + { url = "https://files.pythonhosted.org/packages/c4/86/7168b3d83be8eb553897b1fac9da8bbb06568e5cfe555ffc329ebb46f59d/mmh3-5.2.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:7f196cd7910d71e9d9860da0ff7a77f64d22c1ad931f1dd18559a06e03109fc0", size = 41923, upload-time = "2026-03-05T15:55:10.924Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9b/b653ab611c9060ce8ff0ba25c0226757755725e789292f3ca138a58082cd/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b1f12bd684887a0a5d55e6363ca87056f361e45451105012d329b86ec19dbe0b", size = 39131, upload-time = "2026-03-05T15:55:11.961Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5a2e0d34ab4d33543f01121e832395ea510132ea8e52cdf63926d9d81754/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d106493a60dcb4aef35a0fac85105e150a11cf8bc2b0d388f5a33272d756c966", size = 39825, upload-time = "2026-03-05T15:55:13.013Z" }, + { url = "https://files.pythonhosted.org/packages/bd/69/81699a8f39a3f8d368bec6443435c0c392df0d200ad915bf0d222b588e03/mmh3-5.2.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:44983e45310ee5b9f73397350251cdf6e63a466406a105f1d16cb5baa659270b", size = 40344, upload-time = "2026-03-05T15:55:14.026Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b3/71c8c775807606e8fd8acc5c69016e1caf3200d50b50b6dd4b40ce10b76c/mmh3-5.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:368625fb01666655985391dbad3860dc0ba7c0d6b9125819f3121ee7292b4ac8", size = 56291, upload-time = "2026-03-05T15:55:15.137Z" }, + { url = "https://files.pythonhosted.org/packages/6f/75/2c24517d4b2ce9e4917362d24f274d3d541346af764430249ddcc4cb3a08/mmh3-5.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:72d1cc63bcc91e14933f77d51b3df899d6a07d184ec515ea7f56bff659e124d7", size = 40575, upload-time = "2026-03-05T15:55:16.518Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b9/e4a360164365ac9f07a25f0f7928e3a66eb9ecc989384060747aa170e6aa/mmh3-5.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e8b4b5580280b9265af3e0409974fb79c64cf7523632d03fbf11df18f8b0181e", size = 40052, upload-time = "2026-03-05T15:55:17.735Z" }, + { url = "https://files.pythonhosted.org/packages/97/ca/120d92223a7546131bbbc31c9174168ee7a73b1366f5463ffe69d9e691fe/mmh3-5.2.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4cbbde66f1183db040daede83dd86c06d663c5bb2af6de1142b7c8c37923dd74", size = 97311, upload-time = "2026-03-05T15:55:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/b6/71/c1a60c1652b8813ef9de6d289784847355417ee0f2980bca002fe87f4ae5/mmh3-5.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ff038d52ef6aa0f309feeba00c5095c9118d0abf787e8e8454d6048db2037fc", size = 103279, upload-time = "2026-03-05T15:55:20.448Z" }, + { url = "https://files.pythonhosted.org/packages/48/29/ad97f4be1509cdcb28ae32c15593ce7c415db47ace37f8fad35b493faa9a/mmh3-5.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4130d0b9ce5fad6af07421b1aecc7e079519f70d6c05729ab871794eded8617", size = 106290, upload-time = "2026-03-05T15:55:21.6Z" }, + { url = "https://files.pythonhosted.org/packages/77/29/1f86d22e281bd8827ba373600a4a8b0c0eae5ca6aa55b9a8c26d2a34decc/mmh3-5.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e0bfe77d238308839699944164b96a2eeccaf55f2af400f54dc20669d8d5f2", size = 113116, upload-time = "2026-03-05T15:55:22.826Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7c/339971ea7ed4c12d98f421f13db3ea576a9114082ccb59d2d1a0f00ccac1/mmh3-5.2.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f963eafc0a77a6c0562397da004f5876a9bcf7265a7bcc3205e29636bc4a1312", size = 120740, upload-time = "2026-03-05T15:55:24.3Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/3c7c4bdb8e926bb3c972d1e2907d77960c1c4b250b41e8366cf20c6e4373/mmh3-5.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:92883836caf50d5255be03d988d75bc93e3f86ba247b7ca137347c323f731deb", size = 99143, upload-time = "2026-03-05T15:55:25.456Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/33dd8706e732458c8375eae63c981292de07a406bad4ec03e5269654aa2c/mmh3-5.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57b52603e89355ff318025dd55158f6e71396c0f1f609d548e9ea9c94cc6ce0a", size = 98703, upload-time = "2026-03-05T15:55:26.723Z" }, + { url = "https://files.pythonhosted.org/packages/51/04/76bbce05df76cbc3d396f13b2ea5b1578ef02b6a5187e132c6c33f99d596/mmh3-5.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f40a95186a72fa0b67d15fef0f157bfcda00b4f59c8a07cbe5530d41ac35d105", size = 106484, upload-time = "2026-03-05T15:55:28.214Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8f/c6e204a2c70b719c1f62ffd9da27aef2dddcba875ea9c31ca0e87b975a46/mmh3-5.2.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:58370d05d033ee97224c81263af123dea3d931025030fd34b61227a768a8858a", size = 110012, upload-time = "2026-03-05T15:55:29.532Z" }, + { url = "https://files.pythonhosted.org/packages/e3/37/7181efd8e39db386c1ebc3e6b7d1f702a09d7c1197a6f2742ed6b5c16597/mmh3-5.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7be6dfb49e48fd0a7d91ff758a2b51336f1cd21f9d44b20f6801f072bd080cdd", size = 97508, upload-time = "2026-03-05T15:55:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/42/0f/afa7ca2615fd85e1469474bb860e381443d0b868c083b62b41cb1d7ca32f/mmh3-5.2.1-cp314-cp314-win32.whl", hash = "sha256:54fe8518abe06a4c3852754bfd498b30cc58e667f376c513eac89a244ce781a4", size = 41387, upload-time = "2026-03-05T15:55:32.403Z" }, + { url = "https://files.pythonhosted.org/packages/71/0d/46d42a260ee1357db3d486e6c7a692e303c017968e14865e00efa10d09fc/mmh3-5.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3f796b535008708846044c43302719c6956f39ca2d93f2edda5319e79a29efbb", size = 42101, upload-time = "2026-03-05T15:55:33.646Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7b/848a8378059d96501a41159fca90d6a99e89736b0afbe8e8edffeac8c74b/mmh3-5.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:cd471ede0d802dd936b6fab28188302b2d497f68436025857ca72cd3810423fe", size = 39836, upload-time = "2026-03-05T15:55:35.026Z" }, + { url = "https://files.pythonhosted.org/packages/27/61/1dabea76c011ba8547c25d30c91c0ec22544487a8750997a27a0c9e1180b/mmh3-5.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5174a697ce042fa77c407e05efe41e03aa56dae9ec67388055820fb48cf4c3ba", size = 57727, upload-time = "2026-03-05T15:55:36.162Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/731185950d1cf2d5e28979cc8593016ba1619a295faba10dda664a4931b5/mmh3-5.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0a3984146e414684a6be2862d84fcb1035f4984851cb81b26d933bab6119bf00", size = 41308, upload-time = "2026-03-05T15:55:37.254Z" }, + { url = "https://files.pythonhosted.org/packages/76/aa/66c76801c24b8c9418b4edde9b5e57c75e72c94e29c48f707e3962534f18/mmh3-5.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bd6e7d363aa93bd3421b30b6af97064daf47bc96005bddba67c5ffbc6df426b8", size = 40758, upload-time = "2026-03-05T15:55:38.61Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bb/79a1f638a02f0ae389f706d13891e2fbf7d8c0a22ecde67ba828951bb60a/mmh3-5.2.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:113f78e7463a36dbbcea05bfe688efd7fa759d0f0c56e73c974d60dcfec3dfcc", size = 109670, upload-time = "2026-03-05T15:55:40.13Z" }, + { url = "https://files.pythonhosted.org/packages/26/94/8cd0e187a288985bcfc79bf5144d1d712df9dee74365f59d26e3a1865be6/mmh3-5.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e8ec5f606e0809426d2440e0683509fb605a8820a21ebd120dcdba61b74ef7f", size = 117399, upload-time = "2026-03-05T15:55:42.076Z" }, + { url = "https://files.pythonhosted.org/packages/42/94/dfea6059bd5c5beda565f58a4096e43f4858fb6d2862806b8bbd12cbb284/mmh3-5.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22b0f9971ec4e07e8223f2beebe96a6cfc779d940b6f27d26604040dd74d3a44", size = 120386, upload-time = "2026-03-05T15:55:43.481Z" }, + { url = "https://files.pythonhosted.org/packages/47/cb/f9c45e62aaa67220179f487772461d891bb582bb2f9783c944832c60efd9/mmh3-5.2.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85ffc9920ffc39c5eee1e3ac9100c913a0973996fbad5111f939bbda49204bb7", size = 125924, upload-time = "2026-03-05T15:55:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/a5/83/fe54a4a7c11bc9f623dfc1707decd034245602b076dfc1dcc771a4163170/mmh3-5.2.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7aec798c2b01aaa65a55f1124f3405804184373abb318a3091325aece235f67c", size = 135280, upload-time = "2026-03-05T15:55:45.866Z" }, + { url = "https://files.pythonhosted.org/packages/97/67/fe7e9e9c143daddd210cd22aef89cbc425d58ecf238d2b7d9eb0da974105/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55dbbd8ffbc40d1697d5e2d0375b08599dae8746b0b08dea05eee4ce81648fac", size = 110050, upload-time = "2026-03-05T15:55:47.074Z" }, + { url = "https://files.pythonhosted.org/packages/43/c4/6d4b09fcbef80794de447c9378e39eefc047156b290fa3dd2d5257ca8227/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6c85c38a279ca9295a69b9b088a2e48aa49737bb1b34e6a9dc6297c110e8d912", size = 111158, upload-time = "2026-03-05T15:55:48.239Z" }, + { url = "https://files.pythonhosted.org/packages/81/a6/ca51c864bdb30524beb055a6d8826db3906af0834ec8c41d097a6e8573d5/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6290289fa5fb4c70fd7f72016e03633d60388185483ff3b162912c81205ae2cf", size = 116890, upload-time = "2026-03-05T15:55:49.405Z" }, + { url = "https://files.pythonhosted.org/packages/cc/04/5a1fe2e2ad843d03e89af25238cbc4f6840a8bb6c4329a98ab694c71deda/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4fc6cd65dc4d2fdb2625e288939a3566e36127a84811a4913f02f3d5931da52d", size = 123121, upload-time = "2026-03-05T15:55:50.61Z" }, + { url = "https://files.pythonhosted.org/packages/af/4d/3c820c6f4897afd25905270a9f2330a23f77a207ea7356f7aadace7273c0/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:623f938f6a039536cc02b7582a07a080f13fdfd48f87e63201d92d7e34d09a18", size = 110187, upload-time = "2026-03-05T15:55:52.143Z" }, + { url = "https://files.pythonhosted.org/packages/21/54/1d71cd143752361c0aebef16ad3f55926a6faf7b112d355745c1f8a25f7f/mmh3-5.2.1-cp314-cp314t-win32.whl", hash = "sha256:29bc3973676ae334412efdd367fcd11d036b7be3efc1ce2407ef8676dabfeb82", size = 41934, upload-time = "2026-03-05T15:55:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e4/63a2a88f31d93dea03947cccc2a076946857e799ea4f7acdecbf43b324aa/mmh3-5.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:28cfab66577000b9505a0d068c731aee7ca85cd26d4d63881fab17857e0fe1fb", size = 43036, upload-time = "2026-03-05T15:55:55.252Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0f/59204bf136d1201f8d7884cfbaf7498c5b4674e87a4c693f9bde63741ce1/mmh3-5.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dfd51b4c56b673dfbc43d7d27ef857dd91124801e2806c69bb45585ce0fa019b", size = 40391, upload-time = "2026-03-05T15:55:56.697Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "narwhals" +version = "2.24.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/1d/58946e5aab18393e793bd4add6985b95d0e01c3a2d832f38f54468b10dcd/narwhals-2.24.0.tar.gz", hash = "sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d", size = 661143, upload-time = "2026-07-13T10:49:19.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl", hash = "sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489", size = 461030, upload-time = "2026-07-13T10:49:17.571Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "(python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')", + "python_full_version < '3.12' and sys_platform != 'linux' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "(python_full_version >= '3.14' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform == 'win32')", + "(python_full_version == '3.13.*' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform == 'win32')", + "(python_full_version == '3.12.*' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform == 'win32')", + "python_full_version >= '3.14' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'linux' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/4d/5014667e2a3a77d6e1b74cc3d88948d06163b8e0a33a84c85073322b5dec/onnxruntime-1.28.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f5c5daabd28aad610f83fdcf32acec8fb57e6adc6c6a39fe2a3c755db957b410", size = 19130506, upload-time = "2026-07-25T01:22:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/ea/97/b7ce1bc8bb6048b5fe9129f55d6506dc19499068ef2e0a0af1ae3c8aa4e7/onnxruntime-1.28.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d66f9ceb29909c70839e4e4fb3435c7b490050d8f162bd5f3aba4ca01ee517f", size = 17039880, upload-time = "2026-07-25T01:21:37.538Z" }, + { url = "https://files.pythonhosted.org/packages/f3/17/4e5ecd8764f87573c495d834ce79e61ecca47f7a01d1e444a606e570edcb/onnxruntime-1.28.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a166b78ee04f3a37fa1ef82034b6a3ce96d9684e582d4d30b296de83e9998bb5", size = 19193162, upload-time = "2026-07-25T01:21:59.151Z" }, + { url = "https://files.pythonhosted.org/packages/9f/10/3d946d5d5f2cdcc3c8da36cae63190c516d16349edaffd944bda60ca4c3e/onnxruntime-1.28.0-cp311-cp311-win_amd64.whl", hash = "sha256:0d650aeee29368414367b65529e90afe4bf1bab76254789063b8b2f7ea3013c8", size = 13752539, upload-time = "2026-07-25T01:22:24.524Z" }, + { url = "https://files.pythonhosted.org/packages/8f/74/1c440be7af1e026280b139caa1be5d11bd4dc368011ddbe8f5362b58e12f/onnxruntime-1.28.0-cp311-cp311-win_arm64.whl", hash = "sha256:0faf85fb447a663c9cdadc39bd6b19bdf7bedded6699e45731b9b36c46fd993d", size = 13449940, upload-time = "2026-07-25T01:22:14.97Z" }, + { url = "https://files.pythonhosted.org/packages/98/f8/dcbe7700dca82fa540035abd3c868fe5ad0f86af00b9a3db7c2e27d15c7d/onnxruntime-1.28.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:26ff0fdd06efb6c155bae95387a09db1a2be89c7a03e4d0bffd5a171cc2826da", size = 19141362, upload-time = "2026-07-25T01:22:36.965Z" }, + { url = "https://files.pythonhosted.org/packages/28/5b/1d77e62097fdbe07e2dc827f389b1c4c0c275f6fab0369a8f46d2461af27/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e81a23df16e7acb9d51b06d30cc098e49315ef9180f97bc2221d167b4b04d9c", size = 17050628, upload-time = "2026-07-25T01:21:40.481Z" }, + { url = "https://files.pythonhosted.org/packages/95/df/5486ab03e9be288d5268867054c8b04bebcf95bfd12e801c05cc67703dab/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a83bdb70d143cede762b677789bf2a7acca54b3fb82565601d5c30695aa933c", size = 19214257, upload-time = "2026-07-25T01:22:01.695Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3b/986ca67c274932ba9ac5332fb10de56f643dfd433c74e33f8ae8f847cf24/onnxruntime-1.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:c35064f9b3c43c81c5d5d282091401d0f1ff22796d93ccade4ea2ece5e137ab8", size = 13755036, upload-time = "2026-07-25T01:22:26.89Z" }, + { url = "https://files.pythonhosted.org/packages/1d/46/059dba81d46c6ba88e0c2d1c64321ac8098847d678423300a183d42ecbd6/onnxruntime-1.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:e02feeb0165c5f13b4cc954738078d59b90128516ac12b671ee24a530242bf02", size = 13454462, upload-time = "2026-07-25T01:22:17.38Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/3807e2b17d9eb71d3cb78ed2ba76869b05c637c9b9d6112e636098b0c97a/onnxruntime-1.28.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:31410f544674f534c2f27348af52ef81682ca9c8719154bf4d48f0ef23823b1e", size = 19141759, upload-time = "2026-07-25T01:21:53.765Z" }, + { url = "https://files.pythonhosted.org/packages/c0/23/b46045c3bf67a9cf54c12f5df0f018a422c65fbb9d6072b10071bebfaae2/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f649dd6f6452d12a8059888aa489fe519e062e18793dac72b9efa0f9fdb64135", size = 17049339, upload-time = "2026-07-25T01:21:43.005Z" }, + { url = "https://files.pythonhosted.org/packages/78/b6/8c5396e7894e77c5a7d1e026f3acb9dd39c4b5644e412e37a0055eaa3bc5/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fa221d669282bd8f582708ce4c96010a7e9fb0661f9006b37fe2fedafb73fe", size = 19214329, upload-time = "2026-07-25T01:22:04.133Z" }, + { url = "https://files.pythonhosted.org/packages/56/f1/51225c202edba4dfc94e1ea03f3d78f1aaf307da75fd792c0ce1946b2514/onnxruntime-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:1a1a19175464665c9b8d50bc916f216cc0b569110045b7bbca8f9f290b186f58", size = 13755033, upload-time = "2026-07-25T01:22:29.302Z" }, + { url = "https://files.pythonhosted.org/packages/f4/db/f59f715edfdd96a051f32b5ef0e680a20a8755d4ecd75f63090e960e347a/onnxruntime-1.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:cfab507abe09d6ffeb817eee07944d452fdc0b00fdcef34cab4db10a45e378c7", size = 13454175, upload-time = "2026-07-25T01:22:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/47/28/810314fa88647af9f4cdaf438a30ad1cfebebb53ded55499232d7a0094e6/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac301f53b1930402fc46c368e268acfed02f3207272aaff05070d7e09f96f031", size = 17057307, upload-time = "2026-07-25T01:21:45.492Z" }, + { url = "https://files.pythonhosted.org/packages/3d/cc/9e9f193cc0f29f263a8f09ec08487aed6c96ee856d5fd77da32a425c1949/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7f022a1103cae591c75fc4565589a515f2ddd14a6ac8e8a05812dfeda142e28", size = 19222954, upload-time = "2026-07-25T01:22:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/4e/eb/952314c451d9463e5c9aed9978eec76cf32930d407d9ab8700dd0f4ea1ea/onnxruntime-1.28.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8adff67a3f28257b37cfe945a7e952e4122666aa8c91a0380862e9fd4c2ed19f", size = 19143748, upload-time = "2026-07-25T01:21:56.297Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e9/139180b4dd810329aaa42c238b4e6383c906202d98609ae29d66eb7c32b1/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc2565e487b4896fb988d6383577d875d958e071fc5f6c3550bd5d02ae98264b", size = 17051950, upload-time = "2026-07-25T01:21:48.606Z" }, + { url = "https://files.pythonhosted.org/packages/03/88/9432428273356ad3c8aa01f52c1b3e7f53c4c0192748f41ad983872b436b/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6afdc83f1317c136e92fc29f5ee9f058de59d87c0b22cee3fdbfbaa0ccc2098a", size = 19214924, upload-time = "2026-07-25T01:22:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e2/6feb3a43517aaf2b1bf7e46897ba5eb81a29717f7d7901420614d5ee4653/onnxruntime-1.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:f2a3b9e30ce880d4ca54999cb313569e36da4f62eefe25f87be18f43e9a3a4d5", size = 14093738, upload-time = "2026-07-25T01:22:31.629Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8f/83974a1e201dc2e58e5e7111bcaeb1ca2413e9c41f505d26419ee9e3dddf/onnxruntime-1.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:07fb3cbe990d6bf0ab3c22bfbbfb0e314151266046ea6edb4a07f556b4258c5f", size = 13821117, upload-time = "2026-07-25T01:22:22.387Z" }, + { url = "https://files.pythonhosted.org/packages/0d/83/00e606bc25c756d76a267370c39b7516ad52f9cf134d7ff2bff8b6108bc4/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e562d6e36a749f6764481c0ddb0f2af3d0b5a3c164291361d08803c557f369af", size = 17055518, upload-time = "2026-07-25T01:21:51.08Z" }, + { url = "https://files.pythonhosted.org/packages/94/a9/68707e1ce345cbdbcd4df65932ebc82a673e917d63eda0007ebcff948691/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f6e92367ddce1e4d33cf295024f40192be6c6171a09208f515ba169ced06c8e", size = 19222976, upload-time = "2026-07-25T01:22:12.474Z" }, +] + +[[package]] +name = "opencv-python-headless" +version = "5.0.0.93" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/99/76b7c80252aa83c1af16393454aafd125a0287101afe8deb0a6821af0e30/opencv_python_headless-5.0.0.93.tar.gz", hash = "sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c", size = 81817738, upload-time = "2026-07-02T07:01:06.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7c/8c8097891c509d98cd128493835c95631c80be6a8f37ed9d25716c2e16f1/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:030ca5e0837a2963ab36ef896baa9767eb8d2b83353fb28af5a521e40dd8756f", size = 48322581, upload-time = "2026-07-02T05:50:34.207Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/eab2ad388c3cbab2a350c10c2ef19ce6bd099240afc31789032c996bab52/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:1e55af3abfb462eeeabe5c775f12bdb36216d8a93a3583d69e6bd6e1d6ba7d00", size = 34782894, upload-time = "2026-07-02T05:51:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/ec/78/afca939f40ffe2b2380bfa86f812b2f7d4acc5a27b27dc41b49cad7ce7b4/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10818d91510e05c04568ae12b5cd120779c70c01bf897b001a6221fe430df80f", size = 36521085, upload-time = "2026-07-02T06:55:24.429Z" }, + { url = "https://files.pythonhosted.org/packages/2b/97/8170e9819764c47e436c130d3ff6cfb73b58f923eae9d3a03d8982b04aec/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09a872a157c1376ab922a69bbf22f9a95bcc7b658a9d8b436a60212b02b2eeb4", size = 56563598, upload-time = "2026-07-02T06:55:47.355Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/1a28a7101e31801042b3098871a74b76c61581d328ef40774ff4edb53a56/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:840bd717c21e5c11cadadc022a823315ea417f961213d06b4df010e019eb16f4", size = 39648433, upload-time = "2026-07-02T06:56:04.255Z" }, + { url = "https://files.pythonhosted.org/packages/9b/21/f6ef335f6e65724aa78b8d792b48d40a48c381715f1e62f5a5049e09d07e/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ed709fdf9aa0bd1f2ed8549e71d19449b03a675bb581eb292285f6861953be37", size = 61204038, upload-time = "2026-07-02T06:56:41.823Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8f/b8756467ea991449a293797f6b3fa80fcfdd29598a0a60d1cd5715b96e61/opencv_python_headless-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:c6bcd96b185975ea240d22cfdb15a1f6d080cc95264cfbe2621f21bb144d89b9", size = 35411237, upload-time = "2026-07-02T05:50:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/b8/88/763b967f7efd7226b82c9fae16d560cba049b1f0c036647e65c610fd636e/opencv_python_headless-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e", size = 43825962, upload-time = "2026-07-02T05:50:09.627Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/09/4d717852c1cf3f854b76c7110a5d00883bc3c99288b9b0dbcbeb9e306eb6/opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac", size = 20202, upload-time = "2026-07-16T15:25:37.658Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/71/65fd9d54c10b860f87c045ccee1264cab7011268895d3528818a29c1172a/opentelemetry_exporter_otlp_proto_common-1.44.0-py3-none-any.whl", hash = "sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694", size = 17045, upload-time = "2026-07-16T15:25:18.201Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/47/80d9e9d468dc5de3af5096f5ccdb065fa4dd1470f74495cc53e59e397f47/opentelemetry_exporter_otlp_proto_grpc-1.44.0.tar.gz", hash = "sha256:40d1ae9e03fcc36de3cbac610cc99f35894938bff9cfd90fc4ec68bd85448463", size = 27225, upload-time = "2026-07-16T15:25:38.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/29/6ae42ba32b153ae0a44ae125f0caff2188bbe62d99c82d1768da30864e72/opentelemetry_exporter_otlp_proto_grpc-1.44.0-py3-none-any.whl", hash = "sha256:6a1a645ea182a2f59440c51fa8301d309f3324a8f9d65f8395584b064b67ee4e", size = 19624, upload-time = "2026-07-16T15:25:19.096Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/01/40ac4ae9a149263cc52c2cee200ddd80cb6d8db1a4610abf8eabce0fe771/opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3", size = 46488, upload-time = "2026-07-16T15:25:45.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/7c/8be563d68e93bbefa5c8affb82ddcff91b3ad858ce49957ba7b16fd3e0ab/opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56", size = 72483, upload-time = "2026-07-16T15:25:28.429Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/77/a6592cbc7c8d9bcc9d6757a9df45e04a7c585e3e6e7a13456da522b21109/opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b", size = 208624, upload-time = "2026-07-16T15:25:46.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/23/ff077e61886ee020a17ce9c8b6fa11c601c8d8345b09ea24f605445df62a/opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad", size = 137221, upload-time = "2026-07-16T15:25:29.534Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/73/0cbdebcb4cf545fdd328da14f5137e37d0770c3f26185e478b0d15d94f50/opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60", size = 148774, upload-time = "2026-07-16T15:25:46.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/0e/49df70d9b81fb5cbae4bbf2a49d865b09bcbcbc4eb53f5851b1027738d78/opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb", size = 204645, upload-time = "2026-07-16T15:25:30.688Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" }, + { url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" }, + { url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" }, + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[package]] +name = "overrides" +version = "7.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ef/f1fd7431d635bf20015489bf0bd69c17fff1018de773540f651455a3916b/pandas-3.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2946e77e4a53cd248cbde631a12f0e51c8324ce354c3eba4d20147c1ad6f4282", size = 10397178, upload-time = "2026-07-22T22:17:48.274Z" }, + { url = "https://files.pythonhosted.org/packages/31/b4/0eafac990a431561187694126de01f9b12559549b4d86360c0c4bd870fde/pandas-3.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71ecc8fb7ed1a7aa4392316b5309a6347e8e7f832f38fd897846b3a1457a9298", size = 9990736, upload-time = "2026-07-22T22:17:52.388Z" }, + { url = "https://files.pythonhosted.org/packages/de/21/359880af3ea9b7cb23bea5b51e8e70ef3866c03be09da9a2787e18e330a8/pandas-3.0.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b173f5951ff6b8b0ec7675e20dff3c97b7e7a57dfcce387c2d7c5afe87cb7899", size = 10814438, upload-time = "2026-07-22T22:17:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/d6cc4d7e508bbccf5d6027314a8312bc7ac73d0ec7f195f53838daafab40/pandas-3.0.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c0cf1dd9b55a22d105fc46c1b489af3bd42264fcba7c66297bf47a9a1d9c78a", size = 11323634, upload-time = "2026-07-22T22:17:56.858Z" }, + { url = "https://files.pythonhosted.org/packages/70/2b/d5f0a8c90dd0ae04e64ba53b871afb796ec026b615086d382ddc2ade729b/pandas-3.0.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0fac0010c75e4efb6b99e249c183a8993ce0dc95c240f9b120a5e67c727b7928", size = 11850860, upload-time = "2026-07-22T22:17:59.1Z" }, + { url = "https://files.pythonhosted.org/packages/5c/30/183aec2e19adf778a98d29b5729a0a68f4cc4ebf9b9c3b70d0297355bcb1/pandas-3.0.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:08d24fe11a17dc33bd6e937dc9c665f9cba08fbdc9f657f405713515febe300d", size = 12411100, upload-time = "2026-07-22T22:18:01.485Z" }, + { url = "https://files.pythonhosted.org/packages/fa/9a/31f4983f191af51ab2a8f2d0c7b33dff3a84da26533f982fff02c2f9e28b/pandas-3.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:b1261758dfb6cf12c3cff8300e21cefad30e7ec709abb4c24ac7318e6a52462a", size = 9968804, upload-time = "2026-07-22T22:18:03.903Z" }, + { url = "https://files.pythonhosted.org/packages/49/97/7886c89a39045c69ad82cbceaf3343810480c8ef49a216319ce8183860a6/pandas-3.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:679f4e85b30ddb1515458ab1e788d3e260eae369b1f78da7a3aa4cac8ebf4a2a", size = 9205447, upload-time = "2026-07-22T22:18:06.134Z" }, + { url = "https://files.pythonhosted.org/packages/1c/54/1dc810ea558d1320b597aa140a514f2fdf1d2ea09c38cf556f13ea712ec9/pandas-3.0.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fa290c16964d4963fbfbc358928239cf3bd755b20e988ce944877def2f44471d", size = 10411717, upload-time = "2026-07-22T22:18:08.307Z" }, + { url = "https://files.pythonhosted.org/packages/68/56/fbe81c09195924d8b7b8d4461a20458fe80a6a5ed6b24f0314da684277e1/pandas-3.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2e26bb46934b8a2ca0c3de1d3d606fc5f6746584791b2db264d58cf370e08dc", size = 9957095, upload-time = "2026-07-22T22:18:10.6Z" }, + { url = "https://files.pythonhosted.org/packages/e0/51/fac252f4a913ed5eabf3c11b880a9e8d5a6c10f0b2129d0462212d238b4d/pandas-3.0.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73fa87b08a7ef706f8aafda39ddaccf2a99047bea62d8c88a0361bcafb2237bc", size = 10485458, upload-time = "2026-07-22T22:18:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/12/98/e976540c1addf70442be7842a18cf70884a964abbf69442504f4d2939989/pandas-3.0.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d373ce03ffd84010ed9839fa73672a9c8256990532e158440c0085db7d914b34", size = 10998091, upload-time = "2026-07-22T22:18:15.209Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8c/1f29b5be8d3fc47dd7567eb167fabba2085879b31e0287ce7cba6d3d2ff4/pandas-3.0.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a29c53d85ea98c5e792c59ef82ee9fbe6ca902c0d0adb6b23f45ef894cd7bf6", size = 11499501, upload-time = "2026-07-22T22:18:17.689Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e2/bd9c98ad2df7b38bde002adde4cdf353519da51881634323b126c55997f9/pandas-3.0.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a5ad3b02ed6bc7d7ae9b70804b2c6aa31827489d150f8e623ce82491b82085d7", size = 12060559, upload-time = "2026-07-22T22:18:20.147Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9a/ffbd852d58bd74a617fe2f8ee6a58a96982271ce41cf981eab22190b4a4b/pandas-3.0.5-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:b2acb4650527eec6822c3dadb2b771277b65e7dae7a267d4bccf65fd1bb3fbce", size = 7197652, upload-time = "2026-07-22T22:18:22.502Z" }, + { url = "https://files.pythonhosted.org/packages/70/b5/d2d3e9ae73362ba4229651b0ee1455cf78073a1ce585f6ff693782ce263e/pandas-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:80a611068e8a3ac23f7398c6c14eb46dc974e5cc9997f653e2dcfd1da74edd41", size = 9831691, upload-time = "2026-07-22T22:18:24.534Z" }, + { url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796, upload-time = "2026-07-22T22:18:27.064Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/7b95c4a0025227d6f118c4039b423412ac6a982db02864166185d812fbc7/pandas-3.0.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c05a767fe8e5b4fe9e1c29806829c582052eaedb9120a3da83ba3f69e24a5b", size = 10385742, upload-time = "2026-07-22T22:18:29.346Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0c/dc78fd8c4da477b4b5e8ad37295af352190d21ef63a9ee1bc071753074cc/pandas-3.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b86765f268b56f7e665b93bce9d5df69dee7f99e595cf8fb839483ab315942a3", size = 9932067, upload-time = "2026-07-22T22:18:31.833Z" }, + { url = "https://files.pythonhosted.org/packages/3e/71/3592c055cf44df9808550f9368ceda80ff2b224d355ef73fe251dcda1802/pandas-3.0.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c597ecf5616b5c420372c1d4d4c00dbbfba7398bea857dcc984347e1ea48417b", size = 10466756, upload-time = "2026-07-22T22:18:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/e3/70/4363150359f95b4cb4bcbb34ca23572bb5495749a621a8f3d5a1ddfd293c/pandas-3.0.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b11c36e218331d0387cbe3a0a5f75162357a1d92d57b2b08a336ff94b19b2be", size = 10938525, upload-time = "2026-07-22T22:18:36.81Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d0/317e7a0c67c0e69fa905a0161409397a7dc2d46ff611f6ca4803352c042b/pandas-3.0.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cf52e1f61d229496da17dc7ab54acdee627357e7008fd4fecba3d0ba2937fa58", size = 11489303, upload-time = "2026-07-22T22:18:39.287Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8d/36dade89b49e4f9d5cbdbe863772581f98c0c6d78fc39ad4c557f6f2e17e/pandas-3.0.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db172144bb56422bd157812f3b021eacc255451470b31e2c633c349490a1cfee", size = 11989004, upload-time = "2026-07-22T22:18:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ba/18c4ec8a746e177da05a9e7a7963781d8ea195780724f854601b6ebd6b78/pandas-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:0d298e951f23016ce4699951d044ae6418dbc91bf68cefca0f77666fcbb4e5c6", size = 9826896, upload-time = "2026-07-22T22:18:44.539Z" }, + { url = "https://files.pythonhosted.org/packages/de/ec/28a57266b753799a87b8bc79e7887ac6fd981b8c6d2978a0b7e7b6bd708c/pandas-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:66266d3442a5e8b3c90274c2b8b230bee42dd1c286bc822cc2f9f2c7e12b883e", size = 9094790, upload-time = "2026-07-22T22:18:47.468Z" }, + { url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, + { url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, + { url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, + { url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, + { url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, +] + +[[package]] +name = "pooch" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "platformdirs" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/43/85ef45e8b36c6a48546af7b266592dc32d7f67837a6514d111bced6d7d75/pooch-1.9.0.tar.gz", hash = "sha256:de46729579b9857ffd3e741987a2f6d5e0e03219892c167c6578c0091fb511ed", size = 61788, upload-time = "2026-01-30T19:15:09.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl", hash = "sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b", size = 67175, upload-time = "2026-01-30T19:15:08.36Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pyarrow" +version = "24.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/c9/a47ab7ece0d86cbe6678418a0fbd1ac4bb493b9184a3891dfa0e7f287ae0/pyarrow-24.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74", size = 35068898, upload-time = "2026-04-21T10:46:36.599Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bc/8db86617a9a58008acf8913d6fed68ea2a46acb6de928db28d724c891a68/pyarrow-24.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3", size = 36679915, upload-time = "2026-04-21T10:46:42.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8e/fb178720400ef69db251eb4a9c3ccf4af269bc1feb5055529b8fc87170d1/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868", size = 45697931, upload-time = "2026-04-21T10:46:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/f3/27/99c42abe8e21b44f4917f62631f3aa31404882a2c41d8a4cd5c110e13d52/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e", size = 48837449, upload-time = "2026-04-21T10:46:55.329Z" }, + { url = "https://files.pythonhosted.org/packages/36/b6/333749e2666e9032891125bf9c691146e92901bece62030ac1430e2e7c88/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57", size = 49395949, upload-time = "2026-04-21T10:47:01.869Z" }, + { url = "https://files.pythonhosted.org/packages/17/25/c5201706a2dd374e8ba6ee3fd7a8c89fb7ffc16eed5217a91fd2bd7f7626/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c", size = 51912986, upload-time = "2026-04-21T10:47:09.872Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d2/4d1bbba65320b21a49678d6fbdc6ff7c649251359fdcfc03568c4136231d/pyarrow-24.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981", size = 27255371, upload-time = "2026-04-21T10:47:15.943Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, + { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, + { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, + { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, + { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, + { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, + { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, + { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, +] + +[[package]] +name = "pybase64" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/b8/4ed5c7ad5ec15b08d35cc79ace6145d5c1ae426e46435f4987379439dfea/pybase64-1.4.3.tar.gz", hash = "sha256:c2ed274c9e0ba9c8f9c4083cfe265e66dd679126cd9c2027965d807352f3f053", size = 137272, upload-time = "2025-12-06T13:27:04.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/63/21e981e9d3f1f123e0b0ee2130112b1956cad9752309f574862c7ae77c08/pybase64-1.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:70b0d4a4d54e216ce42c2655315378b8903933ecfa32fced453989a92b4317b2", size = 38237, upload-time = "2025-12-06T13:22:52.159Z" }, + { url = "https://files.pythonhosted.org/packages/92/fb/3f448e139516404d2a3963915cc10dc9dde7d3a67de4edba2f827adfef17/pybase64-1.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8127f110cdee7a70e576c5c9c1d4e17e92e76c191869085efbc50419f4ae3c72", size = 31673, upload-time = "2025-12-06T13:22:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/bb06a5b9885e7d853ac1e801c4d8abfdb4c8506deee33e53d55aa6690e67/pybase64-1.4.3-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f9ef0388878bc15a084bd9bf73ec1b2b4ee513d11009b1506375e10a7aae5032", size = 68331, upload-time = "2025-12-06T13:22:54.197Z" }, + { url = "https://files.pythonhosted.org/packages/64/15/8d60b9ec5e658185fc2ee3333e01a6e30d717cf677b24f47cbb3a859d13c/pybase64-1.4.3-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95a57cccf106352a72ed8bc8198f6820b16cc7d55aa3867a16dea7011ae7c218", size = 71370, upload-time = "2025-12-06T13:22:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/ac/29/a3e5c1667cc8c38d025a4636855de0fc117fc62e2afeb033a3c6f12c6a22/pybase64-1.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cd1c47dfceb9c7bd3de210fb4e65904053ed2d7c9dce6d107f041ff6fbd7e21", size = 59834, upload-time = "2025-12-06T13:22:56.682Z" }, + { url = "https://files.pythonhosted.org/packages/a9/00/8ffcf9810bd23f3984698be161cf7edba656fd639b818039a7be1d6405d4/pybase64-1.4.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:9fe9922698f3e2f72874b26890d53a051c431d942701bb3a37aae94da0b12107", size = 56652, upload-time = "2025-12-06T13:22:57.724Z" }, + { url = "https://files.pythonhosted.org/packages/81/62/379e347797cdea4ab686375945bc77ad8d039c688c0d4d0cfb09d247beb9/pybase64-1.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:af5f4bd29c86b59bb4375e0491d16ec8a67548fa99c54763aaedaf0b4b5a6632", size = 59382, upload-time = "2025-12-06T13:22:58.758Z" }, + { url = "https://files.pythonhosted.org/packages/c6/f2/9338ffe2f487086f26a2c8ca175acb3baa86fce0a756ff5670a0822bb877/pybase64-1.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c302f6ca7465262908131411226e02100f488f531bb5e64cb901aa3f439bccd9", size = 59990, upload-time = "2025-12-06T13:23:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a4/85a6142b65b4df8625b337727aa81dc199642de3d09677804141df6ee312/pybase64-1.4.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2f3f439fa4d7fde164ebbbb41968db7d66b064450ab6017c6c95cef0afa2b349", size = 54923, upload-time = "2025-12-06T13:23:02.369Z" }, + { url = "https://files.pythonhosted.org/packages/ac/00/e40215d25624012bf5b7416ca37f168cb75f6dd15acdb91ea1f2ea4dc4e7/pybase64-1.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a23c6866551043f8b681a5e1e0d59469148b2920a3b4fc42b1275f25ea4217a", size = 58664, upload-time = "2025-12-06T13:23:03.378Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/d7e19a63e795c13837f2356268d95dc79d1180e756f57ced742a1e52fdeb/pybase64-1.4.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:56e6526f8565642abc5f84338cc131ce298a8ccab696b19bdf76fa6d7dc592ef", size = 52338, upload-time = "2025-12-06T13:23:04.458Z" }, + { url = "https://files.pythonhosted.org/packages/f2/32/3c746d7a310b69bdd9df77ffc85c41b80bce00a774717596f869b0d4a20e/pybase64-1.4.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6a792a8b9d866ffa413c9687d9b611553203753987a3a582d68cbc51cf23da45", size = 68993, upload-time = "2025-12-06T13:23:05.526Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b3/63cec68f9d6f6e4c0b438d14e5f1ef536a5fe63ce14b70733ac5e31d7ab8/pybase64-1.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:62ad29a5026bb22cfcd1ca484ec34b0a5ced56ddba38ceecd9359b2818c9c4f9", size = 58055, upload-time = "2025-12-06T13:23:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/d5/cb/7acf7c3c06f9692093c07f109668725dc37fb9a3df0fa912b50add645195/pybase64-1.4.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11b9d1d2d32ec358c02214363b8fc3651f6be7dd84d880ecd597a6206a80e121", size = 54430, upload-time = "2025-12-06T13:23:07.936Z" }, + { url = "https://files.pythonhosted.org/packages/33/39/4eb33ff35d173bfff4002e184ce8907f5d0a42d958d61cd9058ef3570179/pybase64-1.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0aebaa7f238caa0a0d373616016e2040c6c879ebce3ba7ab3c59029920f13640", size = 56272, upload-time = "2025-12-06T13:23:09.253Z" }, + { url = "https://files.pythonhosted.org/packages/19/97/a76d65c375a254e65b730c6f56bf528feca91305da32eceab8bcc08591e6/pybase64-1.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e504682b20c63c2b0c000e5f98a80ea867f8d97642e042a5a39818e44ba4d599", size = 70904, upload-time = "2025-12-06T13:23:10.336Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/8338b6d3da3c265002839e92af0a80d6db88385c313c73f103dfb800c857/pybase64-1.4.3-cp311-cp311-win32.whl", hash = "sha256:e9a8b81984e3c6fb1db9e1614341b0a2d98c0033d693d90c726677db1ffa3a4c", size = 33639, upload-time = "2025-12-06T13:23:11.9Z" }, + { url = "https://files.pythonhosted.org/packages/39/dc/32efdf2f5927e5449cc341c266a1bbc5fecd5319a8807d9c5405f76e6d02/pybase64-1.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:a90a8fa16a901fabf20de824d7acce07586e6127dc2333f1de05f73b1f848319", size = 35797, upload-time = "2025-12-06T13:23:13.174Z" }, + { url = "https://files.pythonhosted.org/packages/da/59/eda4f9cb0cbce5a45f0cd06131e710674f8123a4d570772c5b9694f88559/pybase64-1.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:61d87de5bc94d143622e94390ec3e11b9c1d4644fe9be3a81068ab0f91056f59", size = 31160, upload-time = "2025-12-06T13:23:15.696Z" }, + { url = "https://files.pythonhosted.org/packages/86/a7/efcaa564f091a2af7f18a83c1c4875b1437db56ba39540451dc85d56f653/pybase64-1.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:18d85e5ab8b986bb32d8446aca6258ed80d1bafe3603c437690b352c648f5967", size = 38167, upload-time = "2025-12-06T13:23:16.821Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/c7ad35adff2d272bf2930132db2b3eea8c44bb1b1f64eb9b2b8e57cde7b4/pybase64-1.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3f5791a3491d116d0deaf4d83268f48792998519698f8751efb191eac84320e9", size = 31673, upload-time = "2025-12-06T13:23:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/43/1b/9a8cab0042b464e9a876d5c65fe5127445a2436da36fda64899b119b1a1b/pybase64-1.4.3-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f0b3f200c3e06316f6bebabd458b4e4bcd4c2ca26af7c0c766614d91968dee27", size = 68210, upload-time = "2025-12-06T13:23:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/62/f7/965b79ff391ad208b50e412b5d3205ccce372a2d27b7218ae86d5295b105/pybase64-1.4.3-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb632edfd132b3eaf90c39c89aa314beec4e946e210099b57d40311f704e11d4", size = 71599, upload-time = "2025-12-06T13:23:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/03/4b/a3b5175130b3810bbb8ccfa1edaadbd3afddb9992d877c8a1e2f274b476e/pybase64-1.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:356ef1d74648ce997f5a777cf8f1aefecc1c0b4fe6201e0ef3ec8a08170e1b54", size = 59922, upload-time = "2025-12-06T13:23:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/da/5d/c38d1572027fc601b62d7a407721688b04b4d065d60ca489912d6893e6cf/pybase64-1.4.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:c48361f90db32bacaa5518419d4eb9066ba558013aaf0c7781620279ecddaeb9", size = 56712, upload-time = "2025-12-06T13:23:22.77Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d4/4e04472fef485caa8f561d904d4d69210a8f8fc1608ea15ebd9012b92655/pybase64-1.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:702bcaa16ae02139d881aeaef5b1c8ffb4a3fae062fe601d1e3835e10310a517", size = 59300, upload-time = "2025-12-06T13:23:24.543Z" }, + { url = "https://files.pythonhosted.org/packages/86/e7/16e29721b86734b881d09b7e23dfd7c8408ad01a4f4c7525f3b1088e25ec/pybase64-1.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:53d0ffe1847b16b647c6413d34d1de08942b7724273dd57e67dcbdb10c574045", size = 60278, upload-time = "2025-12-06T13:23:25.608Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/18515f211d7c046be32070709a8efeeef8a0203de4fd7521e6b56404731b/pybase64-1.4.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9a1792e8b830a92736dae58f0c386062eb038dfe8004fb03ba33b6083d89cd43", size = 54817, upload-time = "2025-12-06T13:23:26.633Z" }, + { url = "https://files.pythonhosted.org/packages/e7/be/14e29d8e1a481dbff151324c96dd7b5d2688194bb65dc8a00ca0e1ad1e86/pybase64-1.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d468b1b1ac5ad84875a46eaa458663c3721e8be5f155ade356406848d3701f6", size = 58611, upload-time = "2025-12-06T13:23:27.684Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8a/a2588dfe24e1bbd742a554553778ab0d65fdf3d1c9a06d10b77047d142aa/pybase64-1.4.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e97b7bdbd62e71898cd542a6a9e320d9da754ff3ebd02cb802d69087ee94d468", size = 52404, upload-time = "2025-12-06T13:23:28.714Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/afcda7445bebe0cbc38cafdd7813234cdd4fc5573ff067f1abf317bb0cec/pybase64-1.4.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b33aeaa780caaa08ffda87fc584d5eab61e3d3bbb5d86ead02161dc0c20d04bc", size = 68817, upload-time = "2025-12-06T13:23:30.079Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3a/87c3201e555ed71f73e961a787241a2438c2bbb2ca8809c29ddf938a3157/pybase64-1.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c0efcf78f11cf866bed49caa7b97552bc4855a892f9cc2372abcd3ed0056f0d", size = 57854, upload-time = "2025-12-06T13:23:31.17Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7d/931c2539b31a7b375e7d595b88401eeb5bd6c5ce1059c9123f9b608aaa14/pybase64-1.4.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:66e3791f2ed725a46593f8bd2761ff37d01e2cdad065b1dceb89066f476e50c6", size = 54333, upload-time = "2025-12-06T13:23:32.422Z" }, + { url = "https://files.pythonhosted.org/packages/de/5e/537601e02cc01f27e9d75f440f1a6095b8df44fc28b1eef2cd739aea8cec/pybase64-1.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:72bb0b6bddadab26e1b069bb78e83092711a111a80a0d6b9edcb08199ad7299b", size = 56492, upload-time = "2025-12-06T13:23:33.515Z" }, + { url = "https://files.pythonhosted.org/packages/96/97/2a2e57acf8f5c9258d22aba52e71f8050e167b29ed2ee1113677c1b600c1/pybase64-1.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5b3365dbcbcdb0a294f0f50af0c0a16b27a232eddeeb0bceeefd844ef30d2a23", size = 70974, upload-time = "2025-12-06T13:23:36.27Z" }, + { url = "https://files.pythonhosted.org/packages/75/2e/a9e28941c6dab6f06e6d3f6783d3373044be9b0f9a9d3492c3d8d2260ac0/pybase64-1.4.3-cp312-cp312-win32.whl", hash = "sha256:7bca1ed3a5df53305c629ca94276966272eda33c0d71f862d2d3d043f1e1b91a", size = 33686, upload-time = "2025-12-06T13:23:37.848Z" }, + { url = "https://files.pythonhosted.org/packages/83/e3/507ab649d8c3512c258819c51d25c45d6e29d9ca33992593059e7b646a33/pybase64-1.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:9f2da8f56d9b891b18b4daf463a0640eae45a80af548ce435be86aa6eff3603b", size = 35833, upload-time = "2025-12-06T13:23:38.877Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8a/6eba66cd549a2fc74bb4425fd61b839ba0ab3022d3c401b8a8dc2cc00c7a/pybase64-1.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:0631d8a2d035de03aa9bded029b9513e1fee8ed80b7ddef6b8e9389ffc445da0", size = 31185, upload-time = "2025-12-06T13:23:39.908Z" }, + { url = "https://files.pythonhosted.org/packages/3a/50/b7170cb2c631944388fe2519507fe3835a4054a6a12a43f43781dae82be1/pybase64-1.4.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:ea4b785b0607d11950b66ce7c328f452614aefc9c6d3c9c28bae795dc7f072e1", size = 33901, upload-time = "2025-12-06T13:23:40.951Z" }, + { url = "https://files.pythonhosted.org/packages/48/8b/69f50578e49c25e0a26e3ee72c39884ff56363344b79fc3967f5af420ed6/pybase64-1.4.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:6a10b6330188c3026a8b9c10e6b9b3f2e445779cf16a4c453d51a072241c65a2", size = 40807, upload-time = "2025-12-06T13:23:42.006Z" }, + { url = "https://files.pythonhosted.org/packages/5c/8d/20b68f11adfc4c22230e034b65c71392e3e338b413bf713c8945bd2ccfb3/pybase64-1.4.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:27fdff227a0c0e182e0ba37a99109645188978b920dfb20d8b9c17eeee370d0d", size = 30932, upload-time = "2025-12-06T13:23:43.348Z" }, + { url = "https://files.pythonhosted.org/packages/f7/79/b1b550ac6bff51a4880bf6e089008b2e1ca16f2c98db5e039a08ac3ad157/pybase64-1.4.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2a8204f1fdfec5aa4184249b51296c0de95445869920c88123978304aad42df1", size = 31394, upload-time = "2025-12-06T13:23:44.317Z" }, + { url = "https://files.pythonhosted.org/packages/82/70/b5d7c5932bf64ee1ec5da859fbac981930b6a55d432a603986c7f509c838/pybase64-1.4.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:874fc2a3777de6baf6aa921a7aa73b3be98295794bea31bd80568a963be30767", size = 38078, upload-time = "2025-12-06T13:23:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/e66fe373bce717c6858427670736d54297938dad61c5907517ab4106bd90/pybase64-1.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2dc64a94a9d936b8e3449c66afabbaa521d3cc1a563d6bbaaa6ffa4535222e4b", size = 38158, upload-time = "2025-12-06T13:23:46.872Z" }, + { url = "https://files.pythonhosted.org/packages/80/a9/b806ed1dcc7aed2ea3dd4952286319e6f3a8b48615c8118f453948e01999/pybase64-1.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e48f86de1c145116ccf369a6e11720ce696c2ec02d285f440dfb57ceaa0a6cb4", size = 31672, upload-time = "2025-12-06T13:23:47.88Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c9/24b3b905cf75e23a9a4deaf203b35ffcb9f473ac0e6d8257f91a05dfce62/pybase64-1.4.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:1d45c8fe8fe82b65c36b227bb4a2cf623d9ada16bed602ce2d3e18c35285b72a", size = 68244, upload-time = "2025-12-06T13:23:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cd/d15b0c3e25e5859fab0416dc5b96d34d6bd2603c1c96a07bb2202b68ab92/pybase64-1.4.3-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ad70c26ba091d8f5167e9d4e1e86a0483a5414805cdb598a813db635bd3be8b8", size = 71620, upload-time = "2025-12-06T13:23:50.081Z" }, + { url = "https://files.pythonhosted.org/packages/0d/31/4ca953cc3dcde2b3711d6bfd70a6f4ad2ca95a483c9698076ba605f1520f/pybase64-1.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e98310b7c43145221e7194ac9fa7fffc84763c87bfc5e2f59f9f92363475bdc1", size = 59930, upload-time = "2025-12-06T13:23:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/60/55/e7f7bdcd0fd66e61dda08db158ffda5c89a306bbdaaf5a062fbe4e48f4a1/pybase64-1.4.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:398685a76034e91485a28aeebcb49e64cd663212fd697b2497ac6dfc1df5e671", size = 56425, upload-time = "2025-12-06T13:23:52.732Z" }, + { url = "https://files.pythonhosted.org/packages/cb/65/b592c7f921e51ca1aca3af5b0d201a98666d0a36b930ebb67e7c2ed27395/pybase64-1.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7e46400a6461187ccb52ed75b0045d937529e801a53a9cd770b350509f9e4d50", size = 59327, upload-time = "2025-12-06T13:23:53.856Z" }, + { url = "https://files.pythonhosted.org/packages/23/95/1613d2fb82dbb1548595ad4179f04e9a8451bfa18635efce18b631eabe3f/pybase64-1.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:1b62b9f2f291d94f5e0b76ab499790b7dcc78a009d4ceea0b0428770267484b6", size = 60294, upload-time = "2025-12-06T13:23:54.937Z" }, + { url = "https://files.pythonhosted.org/packages/9d/73/40431f37f7d1b3eab4673e7946ff1e8f5d6bd425ec257e834dae8a6fc7b0/pybase64-1.4.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:f30ceb5fa4327809dede614be586efcbc55404406d71e1f902a6fdcf322b93b2", size = 54858, upload-time = "2025-12-06T13:23:56.031Z" }, + { url = "https://files.pythonhosted.org/packages/a7/84/f6368bcaf9f743732e002a9858646fd7a54f428490d427dd6847c5cfe89e/pybase64-1.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0d5f18ed53dfa1d4cf8b39ee542fdda8e66d365940e11f1710989b3cf4a2ed66", size = 58629, upload-time = "2025-12-06T13:23:57.12Z" }, + { url = "https://files.pythonhosted.org/packages/43/75/359532f9adb49c6b546cafc65c46ed75e2ccc220d514ba81c686fbd83965/pybase64-1.4.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:119d31aa4b58b85a8ebd12b63c07681a138c08dfc2fe5383459d42238665d3eb", size = 52448, upload-time = "2025-12-06T13:23:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/92/6c/ade2ba244c3f33ed920a7ed572ad772eb0b5f14480b72d629d0c9e739a40/pybase64-1.4.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3cf0218b0e2f7988cf7d738a73b6a1d14f3be6ce249d7c0f606e768366df2cce", size = 68841, upload-time = "2025-12-06T13:23:59.886Z" }, + { url = "https://files.pythonhosted.org/packages/a0/51/b345139cd236be382f2d4d4453c21ee6299e14d2f759b668e23080f8663f/pybase64-1.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:12f4ee5e988bc5c0c1106b0d8fc37fb0508f12dab76bac1b098cb500d148da9d", size = 57910, upload-time = "2025-12-06T13:24:00.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b8/9f84bdc4f1c4f0052489396403c04be2f9266a66b70c776001eaf0d78c1f/pybase64-1.4.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:937826bc7b6b95b594a45180e81dd4d99bd4dd4814a443170e399163f7ff3fb6", size = 54335, upload-time = "2025-12-06T13:24:02.046Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c7/be63b617d284de46578a366da77ede39c8f8e815ed0d82c7c2acca560fab/pybase64-1.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:88995d1460971ef80b13e3e007afbe4b27c62db0508bc7250a2ab0a0b4b91362", size = 56486, upload-time = "2025-12-06T13:24:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/5e/96/f252c8f9abd6ded3ef1ccd3cdbb8393a33798007f761b23df8de1a2480e6/pybase64-1.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:72326fe163385ed3e1e806dd579d47fde5d8a59e51297a60fc4e6cbc1b4fc4ed", size = 70978, upload-time = "2025-12-06T13:24:04.221Z" }, + { url = "https://files.pythonhosted.org/packages/af/51/0f5714af7aeef96e30f968e4371d75ad60558aaed3579d7c6c8f1c43c18a/pybase64-1.4.3-cp313-cp313-win32.whl", hash = "sha256:b1623730c7892cf5ed0d6355e375416be6ef8d53ab9b284f50890443175c0ac3", size = 33684, upload-time = "2025-12-06T13:24:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ad/0cea830a654eb08563fb8214150ef57546ece1cc421c09035f0e6b0b5ea9/pybase64-1.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:8369887590f1646a5182ca2fb29252509da7ae31d4923dbb55d3e09da8cc4749", size = 35832, upload-time = "2025-12-06T13:24:06.35Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/eec2a8214989c751bc7b4cad1860eb2c6abf466e76b77508c0f488c96a37/pybase64-1.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:860b86bca71e5f0237e2ab8b2d9c4c56681f3513b1bf3e2117290c1963488390", size = 31175, upload-time = "2025-12-06T13:24:07.419Z" }, + { url = "https://files.pythonhosted.org/packages/db/c9/e23463c1a2913686803ef76b1a5ae7e6fac868249a66e48253d17ad7232c/pybase64-1.4.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:eb51db4a9c93215135dccd1895dca078e8785c357fabd983c9f9a769f08989a9", size = 38497, upload-time = "2025-12-06T13:24:08.873Z" }, + { url = "https://files.pythonhosted.org/packages/71/83/343f446b4b7a7579bf6937d2d013d82f1a63057cf05558e391ab6039d7db/pybase64-1.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a03ef3f529d85fd46b89971dfb00c634d53598d20ad8908fb7482955c710329d", size = 32076, upload-time = "2025-12-06T13:24:09.975Z" }, + { url = "https://files.pythonhosted.org/packages/46/fc/cb64964c3b29b432f54d1bce5e7691d693e33bbf780555151969ffd95178/pybase64-1.4.3-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:2e745f2ce760c6cf04d8a72198ef892015ddb89f6ceba489e383518ecbdb13ab", size = 72317, upload-time = "2025-12-06T13:24:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b7/fab2240da6f4e1ad46f71fa56ec577613cf5df9dce2d5b4cfaa4edd0e365/pybase64-1.4.3-cp313-cp313t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fac217cd9de8581a854b0ac734c50fd1fa4b8d912396c1fc2fce7c230efe3a7", size = 75534, upload-time = "2025-12-06T13:24:12.433Z" }, + { url = "https://files.pythonhosted.org/packages/91/3b/3e2f2b6e68e3d83ddb9fa799f3548fb7449765daec9bbd005a9fbe296d7f/pybase64-1.4.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:da1ee8fa04b283873de2d6e8fa5653e827f55b86bdf1a929c5367aaeb8d26f8a", size = 65399, upload-time = "2025-12-06T13:24:13.928Z" }, + { url = "https://files.pythonhosted.org/packages/6b/08/476ac5914c3b32e0274a2524fc74f01cbf4f4af4513d054e41574eb018f6/pybase64-1.4.3-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:b0bf8e884ee822ca7b1448eeb97fa131628fe0ff42f60cae9962789bd562727f", size = 60487, upload-time = "2025-12-06T13:24:15.177Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b8/618a92915330cc9cba7880299b546a1d9dab1a21fd6c0292ee44a4fe608c/pybase64-1.4.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1bf749300382a6fd1f4f255b183146ef58f8e9cb2f44a077b3a9200dfb473a77", size = 63959, upload-time = "2025-12-06T13:24:16.854Z" }, + { url = "https://files.pythonhosted.org/packages/a5/52/af9d8d051652c3051862c442ec3861259c5cdb3fc69774bc701470bd2a59/pybase64-1.4.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:153a0e42329b92337664cfc356f2065248e6c9a1bd651bbcd6dcaf15145d3f06", size = 64874, upload-time = "2025-12-06T13:24:18.328Z" }, + { url = "https://files.pythonhosted.org/packages/e4/51/5381a7adf1f381bd184d33203692d3c57cf8ae9f250f380c3fecbdbe554b/pybase64-1.4.3-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:86ee56ac7f2184ca10217ed1c655c1a060273e233e692e9086da29d1ae1768db", size = 58572, upload-time = "2025-12-06T13:24:19.417Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f0/578ee4ffce5818017de4fdf544e066c225bc435e73eb4793cde28a689d0b/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0e71a4db76726bf830b47477e7d830a75c01b2e9b01842e787a0836b0ba741e3", size = 63636, upload-time = "2025-12-06T13:24:20.497Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ad/8ae94814bf20159ea06310b742433e53d5820aa564c9fdf65bf2d79f8799/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2ba7799ec88540acd9861b10551d24656ca3c2888ecf4dba2ee0a71544a8923f", size = 56193, upload-time = "2025-12-06T13:24:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/d1/31/6438cfcc3d3f0fa84d229fa125c243d5094e72628e525dfefadf3bcc6761/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2860299e4c74315f5951f0cf3e72ba0f201c3356c8a68f95a3ab4e620baf44e9", size = 72655, upload-time = "2025-12-06T13:24:22.673Z" }, + { url = "https://files.pythonhosted.org/packages/a3/0d/2bbc9e9c3fc12ba8a6e261482f03a544aca524f92eae0b4908c0a10ba481/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:bb06015db9151f0c66c10aae8e3603adab6b6cd7d1f7335a858161d92fc29618", size = 62471, upload-time = "2025-12-06T13:24:23.8Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0b/34d491e7f49c1dbdb322ea8da6adecda7c7cd70b6644557c6e4ca5c6f7c7/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:242512a070817272865d37c8909059f43003b81da31f616bb0c391ceadffe067", size = 58119, upload-time = "2025-12-06T13:24:24.994Z" }, + { url = "https://files.pythonhosted.org/packages/ce/17/c21d0cde2a6c766923ae388fc1f78291e1564b0d38c814b5ea8a0e5e081c/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5d8277554a12d3e3eed6180ebda62786bf9fc8d7bb1ee00244258f4a87ca8d20", size = 60791, upload-time = "2025-12-06T13:24:26.046Z" }, + { url = "https://files.pythonhosted.org/packages/92/b2/eaa67038916a48de12b16f4c384bcc1b84b7ec731b23613cb05f27673294/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f40b7ddd698fc1e13a4b64fbe405e4e0e1279e8197e37050e24154655f5f7c4e", size = 74701, upload-time = "2025-12-06T13:24:27.466Z" }, + { url = "https://files.pythonhosted.org/packages/42/10/abb7757c330bb869ebb95dab0c57edf5961ffbd6c095c8209cbbf75d117d/pybase64-1.4.3-cp313-cp313t-win32.whl", hash = "sha256:46d75c9387f354c5172582a9eaae153b53a53afeb9c19fcf764ea7038be3bd8b", size = 33965, upload-time = "2025-12-06T13:24:28.548Z" }, + { url = "https://files.pythonhosted.org/packages/63/a0/2d4e5a59188e9e6aed0903d580541aaea72dcbbab7bf50fb8b83b490b6c3/pybase64-1.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:d7344625591d281bec54e85cbfdab9e970f6219cac1570f2aa140b8c942ccb81", size = 36207, upload-time = "2025-12-06T13:24:29.646Z" }, + { url = "https://files.pythonhosted.org/packages/1f/05/95b902e8f567b4d4b41df768ccc438af618f8d111e54deaf57d2df46bd76/pybase64-1.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:28a3c60c55138e0028313f2eccd321fec3c4a0be75e57a8d3eb883730b1b0880", size = 31505, upload-time = "2025-12-06T13:24:30.687Z" }, + { url = "https://files.pythonhosted.org/packages/e4/80/4bd3dff423e5a91f667ca41982dc0b79495b90ec0c0f5d59aca513e50f8c/pybase64-1.4.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:015bb586a1ea1467f69d57427abe587469392215f59db14f1f5c39b52fdafaf5", size = 33835, upload-time = "2025-12-06T13:24:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/45/60/a94d94cc1e3057f602e0b483c9ebdaef40911d84a232647a2fe593ab77bb/pybase64-1.4.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d101e3a516f837c3dcc0e5a0b7db09582ebf99ed670865223123fb2e5839c6c0", size = 40673, upload-time = "2025-12-06T13:24:32.82Z" }, + { url = "https://files.pythonhosted.org/packages/e3/71/cf62b261d431857e8e054537a5c3c24caafa331de30daede7b2c6c558501/pybase64-1.4.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8f183ac925a48046abe047360fe3a1b28327afb35309892132fe1915d62fb282", size = 30939, upload-time = "2025-12-06T13:24:34.001Z" }, + { url = "https://files.pythonhosted.org/packages/24/3e/d12f92a3c1f7c6ab5d53c155bff9f1084ba997a37a39a4f781ccba9455f3/pybase64-1.4.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30bf3558e24dcce4da5248dcf6d73792adfcf4f504246967e9db155be4c439ad", size = 31401, upload-time = "2025-12-06T13:24:35.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3d/9c27440031fea0d05146f8b70a460feb95d8b4e3d9ca8f45c972efb4c3d3/pybase64-1.4.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a674b419de318d2ce54387dd62646731efa32b4b590907800f0bd40675c1771d", size = 38075, upload-time = "2025-12-06T13:24:36.53Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d4/6c0e0cf0efd53c254173fbcd84a3d8fcbf5e0f66622473da425becec32a5/pybase64-1.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:720104fd7303d07bac302be0ff8f7f9f126f2f45c1edb4f48fdb0ff267e69fe1", size = 38257, upload-time = "2025-12-06T13:24:38.049Z" }, + { url = "https://files.pythonhosted.org/packages/50/eb/27cb0b610d5cd70f5ad0d66c14ad21c04b8db930f7139818e8fbdc14df4d/pybase64-1.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:83f1067f73fa5afbc3efc0565cecc6ed53260eccddef2ebe43a8ce2b99ea0e0a", size = 31685, upload-time = "2025-12-06T13:24:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/db/26/b136a4b65e5c94ff06217f7726478df3f31ab1c777c2c02cf698e748183f/pybase64-1.4.3-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b51204d349a4b208287a8aa5b5422be3baa88abf6cc8ff97ccbda34919bbc857", size = 68460, upload-time = "2025-12-06T13:24:41.735Z" }, + { url = "https://files.pythonhosted.org/packages/68/6d/84ce50e7ee1ae79984d689e05a9937b2460d4efa1e5b202b46762fb9036c/pybase64-1.4.3-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30f2fd53efecbdde4bdca73a872a68dcb0d1bf8a4560c70a3e7746df973e1ef3", size = 71688, upload-time = "2025-12-06T13:24:42.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/57/6743e420416c3ff1b004041c85eb0ebd9c50e9cf05624664bfa1dc8b5625/pybase64-1.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0932b0c5cfa617091fd74f17d24549ce5de3628791998c94ba57be808078eeaf", size = 60040, upload-time = "2025-12-06T13:24:44.37Z" }, + { url = "https://files.pythonhosted.org/packages/3b/68/733324e28068a89119af2921ce548e1c607cc5c17d354690fc51c302e326/pybase64-1.4.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:acb61f5ab72bec808eb0d4ce8b87ec9f38d7d750cb89b1371c35eb8052a29f11", size = 56478, upload-time = "2025-12-06T13:24:45.815Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9e/f3f4aa8cfe3357a3cdb0535b78eb032b671519d3ecc08c58c4c6b72b5a91/pybase64-1.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:2bc2d5bc15168f5c04c53bdfe5a1e543b2155f456ed1e16d7edce9ce73842021", size = 59463, upload-time = "2025-12-06T13:24:46.938Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d1/53286038e1f0df1cf58abcf4a4a91b0f74ab44539c2547b6c31001ddd054/pybase64-1.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:8a7bc3cd23880bdca59758bcdd6f4ef0674f2393782763910a7466fab35ccb98", size = 60360, upload-time = "2025-12-06T13:24:48.039Z" }, + { url = "https://files.pythonhosted.org/packages/00/9a/5cc6ce95db2383d27ff4d790b8f8b46704d360d701ab77c4f655bcfaa6a7/pybase64-1.4.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ad15acf618880d99792d71e3905b0e2508e6e331b76a1b34212fa0f11e01ad28", size = 54999, upload-time = "2025-12-06T13:24:49.547Z" }, + { url = "https://files.pythonhosted.org/packages/64/e7/c3c1d09c3d7ae79e3aa1358c6d912d6b85f29281e47aa94fc0122a415a2f/pybase64-1.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448158d417139cb4851200e5fee62677ae51f56a865d50cda9e0d61bda91b116", size = 58736, upload-time = "2025-12-06T13:24:50.641Z" }, + { url = "https://files.pythonhosted.org/packages/db/d5/0baa08e3d8119b15b588c39f0d39fd10472f0372e3c54ca44649cbefa256/pybase64-1.4.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9058c49b5a2f3e691b9db21d37eb349e62540f9f5fc4beabf8cbe3c732bead86", size = 52298, upload-time = "2025-12-06T13:24:51.791Z" }, + { url = "https://files.pythonhosted.org/packages/00/87/fc6f11474a1de7e27cd2acbb8d0d7508bda3efa73dfe91c63f968728b2a3/pybase64-1.4.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ce561724f6522907a66303aca27dce252d363fcd85884972d348f4403ba3011a", size = 69049, upload-time = "2025-12-06T13:24:53.253Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/7fb5566f669ac18b40aa5fc1c438e24df52b843c1bdc5da47d46d4c1c630/pybase64-1.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:63316560a94ac449fe86cb8b9e0a13714c659417e92e26a5cbf085cd0a0c838d", size = 57952, upload-time = "2025-12-06T13:24:54.342Z" }, + { url = "https://files.pythonhosted.org/packages/de/cc/ceb949232dbbd3ec4ee0190d1df4361296beceee9840390a63df8bc31784/pybase64-1.4.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7ecd796f2ac0be7b73e7e4e232b8c16422014de3295d43e71d2b19fd4a4f5368", size = 54484, upload-time = "2025-12-06T13:24:55.774Z" }, + { url = "https://files.pythonhosted.org/packages/a7/69/659f3c8e6a5d7b753b9c42a4bd9c42892a0f10044e9c7351a4148d413a33/pybase64-1.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d01e102a12fb2e1ed3dc11611c2818448626637857ec3994a9cf4809dfd23477", size = 56542, upload-time = "2025-12-06T13:24:57Z" }, + { url = "https://files.pythonhosted.org/packages/85/2c/29c9e6c9c82b72025f9676f9e82eb1fd2339ad038cbcbf8b9e2ac02798fc/pybase64-1.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ebff797a93c2345f22183f454fd8607a34d75eca5a3a4a969c1c75b304cee39d", size = 71045, upload-time = "2025-12-06T13:24:58.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/84/5a3dce8d7a0040a5c0c14f0fe1311cd8db872913fa04438071b26b0dac04/pybase64-1.4.3-cp314-cp314-win32.whl", hash = "sha256:28b2a1bb0828c0595dc1ea3336305cd97ff85b01c00d81cfce4f92a95fb88f56", size = 34200, upload-time = "2025-12-06T13:24:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/57/bc/ce7427c12384adee115b347b287f8f3cf65860b824d74fe2c43e37e81c1f/pybase64-1.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:33338d3888700ff68c3dedfcd49f99bfc3b887570206130926791e26b316b029", size = 36323, upload-time = "2025-12-06T13:25:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1b/2b8ffbe9a96eef7e3f6a5a7be75995eebfb6faaedc85b6da6b233e50c778/pybase64-1.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:62725669feb5acb186458da2f9353e88ae28ef66bb9c4c8d1568b12a790dfa94", size = 31584, upload-time = "2025-12-06T13:25:02.801Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/6824c2e6fb45b8fa4e7d92e3c6805432d5edc7b855e3e8e1eedaaf6efb7c/pybase64-1.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:153fe29be038948d9372c3e77ae7d1cab44e4ba7d9aaf6f064dbeea36e45b092", size = 38601, upload-time = "2025-12-06T13:25:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e5/10d2b3a4ad3a4850be2704a2f70cd9c0cf55725c8885679872d3bc846c67/pybase64-1.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f7fe3decaa7c4a9e162327ec7bd81ce183d2b16f23c6d53b606649c6e0203e9e", size = 32078, upload-time = "2025-12-06T13:25:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/43/04/8b15c34d3c2282f1c1b0850f1113a249401b618a382646a895170bc9b5e7/pybase64-1.4.3-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:a5ae04ea114c86eb1da1f6e18d75f19e3b5ae39cb1d8d3cd87c29751a6a22780", size = 72474, upload-time = "2025-12-06T13:25:06.434Z" }, + { url = "https://files.pythonhosted.org/packages/42/00/f34b4d11278f8fdc68bc38f694a91492aa318f7c6f1bd7396197ac0f8b12/pybase64-1.4.3-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1755b3dce3a2a5c7d17ff6d4115e8bee4a1d5aeae74469db02e47c8f477147da", size = 75706, upload-time = "2025-12-06T13:25:07.636Z" }, + { url = "https://files.pythonhosted.org/packages/bb/5d/71747d4ad7fe16df4c4c852bdbdeb1f2cf35677b48d7c34d3011a7a6ad3a/pybase64-1.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb852f900e27ffc4ec1896817535a0fa19610ef8875a096b59f21d0aa42ff172", size = 65589, upload-time = "2025-12-06T13:25:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/49/b1/d1e82bd58805bb5a3a662864800bab83a83a36ba56e7e3b1706c708002a5/pybase64-1.4.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:9cf21ea8c70c61eddab3421fbfce061fac4f2fb21f7031383005a1efdb13d0b9", size = 60670, upload-time = "2025-12-06T13:25:10.04Z" }, + { url = "https://files.pythonhosted.org/packages/15/67/16c609b7a13d1d9fc87eca12ba2dce5e67f949eeaab61a41bddff843cbb0/pybase64-1.4.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:afff11b331fdc27692fc75e85ae083340a35105cea1a3c4552139e2f0e0d174f", size = 64194, upload-time = "2025-12-06T13:25:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/37bc724e42960f0106c2d33dc957dcec8f760c91a908cc6c0df7718bc1a8/pybase64-1.4.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9a5143df542c1ce5c1f423874b948c4d689b3f05ec571f8792286197a39ba02", size = 64984, upload-time = "2025-12-06T13:25:12.645Z" }, + { url = "https://files.pythonhosted.org/packages/6e/66/b2b962a6a480dd5dae3029becf03ea1a650d326e39bf1c44ea3db78bb010/pybase64-1.4.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:d62e9861019ad63624b4a7914dff155af1cc5d6d79df3be14edcaedb5fdad6f9", size = 58750, upload-time = "2025-12-06T13:25:13.848Z" }, + { url = "https://files.pythonhosted.org/packages/2b/15/9b6d711035e29b18b2e1c03d47f41396d803d06ef15b6c97f45b75f73f04/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:84cfd4d92668ef5766cc42a9c9474b88960ac2b860767e6e7be255c6fddbd34a", size = 63816, upload-time = "2025-12-06T13:25:15.356Z" }, + { url = "https://files.pythonhosted.org/packages/b4/21/e2901381ed0df62e2308380f30d9c4d87d6b74e33a84faed3478d33a7197/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:60fc025437f9a7c2cc45e0c19ed68ed08ba672be2c5575fd9d98bdd8f01dd61f", size = 56348, upload-time = "2025-12-06T13:25:16.559Z" }, + { url = "https://files.pythonhosted.org/packages/c4/16/3d788388a178a0407aa814b976fe61bfa4af6760d9aac566e59da6e4a8b4/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:edc8446196f04b71d3af76c0bd1fe0a45066ac5bffecca88adb9626ee28c266f", size = 72842, upload-time = "2025-12-06T13:25:18.055Z" }, + { url = "https://files.pythonhosted.org/packages/a6/63/c15b1f8bd47ea48a5a2d52a4ec61f037062932ea6434ab916107b58e861e/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e99f6fa6509c037794da57f906ade271f52276c956d00f748e5b118462021d48", size = 62651, upload-time = "2025-12-06T13:25:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b8/f544a2e37c778d59208966d4ef19742a0be37c12fc8149ff34483c176616/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d94020ef09f624d841aa9a3a6029df8cf65d60d7a6d5c8687579fa68bd679b65", size = 58295, upload-time = "2025-12-06T13:25:20.822Z" }, + { url = "https://files.pythonhosted.org/packages/03/99/1fae8a3b7ac181e36f6e7864a62d42d5b1f4fa7edf408c6711e28fba6b4d/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f64ce70d89942a23602dee910dec9b48e5edf94351e1b378186b74fcc00d7f66", size = 60960, upload-time = "2025-12-06T13:25:22.099Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9e/cd4c727742345ad8384569a4466f1a1428f4e5cc94d9c2ab2f53d30be3fe/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8ea99f56e45c469818b9781903be86ba4153769f007ba0655fa3b46dc332803d", size = 74863, upload-time = "2025-12-06T13:25:23.442Z" }, + { url = "https://files.pythonhosted.org/packages/28/86/a236ecfc5b494e1e922da149689f690abc84248c7c1358f5605b8c9fdd60/pybase64-1.4.3-cp314-cp314t-win32.whl", hash = "sha256:343b1901103cc72362fd1f842524e3bb24978e31aea7ff11e033af7f373f66ab", size = 34513, upload-time = "2025-12-06T13:25:24.592Z" }, + { url = "https://files.pythonhosted.org/packages/56/ce/ca8675f8d1352e245eb012bfc75429ee9cf1f21c3256b98d9a329d44bf0f/pybase64-1.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:57aff6f7f9dea6705afac9d706432049642de5b01080d3718acc23af87c5af76", size = 36702, upload-time = "2025-12-06T13:25:25.72Z" }, + { url = "https://files.pythonhosted.org/packages/3b/30/4a675864877397179b09b720ee5fcb1cf772cf7bebc831989aff0a5f79c1/pybase64-1.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e906aa08d4331e799400829e0f5e4177e76a3281e8a4bc82ba114c6b30e405c9", size = 31904, upload-time = "2025-12-06T13:25:26.826Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7c/545fd4935a0e1ddd7147f557bf8157c73eecec9cffd523382fa7af2557de/pybase64-1.4.3-graalpy311-graalpy242_311_native-macosx_10_9_x86_64.whl", hash = "sha256:d27c1dfdb0c59a5e758e7a98bd78eaca5983c22f4a811a36f4f980d245df4611", size = 38393, upload-time = "2025-12-06T13:26:19.535Z" }, + { url = "https://files.pythonhosted.org/packages/c3/ca/ae7a96be9ddc96030d4e9dffc43635d4e136b12058b387fd47eb8301b60f/pybase64-1.4.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0f1a0c51d6f159511e3431b73c25db31095ee36c394e26a4349e067c62f434e5", size = 32109, upload-time = "2025-12-06T13:26:20.72Z" }, + { url = "https://files.pythonhosted.org/packages/bf/44/d4b7adc7bf4fd5b52d8d099121760c450a52c390223806b873f0b6a2d551/pybase64-1.4.3-graalpy311-graalpy242_311_native-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a492518f3078a4e3faaef310697d21df9c6bc71908cebc8c2f6fbfa16d7d6b1f", size = 43227, upload-time = "2025-12-06T13:26:21.845Z" }, + { url = "https://files.pythonhosted.org/packages/08/86/2ba2d8734ef7939debeb52cf9952e457ba7aa226cae5c0e6dd631f9b851f/pybase64-1.4.3-graalpy311-graalpy242_311_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae1a0f47784fd16df90d8acc32011c8d5fcdd9ab392c9ec49543e5f6a9c43a4", size = 35804, upload-time = "2025-12-06T13:26:23.149Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5b/19c725dc3aaa6281f2ce3ea4c1628d154a40dd99657d1381995f8096768b/pybase64-1.4.3-graalpy311-graalpy242_311_native-win_amd64.whl", hash = "sha256:03cea70676ffbd39a1ab7930a2d24c625b416cacc9d401599b1d29415a43ab6a", size = 35880, upload-time = "2025-12-06T13:26:24.663Z" }, + { url = "https://files.pythonhosted.org/packages/17/45/92322aec1b6979e789b5710f73c59f2172bc37c8ce835305434796824b7b/pybase64-1.4.3-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:2baaa092f3475f3a9c87ac5198023918ea8b6c125f4c930752ab2cbe3cd1d520", size = 38746, upload-time = "2025-12-06T13:26:25.869Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/f1a07402870388fdfc2ecec0c718111189732f7d0f2d7fe1386e19e8fad0/pybase64-1.4.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:cde13c0764b1af07a631729f26df019070dad759981d6975527b7e8ecb465b6c", size = 32573, upload-time = "2025-12-06T13:26:27.792Z" }, + { url = "https://files.pythonhosted.org/packages/fa/8f/43c3bb11ca9bacf81cb0b7a71500bb65b2eda6d5fe07433c09b543de97f3/pybase64-1.4.3-graalpy312-graalpy250_312_native-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5c29a582b0ea3936d02bd6fe9bf674ab6059e6e45ab71c78404ab2c913224414", size = 43461, upload-time = "2025-12-06T13:26:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4c/2a5258329200be57497d3972b5308558c6de42e3749c6cc2aa1cbe34b25a/pybase64-1.4.3-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6b664758c804fa919b4f1257aa8cf68e95db76fc331de5f70bfc3a34655afe1", size = 36058, upload-time = "2025-12-06T13:26:30.092Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6d/41faa414cde66ec023b0ca8402a8f11cb61731c3dc27c082909cbbd1f929/pybase64-1.4.3-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:f7537fa22ae56a0bf51e4b0ffc075926ad91c618e1416330939f7ef366b58e3b", size = 36231, upload-time = "2025-12-06T13:26:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/160dded493c00d3376d4ad0f38a2119c5345de4a6693419ad39c3565959b/pybase64-1.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:277de6e03cc9090fb359365c686a2a3036d23aee6cd20d45d22b8c89d1247f17", size = 37939, upload-time = "2025-12-06T13:26:41.014Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b8/a0f10be8d648d6f8f26e560d6e6955efa7df0ff1e009155717454d76f601/pybase64-1.4.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ab1dd8b1ed2d1d750260ed58ab40defaa5ba83f76a30e18b9ebd5646f6247ae5", size = 31466, upload-time = "2025-12-06T13:26:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/22/832a2f9e76cdf39b52e01e40d8feeb6a04cf105494f2c3e3126d0149717f/pybase64-1.4.3-pp311-pypy311_pp73-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:bd4d2293de9fd212e294c136cec85892460b17d24e8c18a6ba18750928037750", size = 40681, upload-time = "2025-12-06T13:26:43.782Z" }, + { url = "https://files.pythonhosted.org/packages/12/d7/6610f34a8972415fab3bb4704c174a1cc477bffbc3c36e526428d0f3957d/pybase64-1.4.3-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af6d0d3a691911cc4c9a625f3ddcd3af720738c21be3d5c72de05629139d393", size = 41294, upload-time = "2025-12-06T13:26:44.936Z" }, + { url = "https://files.pythonhosted.org/packages/64/25/ed24400948a6c974ab1374a233cb7e8af0a5373cea0dd8a944627d17c34a/pybase64-1.4.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5cfc8c49a28322d82242088378f8542ce97459866ba73150b062a7073e82629d", size = 35447, upload-time = "2025-12-06T13:26:46.098Z" }, + { url = "https://files.pythonhosted.org/packages/ee/2b/e18ee7c5ee508a82897f021c1981533eca2940b5f072fc6ed0906c03a7a7/pybase64-1.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:debf737e09b8bf832ba86f5ecc3d3dbd0e3021d6cd86ba4abe962d6a5a77adb3", size = 36134, upload-time = "2025-12-06T13:26:47.35Z" }, +] + +[[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/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { 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/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { 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.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pydeck" +version = "0.9.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/c9/f71032fca47ecc09d30904d9a610234b07139f89eabc2f054b141edcc30f/pydeck-0.9.3.tar.gz", hash = "sha256:695775cbfe51f5fdffbd9735ba469987fdc5efc96bc40a0ee4808170509c78b2", size = 5900912, upload-time = "2026-07-02T23:27:08.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/34/3998411437aff304a9ed4fa37a6fe1ef3132bcd2b5eac59851b80c86123c/pydeck-0.9.3-py2.py3-none-any.whl", hash = "sha256:d8a47c11c81fb12d51b1feb42427ff4f0e13cb599e48931021b2cba98b6849a6", size = 11428091, upload-time = "2026-07-02T23:27:06.399Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pypika" +version = "0.51.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/78/cbaebba88e05e2dcda13ca203131b38d3640219f20ebb49676d26714861b/pypika-0.51.1.tar.gz", hash = "sha256:c30c7c1048fbf056fd3920c5a2b88b0c29dd190a9b2bee971fd17e4abe4d0ebe", size = 80919, upload-time = "2026-02-04T11:27:48.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/83/c77dfeed04022e8930b08eedca2b6e5efed256ab3321396fde90066efb65/pypika-0.51.1-py2.py3-none-any.whl", hash = "sha256:77985b4d7ce71b9905255bf12468cf598349e98837c037541cfc240e528aec46", size = 60585, upload-time = "2026-02-04T11:27:46.251Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + +[[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.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regex" +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440, upload-time = "2026-07-19T00:19:48.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/e5/cef4de2bac939280b68d32adc659478845238a8274f2f79c465063f590ad/regex-2026.7.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ac777001cdfc28b72477d93c8564bb7583081ea8fb45cdca3d568e0a4f87183c", size = 494012, upload-time = "2026-07-19T00:16:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/ff/87/e86f51eb117457bb7803132ffe5cb6e2841e2b5bea4cc85d397f3c6e257d/regex-2026.7.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:59787bd5f8c70aa339084e961d2996b53fbdeab4d5393bba5c1fe1fc32e02bae", size = 295281, upload-time = "2026-07-19T00:16:41.433Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/2360c41d8080a3d9ec7e5c90fad6eab3b50192869d10e9a5609e48c8177b/regex-2026.7.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:90c633e7e8d6bf4e992b8b36ce69e018f834b641dd6de8cea6d78c06ffa119c5", size = 290615, upload-time = "2026-07-19T00:16:43.058Z" }, + { url = "https://files.pythonhosted.org/packages/cf/69/b65ba4344efbc771b28fe5dde84cbbb6c8f9551165952fe78def5b9dde6a/regex-2026.7.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ccab0db8d5f4fbb0272642113c1adb2ffc698c16d3a0944580222331fa7a20", size = 791804, upload-time = "2026-07-19T00:16:44.662Z" }, + { url = "https://files.pythonhosted.org/packages/81/b6/a40dfa0dc6224b36f620c00296eacc830489cbf8c2837b6750dfe6170375/regex-2026.7.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e50d748a32da622f256e8d505867f5d3c43a837c6a9f0efb149655fadd1042a", size = 861723, upload-time = "2026-07-19T00:16:46.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/02/735991dee71abd83196a7962f7ed8bf5aa05720ff06e2d3ff896a85e2bbb/regex-2026.7.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf1516fe58fc104f39b2d1dbe2d5e27d0cd45c4be2e42ba6ee0cc763701ec3c7", size = 905932, upload-time = "2026-07-19T00:16:47.956Z" }, + { url = "https://files.pythonhosted.org/packages/45/6c/e7098d8b846ccdbf431d8c081b61e496526a27a28094ed09e0dce21b3f54/regex-2026.7.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09f3e5287f94f17b709dc9a9e70865855feee835c861613be144218ce4ca82cc", size = 801407, upload-time = "2026-07-19T00:16:49.43Z" }, + { url = "https://files.pythonhosted.org/packages/8a/18/34b69274e2649bcc7d9b089c2b2983fb2632d8ecf667e359593be9072e79/regex-2026.7.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6383cd2ed53a646c659ba1fe65727db76437fdaa069e697a0b44a51d5843d864", size = 774448, upload-time = "2026-07-19T00:16:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e6/0a72247d025585fd3800b98e040b84d562a88af6303347100484849f4f01/regex-2026.7.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:09d3007fc76249a83cdd33de160d50e6cb77f54e09d8fa9e7148e10607ce24af", size = 783297, upload-time = "2026-07-19T00:16:53.071Z" }, + { url = "https://files.pythonhosted.org/packages/b1/aa/c4f65ae7dd02a36b323a70c4cff326e1f3442361aaebc9311100a130d54f/regex-2026.7.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f8c6e7a1cfa3dc9d0ee2de0e65e834537fa29992cc3976ffec914afc35c5dd5", size = 854736, upload-time = "2026-07-19T00:16:54.607Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/668082bcc817b9e694189b84997aeba7385b7779faa6711788679c482e35/regex-2026.7.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b2ea4a3e8357be8849e833beeae757ac3c7a6b3fc055c03c808a53c91ad30d82", size = 763298, upload-time = "2026-07-19T00:16:56.289Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fb/2d07ad555e7af88aa5f867fdafa47a8d945ee237c20af3ebceb46a820835/regex-2026.7.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:80115dd39481fd3a4b4080220799dbcacb921a844de4b827264ececacbe17c78", size = 844430, upload-time = "2026-07-19T00:16:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/51/15/c82a471fe3dce56f03745635b43aa456c40dc0db089e07ef148b331507d1/regex-2026.7.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6ce43a0269d68cee79a7d1ade7def53c20f8f2a047b92d7b5d5bcc73ae88327", size = 789683, upload-time = "2026-07-19T00:16:59.583Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/7532a2c59d56f5398902c20de60f0c9a5d1cd364e42a051b48e1b210be7b/regex-2026.7.19-cp311-cp311-win32.whl", hash = "sha256:9be2a6647740dd3cca6acb24e87f03d7632cd280dbce9bbe40c26353a215a45d", size = 266778, upload-time = "2026-07-19T00:17:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/cf1bc631db154eb95520d9d5dbc2371ff77a0f014bbf7d748fed8496aa63/regex-2026.7.19-cp311-cp311-win_amd64.whl", hash = "sha256:8d3469c91dd92ee41b7c95280edbd975ef1ba9195086686623a1c6e8935ce965", size = 277983, upload-time = "2026-07-19T00:17:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/8d/bd/56ceaf170e875d5a6761bf2bfd0d040f1cacc896850d5e40cb29b11bbd06/regex-2026.7.19-cp311-cp311-win_arm64.whl", hash = "sha256:36aacfb15faaff3ced55afbf35ec72f50d4aee22082c4f7fe0573a33e2fca92e", size = 276961, upload-time = "2026-07-19T00:17:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/d11d7e501ac8fd7d617684423ebb9561e0b998481c1e4cbc0cb212c5d74a/regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d", size = 496778, upload-time = "2026-07-19T00:17:05.677Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a9/a5ab6f312f24318019170dc485d5421fe4f89e43a98640da50d95a8a7041/regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd", size = 297122, upload-time = "2026-07-19T00:17:07.59Z" }, + { url = "https://files.pythonhosted.org/packages/b3/63/4cab4d7f2d384a144d420b763d97674cb70619c878ea6fcd7640d0e62143/regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6", size = 292009, upload-time = "2026-07-19T00:17:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/22/85/102a81b218298957d4ea7d2f084fae537a71add9d6ff93c8e67284c5f45e/regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797", size = 796708, upload-time = "2026-07-19T00:17:11.542Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/dc136af5629938a037cd2b304c12240e132ec92f38be8ff9cc89af2a1f2d/regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18", size = 865651, upload-time = "2026-07-19T00:17:13.312Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/67402ae3cd9c8c988a4c805d15ee3eef015e7ca4cb112cf3e640fc1f4153/regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511", size = 911756, upload-time = "2026-07-19T00:17:15.015Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68", size = 801798, upload-time = "2026-07-19T00:17:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f0/41/e7ecac6edb5722417f85cc67eaf386322fbe8acf6918ec2fdc37c20dd9d0/regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11", size = 776933, upload-time = "2026-07-19T00:17:19.347Z" }, + { url = "https://files.pythonhosted.org/packages/6f/69/03c9b3f058d66403e0ca2c938696e81d51cd4c6d47ec5265f02f96948d9a/regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986", size = 784338, upload-time = "2026-07-19T00:17:21.057Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f7/b38ab3d43f284afbb618fcd15d0e77eb786ae461ce1f6bc7494619ddc0f2/regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b", size = 860452, upload-time = "2026-07-19T00:17:23.119Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/ff60ef0571121714f3cf9920bc183071e384a10b556d042e0fdb06cc07a5/regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb", size = 765958, upload-time = "2026-07-19T00:17:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0f/bd34021162c0ab47f9a315bd56cd5642e920c8e5668a75ef6c6a6fca590d/regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035", size = 851765, upload-time = "2026-07-19T00:17:26.993Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/a2ca43edade0595cccfdc98636739f536d9e26898e7dbddc2b9e98898953/regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a", size = 789714, upload-time = "2026-07-19T00:17:28.699Z" }, + { url = "https://files.pythonhosted.org/packages/5d/47/e02db4015d424fc83c00ea0ac8c5e5ec14397943de9abf909d5ce3a25931/regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5", size = 267157, upload-time = "2026-07-19T00:17:31.051Z" }, + { url = "https://files.pythonhosted.org/packages/08/8e/c780c131f79b42ed22d1bd7da4096c2c35f813e835acd02ef0f018bd892c/regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312", size = 277777, upload-time = "2026-07-19T00:17:32.848Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4c/e4d7e086449bdf379d89774bf1f89dc4a41943f3c5a6125a03905b34b5fb/regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d", size = 277136, upload-time = "2026-07-19T00:17:34.803Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40", size = 496552, upload-time = "2026-07-19T00:17:36.808Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38", size = 296983, upload-time = "2026-07-19T00:17:38.816Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11", size = 291832, upload-time = "2026-07-19T00:17:40.726Z" }, + { url = "https://files.pythonhosted.org/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13", size = 796775, upload-time = "2026-07-19T00:17:42.382Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae", size = 865687, upload-time = "2026-07-19T00:17:44.185Z" }, + { url = "https://files.pythonhosted.org/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da", size = 911962, upload-time = "2026-07-19T00:17:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15", size = 801817, upload-time = "2026-07-19T00:17:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f", size = 776908, upload-time = "2026-07-19T00:17:50.183Z" }, + { url = "https://files.pythonhosted.org/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939", size = 784426, upload-time = "2026-07-19T00:17:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96", size = 860600, upload-time = "2026-07-19T00:17:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220", size = 765950, upload-time = "2026-07-19T00:17:56.041Z" }, + { url = "https://files.pythonhosted.org/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc", size = 851794, upload-time = "2026-07-19T00:17:57.897Z" }, + { url = "https://files.pythonhosted.org/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2", size = 789845, upload-time = "2026-07-19T00:17:59.97Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404", size = 267135, upload-time = "2026-07-19T00:18:01.958Z" }, + { url = "https://files.pythonhosted.org/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e", size = 277747, upload-time = "2026-07-19T00:18:04.121Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8", size = 277129, upload-time = "2026-07-19T00:18:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2", size = 501134, upload-time = "2026-07-19T00:18:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda", size = 299418, upload-time = "2026-07-19T00:18:09.38Z" }, + { url = "https://files.pythonhosted.org/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff", size = 294486, upload-time = "2026-07-19T00:18:11.188Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1", size = 811643, upload-time = "2026-07-19T00:18:12.975Z" }, + { url = "https://files.pythonhosted.org/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf", size = 871081, upload-time = "2026-07-19T00:18:14.733Z" }, + { url = "https://files.pythonhosted.org/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732", size = 917372, upload-time = "2026-07-19T00:18:16.724Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a", size = 816089, upload-time = "2026-07-19T00:18:18.617Z" }, + { url = "https://files.pythonhosted.org/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba", size = 785206, upload-time = "2026-07-19T00:18:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc", size = 800431, upload-time = "2026-07-19T00:18:22.716Z" }, + { url = "https://files.pythonhosted.org/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62", size = 864906, upload-time = "2026-07-19T00:18:24.772Z" }, + { url = "https://files.pythonhosted.org/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1", size = 773559, upload-time = "2026-07-19T00:18:27.008Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e", size = 857739, upload-time = "2026-07-19T00:18:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0", size = 804522, upload-time = "2026-07-19T00:18:31.362Z" }, + { url = "https://files.pythonhosted.org/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4", size = 269141, upload-time = "2026-07-19T00:18:33.479Z" }, + { url = "https://files.pythonhosted.org/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974", size = 280036, upload-time = "2026-07-19T00:18:35.419Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d", size = 279394, upload-time = "2026-07-19T00:18:37.454Z" }, + { url = "https://files.pythonhosted.org/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd", size = 496750, upload-time = "2026-07-19T00:18:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac", size = 297093, upload-time = "2026-07-19T00:18:41.583Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5", size = 292043, upload-time = "2026-07-19T00:18:43.347Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3", size = 797214, upload-time = "2026-07-19T00:18:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053", size = 866433, upload-time = "2026-07-19T00:18:47.315Z" }, + { url = "https://files.pythonhosted.org/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b", size = 911360, upload-time = "2026-07-19T00:18:49.588Z" }, + { url = "https://files.pythonhosted.org/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a", size = 801275, upload-time = "2026-07-19T00:18:51.767Z" }, + { url = "https://files.pythonhosted.org/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1", size = 777131, upload-time = "2026-07-19T00:18:54.053Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e", size = 785020, upload-time = "2026-07-19T00:18:56.579Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12", size = 861263, upload-time = "2026-07-19T00:18:58.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2", size = 766199, upload-time = "2026-07-19T00:19:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97", size = 851317, upload-time = "2026-07-19T00:19:03.015Z" }, + { url = "https://files.pythonhosted.org/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4", size = 789557, upload-time = "2026-07-19T00:19:05.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa", size = 272531, upload-time = "2026-07-19T00:19:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac", size = 280831, upload-time = "2026-07-19T00:19:09.46Z" }, + { url = "https://files.pythonhosted.org/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459", size = 281099, upload-time = "2026-07-19T00:19:11.398Z" }, + { url = "https://files.pythonhosted.org/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3", size = 501121, upload-time = "2026-07-19T00:19:13.425Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518", size = 299415, upload-time = "2026-07-19T00:19:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9", size = 294483, upload-time = "2026-07-19T00:19:17.879Z" }, + { url = "https://files.pythonhosted.org/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435", size = 811833, upload-time = "2026-07-19T00:19:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0", size = 871270, upload-time = "2026-07-19T00:19:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a", size = 917534, upload-time = "2026-07-19T00:19:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276", size = 816135, upload-time = "2026-07-19T00:19:26.919Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c", size = 785492, upload-time = "2026-07-19T00:19:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a", size = 800658, upload-time = "2026-07-19T00:19:31.392Z" }, + { url = "https://files.pythonhosted.org/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009", size = 865073, upload-time = "2026-07-19T00:19:33.485Z" }, + { url = "https://files.pythonhosted.org/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218", size = 773684, upload-time = "2026-07-19T00:19:35.599Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966", size = 857769, upload-time = "2026-07-19T00:19:37.738Z" }, + { url = "https://files.pythonhosted.org/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44", size = 804546, upload-time = "2026-07-19T00:19:40.229Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78", size = 274526, upload-time = "2026-07-19T00:19:42.398Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2", size = 283763, upload-time = "2026-07-19T00:19:44.644Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547", size = 283451, upload-time = "2026-07-19T00:19:46.639Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "safetensors" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846, upload-time = "2026-06-09T07:52:25.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0", size = 473568, upload-time = "2026-06-09T07:52:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562, upload-time = "2026-06-09T07:52:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235", size = 502844, upload-time = "2026-06-09T07:52:07.624Z" }, + { url = "https://files.pythonhosted.org/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0", size = 511823, upload-time = "2026-06-09T07:52:09.565Z" }, + { url = "https://files.pythonhosted.org/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98", size = 633461, upload-time = "2026-06-09T07:52:11.128Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358", size = 545148, upload-time = "2026-06-09T07:52:13.603Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774", size = 516040, upload-time = "2026-06-09T07:52:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78", size = 513832, upload-time = "2026-06-09T07:52:12.37Z" }, + { url = "https://files.pythonhosted.org/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca", size = 559930, upload-time = "2026-06-09T07:52:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4", size = 678670, upload-time = "2026-06-09T07:52:20.032Z" }, + { url = "https://files.pythonhosted.org/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc", size = 786679, upload-time = "2026-06-09T07:52:21.322Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d", size = 765683, upload-time = "2026-06-09T07:52:22.594Z" }, + { url = "https://files.pythonhosted.org/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846", size = 722361, upload-time = "2026-06-09T07:52:23.924Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d", size = 342401, upload-time = "2026-06-09T07:52:28.895Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f", size = 355540, upload-time = "2026-06-09T07:52:27.832Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/e844fd9586e66540a15b71924d17a6cbc1bb749e81ddd0a796bcdba4c055/scikit_learn-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9db6f4d34e68c8899e4cab27fdf8eafe6ed21f2ba52ceb25ea250cd237f8e47b", size = 8789686, upload-time = "2026-06-02T11:53:05.439Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/ff880f62677a17d035817d543cb0fc8727d01eccbee81c5f7fc733a9d856/scikit_learn-1.9.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f401448645a3e7bc115aa3c094097865155b34bff1cba8101857d9104e99074c", size = 8256782, upload-time = "2026-06-02T11:53:08.904Z" }, + { url = "https://files.pythonhosted.org/packages/25/64/eb40435e1a508ab1b4e284ce43ae80f6a162e5be5e38ed5a6fab467a9ea4/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd3a8ef0c758555a3b23c03adaa858af32f7736785ded50ad5991f59c4ed03fa", size = 8992419, upload-time = "2026-06-02T11:53:11.551Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/4810a28e473185429e45a57eebcc91fc991b33d889cc0676063e671db03d/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7e254636164090da847715a27f8e5478feb98c40a9e0ee90cbd277de9e5ceb8", size = 9281411, upload-time = "2026-06-02T11:53:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/be3d369f40d8178ba3bd86635d132e08cb5329b023e4669d9426d84bc007/scikit_learn-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:5dc1818c77575d149e25fce9ef82dd7b7263ae372f03494158668ad632a69759", size = 8272736, upload-time = "2026-06-02T11:53:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/37/79/a733f02dc2118da7e77a134b34f39f40201a353311b011d20859d2db3556/scikit_learn-1.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:366652351f092b219c248f1e72821e841960a63d8f358f1dcfd54dc1cbdbbc28", size = 7919564, upload-time = "2026-06-02T11:53:21.2Z" }, + { url = "https://files.pythonhosted.org/packages/ac/20/75f915ff375d6249e6550ac740fdbbd66159a068fd3af1400ff62036b07a/scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac", size = 8741122, upload-time = "2026-06-02T11:53:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d5/2b5148f2279196775e1db2aeb85d14b70ac80e7e32b3b28e7ebeafb0901d/scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1", size = 8261512, upload-time = "2026-06-02T11:53:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ee/5adbc77656b71f9456a2f5a7a9fdb4bcf9207a6b962889f1c2f9323afa4e/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f", size = 8837603, upload-time = "2026-06-02T11:53:30.328Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c2/63fdda36c56437eeb44aaf9493c8bcd62ce230ab1598924fc626ffbfa943/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8", size = 9132097, upload-time = "2026-06-02T11:53:33.456Z" }, + { url = "https://files.pythonhosted.org/packages/83/a4/c8e67227c680e2259c8864ae72ff48b06e16a6f51253a22167aa02a8aa4e/scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283", size = 8211173, upload-time = "2026-06-02T11:53:36.602Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fd/3c0863792e98e67e9184aa4029288a175935eb65443afcd30d4f143450cf/scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60", size = 7867451, upload-time = "2026-06-02T11:53:39.075Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/c9a35cf59b20a86fec24d306f1547b78dec194b08d367ce2a3e4854169d9/scikit_learn-1.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162", size = 8713289, upload-time = "2026-06-02T11:53:58.788Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a7/552a7821597c632b907f7bfe8f36f9f572777af8ef8a48353041cf8e091a/scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2", size = 8245141, upload-time = "2026-06-02T11:54:01.694Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/f4a0c4fe9711154cddabf913471153af79056382ddc612cfe5ee0ff4b72e/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913", size = 8847671, upload-time = "2026-06-02T11:54:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/f0/af/4d72d9e475ac83719160c662619e4bf7b95c19507cd582e7d0167a3c3dae/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb", size = 9118104, upload-time = "2026-06-02T11:54:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d5/6a58eea2cb9abbb9b3f2bb8b2cfb3243d1152d69f442d256c7af71304769/scikit_learn-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673", size = 8290674, upload-time = "2026-06-02T11:54:10.087Z" }, + { url = "https://files.pythonhosted.org/packages/65/5b/d4c879cf358f1187141cf90ced473f087183489090244f50c124a2ee478b/scikit_learn-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42", size = 7978807, upload-time = "2026-06-02T11:54:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/8a/43/bfae3121ec67ae09150d453c442c7c1cc166e9aefe056e6ab3b7728a5cfc/scikit_learn-1.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949", size = 9031941, upload-time = "2026-06-02T11:54:15.436Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/20a4546eb17f3b25d3c66df15810411c14ed5065bcfab50b53c96fb627b2/scikit_learn-1.9.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96", size = 8613528, upload-time = "2026-06-02T11:54:18.842Z" }, + { url = "https://files.pythonhosted.org/packages/18/3c/e440e039bb82cd19004edaaad00acbde0fb9b461083c3ecf37941c557312/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b", size = 8855050, upload-time = "2026-06-02T11:54:21.699Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/b341b8dab5998da6270a3a42c2152c578501354d36f944b5856757035ef8/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a", size = 9097190, upload-time = "2026-06-02T11:54:24.454Z" }, + { url = "https://files.pythonhosted.org/packages/fb/de/b650b4d69b84468cfa2e28a3ff7b8103743029e6446ce1a97fe060ef688c/scikit_learn-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666", size = 8963204, upload-time = "2026-06-02T11:54:27.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/ff83d76d7418112e5a61326443cdda87be3545dd8d6599c95b2481a4419e/scikit_learn-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa", size = 8222661, upload-time = "2026-06-02T11:54:30.192Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "(python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')", + "python_full_version < '3.12' and sys_platform != 'linux' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "(python_full_version >= '3.14' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform == 'win32')", + "(python_full_version == '3.13.*' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform == 'win32')", + "(python_full_version == '3.12.*' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform == 'win32')", + "python_full_version >= '3.14' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'linux' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +] + +[[package]] +name = "sentence-transformers" +version = "5.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "scikit-learn" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "torch", version = "2.13.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tqdm" }, + { name = "transformers" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/80/573ab31b77bdfa8f18051188adff3405e928386287cd6f756eff5777dd82/sentence_transformers-5.6.1.tar.gz", hash = "sha256:16af5d682ef66672b076d58599a23905800e850ec2bfb1865938306bf684ad72", size = 452185, upload-time = "2026-07-23T14:40:41.589Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ad/8f73f512dc7ad4031d2b64cbb67f70bdfb355756afbe0db610a5146415c1/sentence_transformers-5.6.1-py3-none-any.whl", hash = "sha256:cefbb17b6325a982a4732c8c49fb013375392687049d1de3d435c4b04060680b", size = 596677, upload-time = "2026-07-23T14:40:40.312Z" }, +] + +[[package]] +name = "setuptools" +version = "83.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, +] + +[[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 = "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 = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + +[[package]] +name = "srt" +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/b7/4a1bc231e0681ebf339337b0cd05b91dc6a0d701fa852bb812e244b7a030/srt-3.5.3.tar.gz", hash = "sha256:4884315043a4f0740fd1f878ed6caa376ac06d70e135f306a6dc44632eed0cc0", size = 28296, upload-time = "2023-03-28T02:35:44.007Z" } + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "streamlit" +version = "1.60.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altair" }, + { name = "anyio" }, + { name = "blinker" }, + { name = "click" }, + { name = "gitpython" }, + { name = "httptools" }, + { name = "itsdangerous" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "pyarrow" }, + { name = "pydeck" }, + { name = "python-multipart" }, + { name = "requests" }, + { name = "starlette" }, + { name = "tenacity" }, + { name = "toml" }, + { name = "typing-extensions" }, + { name = "uvicorn" }, + { name = "watchdog", marker = "sys_platform != 'darwin'" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/46/fa437e9651af1dbe7e5b28a754fd9c36df656e4a14a2b5ecc1bcd1d90587/streamlit-1.60.0.tar.gz", hash = "sha256:09f2fdd2aabbd3b3560953795681dd675d5479ac82c21fc2dceadaf9bd484ea3", size = 9819884, upload-time = "2026-07-21T21:13:58.04Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/69/7a1984df015d01875c9ac79bfda7c31492cc94f81a2625303ce4707188e5/streamlit-1.60.0-py3-none-any.whl", hash = "sha256:d167d67cf94537600a6e378d8aa0c31eabab26beb243e2a51de9842e2872e393", size = 10419153, upload-time = "2026-07-21T21:13:55.113Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[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 = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + +[[package]] +name = "torch" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform != 'linux' and sys_platform != 'win32'", +] +dependencies = [ + { name = "filelock", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "fsspec", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "jinja2", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "networkx", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "setuptools", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "sympy", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "typing-extensions", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/fe/cba54dc58523434919b66f13a667e36e436deddd77ca519e96553617d4ec/torch-2.13.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e76f9bcecc52b8ff711239a2f7547d5353df95878ab232f0773c1d95928b92f8", size = 111187938, upload-time = "2026-07-08T16:05:17.065Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3a/ed0f4d4d1dcde03bced7aac9a28e800abcdc0cbd06b6775044c9fbd877b7/torch-2.13.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2fe228aba290d14b9f31b049be550dbd469c3fd3013d7a19705b30454da97027", size = 111213045, upload-time = "2026-07-08T16:05:22.997Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fa/c1c10b7aff4a9a3e8956d4f0a5f468fa6db7abc3208805719076772b4833/torch-2.13.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:33449899ce5496c1b84b4853179d94fd102028ae1407314d9fb956bb79e70d09", size = 111213743, upload-time = "2026-07-08T16:03:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/6dcc7f0c07052102dd36f83cbc5800842a909c8c3fbf1a7f8a5844954de9/torch-2.13.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d849b390e07d8d333ce8ecaf91b273c656c598379a19c9acf1318a883f6b391c", size = 111227066, upload-time = "2026-07-08T16:03:33.6Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ce/aa8b7f9949d32e0f2f624f342bc3b48112c1b8a130288465938bc83bcbf9/torch-2.13.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c28def70706c2f9ecc752574766e8ae4da9b810ab6676b611166761a78a9f1e1", size = 111537025, upload-time = "2026-07-08T16:02:44.28Z" }, +] + +[[package]] +name = "torch" +version = "2.13.0+cpu" +source = { registry = "https://download.pytorch.org/whl/cpu" } +resolution-markers = [ + "(python_full_version >= '3.14' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform == 'win32')", + "(python_full_version == '3.13.*' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform == 'win32')", + "(python_full_version == '3.12.*' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform == 'win32')", + "(python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')", +] +dependencies = [ + { name = "filelock", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "fsspec", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jinja2", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "networkx", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "setuptools", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sympy", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-linux_s390x.whl", hash = "sha256:6e9817dbdf5ea76789babd46e457eac5bf14ff566cf85f8addbfdff2d56601ce", upload-time = "2026-07-08T19:27:52Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:84453b69508ec79902f899c5ed9495acb9e2bbe9fda5f1d5d6f19e3c3842e1a7", upload-time = "2026-07-08T19:28:03Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6746dbcbeb526eb61330b76b41ff1b4eb848951103a892eeb080dfa2b264667b", upload-time = "2026-07-08T19:28:16Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:10717d8b3b67c45a4788bf7ffc0bab1ea1e5ebbedd24466be6100102d141fac1", upload-time = "2026-07-08T19:28:27Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-win_arm64.whl", hash = "sha256:2b3d093abd919ad934c43d47e73ba63ceba7cbd7269fc2e9c1e4fc29e8fe45fa", upload-time = "2026-07-08T19:28:33Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:ffadde149901c8afa138daa38d898264003cfcf1a3336ca5cd964b5af227d867", upload-time = "2026-07-08T19:28:41Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:6f307c2c32d764ffc6ff6893b801fad6d4752f3e67966cb8abf1843427c02604", upload-time = "2026-07-08T19:28:51Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4ca4a9394b0c771238a4f73590fdbbc4debad85ed0fa63d026ae1b085da7d6e2", upload-time = "2026-07-08T19:29:03Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:a8b450c1e58e5800e5b4691dac412f8d2d65a1dc3298166f91596603a3531e6f", upload-time = "2026-07-08T19:29:15Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:fa0762705b933624d59f6823db9ce7ec2e35b3e1e9c319c9db51fbeecfc3e319", upload-time = "2026-07-08T19:29:21Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:966d020354f465672dc7dd10d3a5c6cd17d7eb48620aa1d265b48a1f78f06898", upload-time = "2026-07-08T19:29:30Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:0b8f7d0423027ae8b90c7977c627f3379f325363a08224dffad9b4b2d684a83d", upload-time = "2026-07-08T19:29:40Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3fbf9c9d1f3c10c2d59d04aca426dee9ccc6ceb32d255c61e93acc3b4f75fae6", upload-time = "2026-07-08T19:29:54Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:a17ff48608634db245e17e8bb00a9558554a49aeb1e4f5fe6cd039af2a10515b", upload-time = "2026-07-08T19:30:05Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:ac7aaf322be4777765a53bed7264a214dd81b3a1d276b93150515a3c5f75e4b0", upload-time = "2026-07-08T19:30:12Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-linux_s390x.whl", hash = "sha256:dec241fef3984c0d1edadd1f58708e218d4eae881ceef7bc10cf9964d41b68b9", upload-time = "2026-07-08T19:30:20Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:ca021f9eb2f8345c83fa03e3a04587308afb8df71bd472670b3ece00df58621c", upload-time = "2026-07-08T19:30:32Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d20fa53ee744502fa4c69818a720b05ca0d37abd055d4f6e66cae155114bc691", upload-time = "2026-07-08T19:30:45Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:e2e5134decf00e218da62318f3dc5df156231d367871918e91eba95ab0ad43ab", upload-time = "2026-07-08T19:30:58Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-linux_s390x.whl", hash = "sha256:991cc14b39e751122c01f017be6448533989868731cb5eecd1006893d26787c2", upload-time = "2026-07-08T19:31:09Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:7b8d26e29bceafbdaa8d63bfe7612f23875b5af2cc07e13f809c3ed890bbe1d8", upload-time = "2026-07-08T19:31:21Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:b222c15a0fc2ce207d1c1a59700b46c8fa6748df1f447ad11e5c870dde0933d9", upload-time = "2026-07-08T19:31:35Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:a43376bd094124ef626bfdd3d4c2c62eacb0b5ddc99776f4a32d4fd16f1f3420", upload-time = "2026-07-08T19:31:48Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, +] + +[[package]] +name = "transformers" +version = "5.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/fb/2a2ba88f325e68a921d8b69ff63b477830b2e73ade9a3c8c8cab2f06d741/transformers-5.14.1.tar.gz", hash = "sha256:60d196c27781eacf8637e2b533f517582907ad6f9ae142046d6b69431a5b2173", size = 9295927, upload-time = "2026-07-16T09:41:57.773Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/67/8d85ca2323233ae3c0365a659c4e52ee1f587b440e4bc577e7d8e4416d0f/transformers-5.14.1-py3-none-any.whl", hash = "sha256:9db974c4079ede2d1a3ea7ca5a240df33f2cc26fc2b36ba64c5f2a4f43b6e725", size = 11625234, upload-time = "2026-07-16T09:41:54.143Z" }, +] + +[[package]] +name = "typer" +version = "0.27.0" +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/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, +] + +[[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.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + +[package.optional-dependencies] +standard = [ + { 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" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "vidxp" +version = "0.2.1b1" +source = { editable = "." } +dependencies = [ + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "rich" }, + { name = "typer" }, +] + +[package.optional-dependencies] +actor = [ + { name = "chromadb" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "opencv-python-headless" }, + { name = "pooch" }, + { name = "psutil" }, +] +all = [ + { name = "chromadb" }, + { name = "faster-whisper" }, + { name = "huggingface-hub" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "opencv-python-headless" }, + { name = "pillow" }, + { name = "pooch" }, + { name = "psutil" }, + { name = "sentence-transformers" }, + { name = "torch", version = "2.13.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "transformers" }, +] +benchmarks = [ + { name = "srt" }, +] +dialogue = [ + { name = "chromadb" }, + { name = "faster-whisper" }, + { name = "huggingface-hub" }, + { name = "psutil" }, + { name = "sentence-transformers" }, +] +frontend = [ + { name = "streamlit" }, +] +local-worker = [ + { name = "chromadb" }, + { name = "faster-whisper" }, + { name = "huggingface-hub" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "opencv-python-headless" }, + { name = "pillow" }, + { name = "pooch" }, + { name = "psutil" }, + { name = "sentence-transformers" }, + { name = "torch", version = "2.13.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "transformers" }, +] +scene = [ + { name = "chromadb" }, + { name = "huggingface-hub" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "opencv-python-headless" }, + { name = "pillow" }, + { name = "psutil" }, + { name = "torch", version = "2.13.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "transformers" }, +] +server = [ + { name = "chromadb" }, + { name = "psutil" }, +] +storage = [ + { name = "chromadb" }, + { name = "psutil" }, +] + +[package.metadata] +requires-dist = [ + { name = "chromadb", marker = "extra == 'actor'", specifier = ">=1.5.9,<2" }, + { name = "chromadb", marker = "extra == 'all'", specifier = ">=1.5.9,<2" }, + { name = "chromadb", marker = "extra == 'dialogue'", specifier = ">=1.5.9,<2" }, + { name = "chromadb", marker = "extra == 'local-worker'", specifier = ">=1.5.9,<2" }, + { name = "chromadb", marker = "extra == 'scene'", specifier = ">=1.5.9,<2" }, + { name = "chromadb", marker = "extra == 'server'", specifier = ">=1.5.9,<2" }, + { name = "chromadb", marker = "extra == 'storage'", specifier = ">=1.5.9,<2" }, + { name = "faster-whisper", marker = "extra == 'all'", specifier = ">=1.2.1,<2" }, + { name = "faster-whisper", marker = "extra == 'dialogue'", specifier = ">=1.2.1,<2" }, + { name = "faster-whisper", marker = "extra == 'local-worker'", specifier = ">=1.2.1,<2" }, + { name = "filelock", specifier = ">=3.32,<4" }, + { name = "huggingface-hub", marker = "extra == 'all'", specifier = ">=1.25.1,<2" }, + { name = "huggingface-hub", marker = "extra == 'dialogue'", specifier = ">=1.25.1,<2" }, + { name = "huggingface-hub", marker = "extra == 'local-worker'", specifier = ">=1.25.1,<2" }, + { name = "huggingface-hub", marker = "extra == 'scene'", specifier = ">=1.25.1,<2" }, + { name = "numpy", marker = "extra == 'actor'", specifier = ">=2.3,<3" }, + { name = "numpy", marker = "extra == 'all'", specifier = ">=2.3,<3" }, + { name = "numpy", marker = "extra == 'local-worker'", specifier = ">=2.3,<3" }, + { name = "numpy", marker = "extra == 'scene'", specifier = ">=2.3,<3" }, + { name = "opencv-python-headless", marker = "extra == 'actor'", specifier = ">=5.0.0.93,<6" }, + { name = "opencv-python-headless", marker = "extra == 'all'", specifier = ">=5.0.0.93,<6" }, + { name = "opencv-python-headless", marker = "extra == 'local-worker'", specifier = ">=5.0.0.93,<6" }, + { name = "opencv-python-headless", marker = "extra == 'scene'", specifier = ">=5.0.0.93,<6" }, + { name = "packaging", specifier = ">=26.2,<27" }, + { name = "pillow", marker = "extra == 'all'", specifier = ">=12.3,<13" }, + { name = "pillow", marker = "extra == 'local-worker'", specifier = ">=12.3,<13" }, + { name = "pillow", marker = "extra == 'scene'", specifier = ">=12.3,<13" }, + { name = "platformdirs", specifier = ">=4.11,<5" }, + { name = "pooch", marker = "extra == 'actor'", specifier = ">=1.9,<2" }, + { name = "pooch", marker = "extra == 'all'", specifier = ">=1.9,<2" }, + { name = "pooch", marker = "extra == 'local-worker'", specifier = ">=1.9,<2" }, + { name = "psutil", marker = "extra == 'actor'", specifier = ">=7.2.2,<8" }, + { name = "psutil", marker = "extra == 'all'", specifier = ">=7.2.2,<8" }, + { name = "psutil", marker = "extra == 'dialogue'", specifier = ">=7.2.2,<8" }, + { name = "psutil", marker = "extra == 'local-worker'", specifier = ">=7.2.2,<8" }, + { name = "psutil", marker = "extra == 'scene'", specifier = ">=7.2.2,<8" }, + { name = "psutil", marker = "extra == 'server'", specifier = ">=7.2.2,<8" }, + { name = "psutil", marker = "extra == 'storage'", specifier = ">=7.2.2,<8" }, + { name = "pydantic", specifier = ">=2.13.4,<3" }, + { name = "pydantic-settings", specifier = ">=2.14.2,<3" }, + { name = "rich", specifier = ">=15,<16" }, + { name = "sentence-transformers", marker = "extra == 'all'", specifier = ">=5.6.1,<6" }, + { name = "sentence-transformers", marker = "extra == 'dialogue'", specifier = ">=5.6.1,<6" }, + { name = "sentence-transformers", marker = "extra == 'local-worker'", specifier = ">=5.6.1,<6" }, + { name = "srt", marker = "extra == 'benchmarks'", specifier = ">=3.5,<4" }, + { name = "streamlit", marker = "extra == 'frontend'", specifier = ">=1.60,<2" }, + { name = "torch", marker = "(sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all')", specifier = ">=2.13,<3", index = "https://download.pytorch.org/whl/cpu" }, + { name = "torch", marker = "(sys_platform == 'linux' and extra == 'local-worker') or (sys_platform == 'win32' and extra == 'local-worker')", specifier = ">=2.13,<3", index = "https://download.pytorch.org/whl/cpu" }, + { name = "torch", marker = "(sys_platform == 'linux' and extra == 'scene') or (sys_platform == 'win32' and extra == 'scene')", specifier = ">=2.13,<3", index = "https://download.pytorch.org/whl/cpu" }, + { name = "torch", marker = "sys_platform != 'linux' and sys_platform != 'win32' and extra == 'all'", specifier = ">=2.13,<3" }, + { name = "torch", marker = "sys_platform != 'linux' and sys_platform != 'win32' and extra == 'local-worker'", specifier = ">=2.13,<3" }, + { name = "torch", marker = "sys_platform != 'linux' and sys_platform != 'win32' and extra == 'scene'", specifier = ">=2.13,<3" }, + { name = "transformers", marker = "extra == 'all'", specifier = ">=5.14.1,<6" }, + { name = "transformers", marker = "extra == 'local-worker'", specifier = ">=5.14.1,<6" }, + { name = "transformers", marker = "extra == 'scene'", specifier = ">=5.14.1,<6" }, + { name = "typer", specifier = ">=0.27,<1" }, +] +provides-extras = ["storage", "dialogue", "scene", "actor", "all", "local-worker", "server", "frontend", "benchmarks"] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "websockets" +version = "16.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/03/47debfe28e9d6d354be5d777b67fd44c359b9eb299a5d103500bd7cc3e37/websockets-16.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c", size = 179566, upload-time = "2026-07-17T22:48:49.596Z" }, + { url = "https://files.pythonhosted.org/packages/72/93/31efa1ed78c17e5cfc229fd449e3966e1b9cc15753204cd585cc8dd01f4a/websockets-16.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a", size = 177250, upload-time = "2026-07-17T22:48:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/542378ab3972b0c1cf1df3df3eff9591cea0d30c58c3aa3c4ddbc244e787/websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22", size = 177528, upload-time = "2026-07-17T22:48:52.59Z" }, + { url = "https://files.pythonhosted.org/packages/33/d9/162321f63c7eed558e9e1798ed7a1e34a4f6dab51f35419e4ed7a4907979/websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2", size = 186859, upload-time = "2026-07-17T22:48:53.915Z" }, + { url = "https://files.pythonhosted.org/packages/de/09/87df740f7430ce564bd52402e9c9458d4d0459cc7d2ee29e530c8204851b/websockets-16.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01", size = 188095, upload-time = "2026-07-17T22:48:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/d2/12/3d2703af7cc095f3c81904c92208cc1ae79affbc67376944b50ee9301f73/websockets-16.1.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0", size = 191385, upload-time = "2026-07-17T22:48:56.742Z" }, + { url = "https://files.pythonhosted.org/packages/1d/69/986aa0234a964a00f5149cfc46e136e96c8faad1c783474550f40d31aef4/websockets-16.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29", size = 188653, upload-time = "2026-07-17T22:48:58.134Z" }, + { url = "https://files.pythonhosted.org/packages/35/6b/10f9d03e3970a69ba67bd3b46b87a929b586d0300fadbfe14f57c1f85490/websockets-16.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512", size = 187426, upload-time = "2026-07-17T22:48:59.515Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/bb3aad62bf63d8bb3f0634b2eabffcfb3677a34bd19492110ff6869cf703/websockets-16.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3", size = 184882, upload-time = "2026-07-17T22:49:00.916Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4c/c09a2ea9bfbeccce52fdc383e5f28af4bc8843338aabac28c81489af6120/websockets-16.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57", size = 187584, upload-time = "2026-07-17T22:49:02.283Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8b/31bb4eb4d9eaacf1fdd39d115772a8aeaedfc19b5dc262e57ffbc8a9d42c/websockets-16.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3", size = 186174, upload-time = "2026-07-17T22:49:03.973Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e4/dc02d725610a1ad49e193ef91a548194d71bdc6cdf27da83067dd1f73995/websockets-16.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648", size = 187986, upload-time = "2026-07-17T22:49:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/e0/73/30ed84c8bfd14c73d4af29d5ed9323c3073b48e0b7b23b67070f4e7fd59b/websockets-16.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d", size = 185565, upload-time = "2026-07-17T22:49:06.959Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d3/4be8d4959f51e31b4f8fc0ece12b45bd3b6c0d15ea23b9990d9c11fc805f/websockets-16.1.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be", size = 186598, upload-time = "2026-07-17T22:49:08.293Z" }, + { url = "https://files.pythonhosted.org/packages/26/fa/abb38597a52d84ed9cfacadc7a0c6f2db282c0ab23cdf72b58a666a21227/websockets-16.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81", size = 186834, upload-time = "2026-07-17T22:49:09.766Z" }, + { url = "https://files.pythonhosted.org/packages/59/80/1119ad08a228b90c4eb77fbe48df7836731a605f5f881ba701ca826a4a65/websockets-16.1.1-cp311-cp311-win32.whl", hash = "sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57", size = 179940, upload-time = "2026-07-17T22:49:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/71/b2/e511c1c6f64a95c2f3fc54bffda0e14eaa7e9442be605c29270f7589b918/websockets-16.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a", size = 180239, upload-time = "2026-07-17T22:49:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, + { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, + { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, + { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, + { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, + { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, + { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, + { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" }, + { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" }, + { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" }, + { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" }, + { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" }, + { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" }, + { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" }, + { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" }, + { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ed/71fea6e141590cafc40b14dc5943b0845606bee87bdb52a21b6a73eb4311/websockets-16.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869", size = 177185, upload-time = "2026-07-17T22:50:56.665Z" }, + { url = "https://files.pythonhosted.org/packages/01/ec/00e7eeca200facf9266a83e4cbbf1bed0e67fba1d4d45031d3e5b3d81b5c/websockets-16.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9", size = 177459, upload-time = "2026-07-17T22:50:58.197Z" }, + { url = "https://files.pythonhosted.org/packages/75/fd/5774c4b33f7c0d8f0c51809c8b3a93456c48e3543579262cfa64eb5f522e/websockets-16.1.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e", size = 178294, upload-time = "2026-07-17T22:50:59.641Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/48e2c03d2bd79bb45948841c592d24156312dd5f58cdf8f549febe652fb6/websockets-16.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d", size = 179190, upload-time = "2026-07-17T22:51:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3f/73e511ecf2496ceac57dd4ed8388efe2bcf0769338a2dbf242c8366ae87e/websockets-16.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5", size = 180330, upload-time = "2026-07-17T22:51:02.603Z" }, + { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/db/3cb5df059756a45761cc3dee8fd25ec82b83a6585ea3542b969fda850f99/yarl-1.24.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3", size = 135043, upload-time = "2026-07-20T02:04:52.39Z" }, + { url = "https://files.pythonhosted.org/packages/44/f8/767d6bd5a03db63bc467df2fb56d6fafeae9667d74aea92cd6af399f828b/yarl-1.24.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a", size = 96942, upload-time = "2026-07-20T02:04:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/ce/97/10b939c44d7b28d1dbc389cfc7012306d1ea8dba01eaef44b39fffaee52a/yarl-1.24.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840", size = 97046, upload-time = "2026-07-20T02:04:56.638Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7a/b410dbe39b6255c55fb2a2bcee96eb844d0789235ddc381a889a90dc72d6/yarl-1.24.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966", size = 110512, upload-time = "2026-07-20T02:04:58.955Z" }, + { url = "https://files.pythonhosted.org/packages/83/c7/da591971f78a5617e1f21f5699858ebccd836fe181a6493788ffc91ba69b/yarl-1.24.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723", size = 102454, upload-time = "2026-07-20T02:05:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8e/73b0ed4de47289a78a96045d76d1cfe5e41848bf0da59ce25b2ec87ee05d/yarl-1.24.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb", size = 117617, upload-time = "2026-07-20T02:05:02.325Z" }, + { url = "https://files.pythonhosted.org/packages/cf/14/b744747bc4f57a8d55bd744df463457524583e1e9f7538b5ace0346ab92e/yarl-1.24.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780", size = 116135, upload-time = "2026-07-20T02:05:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/66/ca/95aa4d0e5b7ea4f20e4d577c42d001ed9df207569fdb063cc5ed4ebb496b/yarl-1.24.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e", size = 111935, upload-time = "2026-07-20T02:05:05.738Z" }, + { url = "https://files.pythonhosted.org/packages/72/0d/d2ad8d6b147832d177a4e720ba1962fe686eb0913b74503b3eca094b8bba/yarl-1.24.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2", size = 110010, upload-time = "2026-07-20T02:05:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/50/18/eb335e4120903903f4865041355ae46256a2406eb2865bc24827f4f27b61/yarl-1.24.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58", size = 110058, upload-time = "2026-07-20T02:05:09.246Z" }, + { url = "https://files.pythonhosted.org/packages/44/70/97353add32c62ad6f206d948ac5a5ee84398225e534dc6ed6433d1b335b6/yarl-1.24.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61", size = 103308, upload-time = "2026-07-20T02:05:11.31Z" }, + { url = "https://files.pythonhosted.org/packages/68/39/5e7398d4b6f6b3c9062823ebc60802df5b272e3fe9e788f9734c6ee46c85/yarl-1.24.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6", size = 116898, upload-time = "2026-07-20T02:05:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/09e52f2239e8b96357eccca05915382e4ba5405ebfb623b6036040d99654/yarl-1.24.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f", size = 109400, upload-time = "2026-07-20T02:05:14.821Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6a/e94133d4c2d1a14d2384310bf3e79d9cf32c9d1eae1c6f034fb80d098fa1/yarl-1.24.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077", size = 115934, upload-time = "2026-07-20T02:05:17.78Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/34955ed967b976fc38edcbb6d538dee79dbda4cb7fc7f72a0907a7c78e0f/yarl-1.24.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd", size = 112178, upload-time = "2026-07-20T02:05:19.675Z" }, + { url = "https://files.pythonhosted.org/packages/f5/46/d7bd3a8859d47dcfaffd7127af7076032a7da278a9a02e17b5f37bfb6712/yarl-1.24.5-cp311-cp311-win_amd64.whl", hash = "sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25", size = 97544, upload-time = "2026-07-20T02:05:21.523Z" }, + { url = "https://files.pythonhosted.org/packages/01/69/c1bfd21e32c638974ea2c542a0b8c53ef1fa9eff336020f5d014f9503ff2/yarl-1.24.5-cp311-cp311-win_arm64.whl", hash = "sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a", size = 93359, upload-time = "2026-07-20T02:05:23.493Z" }, + { url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" }, + { url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" }, + { url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" }, + { url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" }, + { url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" }, + { url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" }, + { url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, + { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, + { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, + { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, + { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +] From 7602c1bd900c11852d4e077f6800d50d0a1b8f91 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 02:35:19 +0500 Subject: [PATCH 04/46] feat: add durable multi-media index snapshots --- README.md | 15 +- changes/index-snapshots.feature.md | 6 + docs/CONTRIBUTING.md | 6 +- docs/architecture/platform.md | 6 + src/vidxp/application.py | 28 +- src/vidxp/application_models.py | 34 +- src/vidxp/capabilities/actor/cli.py | 2 + src/vidxp/capabilities/actor/indexing.py | 23 +- src/vidxp/capabilities/actor/results.py | 51 +- src/vidxp/capabilities/actor/schemas.py | 1 + src/vidxp/capabilities/contracts.py | 6 +- src/vidxp/capabilities/dialogue/indexing.py | 1 + src/vidxp/capabilities/scene/indexing.py | 1 + src/vidxp/cli_commands/index.py | 49 +- src/vidxp/composition.py | 2 +- src/vidxp/core/contracts.py | 71 ++- src/vidxp/core/generations.py | 115 ++++ src/vidxp/core/identifiers.py | 40 ++ src/vidxp/core/manifest.py | 115 +++- src/vidxp/core/runner.py | 62 +- src/vidxp/core/snapshots.py | 80 +++ src/vidxp/core/storage.py | 151 ++++- src/vidxp/index_state.py | 14 - src/vidxp/infrastructure/local_index.py | 435 ++++++++++++-- src/vidxp/infrastructure/local_snapshots.py | 464 +++++++++++++++ src/vidxp/ports.py | 45 +- src/vidxp/repository_layout.py | 15 +- tests/test_actor_results.py | 46 +- tests/test_application.py | 173 ++++-- tests/test_cli.py | 35 +- tests/test_contracts.py | 20 + tests/test_frontend.py | 2 +- tests/test_generation_manifest.py | 184 ++++++ tests/test_index_state.py | 18 +- tests/test_indexing.py | 32 +- tests/test_local_snapshots.py | 623 ++++++++++++++++++++ tests/test_manifest.py | 56 ++ tests/test_runner.py | 18 +- tests/test_snapshots.py | 161 +++++ tests/test_storage.py | 155 ++++- tests/test_storage_integration.py | 82 +++ 41 files changed, 3079 insertions(+), 364 deletions(-) create mode 100644 changes/index-snapshots.feature.md create mode 100644 src/vidxp/core/generations.py create mode 100644 src/vidxp/core/identifiers.py create mode 100644 src/vidxp/core/snapshots.py create mode 100644 src/vidxp/infrastructure/local_snapshots.py create mode 100644 tests/test_generation_manifest.py create mode 100644 tests/test_local_snapshots.py create mode 100644 tests/test_snapshots.py diff --git a/README.md b/README.md index e7ee3ff..670ed7e 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,7 @@ VidXP is an evolving beta. We'd love to hear your feedback and where you'd like |---|---|---| | Search results | Top result in the CLI; structured top-k Python results | Rich ranked results, metadata, previews, and filtering across interfaces | | Temporal search | Frame and transcript-phrase timestamps | Better time ranges, scene boundaries, aggregation, and ranking | -| Video collections | One local CLI/UI index; isolated multi-video Python runs | User-facing persistent multi-video libraries and index management | +| Video collections | Persistent local multi-video snapshots with incremental re-index/remove | Media catalog, remote ingestion, and richer library management | | Actor workflows | Face clustering and highlighted video export | Cluster browsing, labeling, actor search, and stronger tracking | | Speaker context | Timestamped dialogue search | Active-speaker detection and links between speech and visible people | | Product experience | CLI and browser indexing/search | Clearer progress, result navigation, recovery, and long-running job controls | @@ -245,11 +245,14 @@ VidXP is an evolving beta. We'd love to hear your feedback and where you'd like | Actor detection | OpenCV Zoo YuNet | | Actor recognition | OpenCV Zoo SFace | -VidXP maintains the standard local CLI/UI index in `chroma_data/`. Starting a -new local indexing run replaces the previous or incomplete local index. Model -caches normally live outside this directory and outside the virtual environment. -Provider revisions and weight checksums are pinned in capability specs and -recorded in the index manifest. +VidXP maintains the standard local CLI/UI repository in `chroma_data/`. Each +successful indexing run creates an immutable generation and atomically publishes +a multi-media snapshot. Re-indexing replaces only that media item's active +generation; removing or clearing media publishes a new snapshot without deleting +the retained generations first. Failed and cancelled runs do not replace the +active snapshot. Model caches normally live outside this directory and outside +the virtual environment. Provider revisions and weight checksums are pinned in +capability specs and recorded in each generation manifest. ## Documentation and project links diff --git a/changes/index-snapshots.feature.md b/changes/index-snapshots.feature.md new file mode 100644 index 0000000..ebb414b --- /dev/null +++ b/changes/index-snapshots.feature.md @@ -0,0 +1,6 @@ +Add durable multi-media index snapshots: + +- build each media item as an immutable generation with an authoritative manifest +- atomically switch the active snapshot only after generation validation +- preserve the previous snapshot when indexing fails or is cancelled +- add CLI support for stable media IDs, incremental re-indexing, removal, and clearing diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 57273cd..c031987 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -15,7 +15,7 @@ Thanks for contributing to VidXP (Video eXPlain). | `src/vidxp/benchmarks/` | Benchmark-specific loaders, prediction adapters, and evaluator calls | | `pyproject.toml` | Package metadata and Python dependencies | | `docs/` | Installation-linked guidance, benchmark research, and contribution notes | -| `chroma_data/` | Local ChromaDB index and `index_status.json` readiness record (generated; do not commit) | +| `chroma_data/` | Local repository with immutable index generations and snapshots (generated; do not commit) | | `benchmark_runs/` | Isolated programmatic and benchmark runs (generated; do not commit) | | Model caches | Managed by the shared model runtime outside the repository | @@ -71,8 +71,8 @@ are stored, state whether an existing `chroma_data` index must be rebuilt. search. 3. If you changed the Streamlit app, smoke-test upload, indexing, cancellation, reload, and search. -4. Confirm that an incomplete local index is replaced rather than treated as - ready. +4. Confirm that a failed or incomplete generation leaves the previous active + snapshot searchable. 5. Do not commit model weights, generated indexes, benchmark runs, or local sample media. diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md index 391311f..262f68e 100644 --- a/docs/architecture/platform.md +++ b/docs/architecture/platform.md @@ -240,6 +240,7 @@ repository/ repository.json media/ indexes/ + store/ generations/ / snapshots/ @@ -375,6 +376,11 @@ by that immutable snapshot. Indexing and search therefore coexist safely. The Chroma adapter stores generation identity with every record and implements snapshot-scoped search and garbage collection. Chroma remains replaceable behind the `IndexRepository` port; snapshot semantics do not depend on Chroma collection layout. +For the embedded adapter, `indexes/store/` is the shared physical Chroma database; +generation directories own manifests and checkpoints, while exact generation record +counts in those manifests are revalidated before committed reads. A missing database, +collection, manifest, or referenced record therefore fails closed instead of creating +an empty replacement during a read. The manifest is authoritative for completed generation state. Progress files and workflow events are not used to decide whether a generation or snapshot is valid. diff --git a/src/vidxp/application.py b/src/vidxp/application.py index 987f1e1..d2f0235 100644 --- a/src/vidxp/application.py +++ b/src/vidxp/application.py @@ -19,6 +19,7 @@ IndexStatus, PrepareModelsCommand, PrepareModelsResult, + RemoveIndexCommand, ResourceNotFoundError, SearchCommand, ) @@ -231,6 +232,7 @@ def create_index( raise ResourceNotFoundError("media") self.layout.ensure_local_directories() config = IndexConfig.local( + video_id=command.media_id, enabled_modalities=selected, frame_stride=command.frame_stride, storage_directory=self.index_directory, @@ -410,33 +412,21 @@ def render_actor( @application_boundary def clear_index(self) -> bool: - if not self.index_directory.exists(): - return False base_config = self._base_config() - if self.index_backend.indexing_in_progress(base_config): - raise IndexingInProgressError( - f"Indexing is active for {self.index_directory}." - ) - status = self.index_backend.status(self.index_directory) - if status is not None and status.get("state") == "ready": - try: - config, _ = self.index_backend.active_config( - self.index_directory, - device=self.device, - ) - except (IndexSchemaError, KeyError, TypeError, ValueError): - config = base_config - else: - config = base_config try: - self.index_backend.clear(config) + return self.index_backend.clear(base_config) except ModuleNotFoundError as exc: raise DependencyUnavailableError( self.registry.index_names(), self.registry.install_hint(self.registry.index_names()), ) from exc - return True + @application_boundary + def remove_from_index(self, command: RemoveIndexCommand) -> bool: + return self.index_backend.remove( + self._base_config(), + command.media_id, + ) def _base_config(self) -> IndexConfig: return IndexConfig.local( diff --git a/src/vidxp/application_models.py b/src/vidxp/application_models.py index 0e8ec03..588fe08 100644 --- a/src/vidxp/application_models.py +++ b/src/vidxp/application_models.py @@ -2,33 +2,26 @@ from enum import StrEnum from pathlib import Path -from typing import Annotated, Any, Generic, Mapping, TypeAlias, TypeVar +from typing import Any, Generic, Mapping, TypeVar from pydantic import ( BaseModel, ConfigDict, Field, JsonValue, - StringConstraints, ) +from vidxp.core.identifiers import ( + ArtifactId as ArtifactId, + Identifier as Identifier, + IndexGenerationId as IndexGenerationId, + IndexSnapshotId as IndexSnapshotId, + JobId as JobId, + MediaId as MediaId, + RepositoryId as RepositoryId, + VideoId as VideoId, +) -Identifier: TypeAlias = Annotated[ - str, - StringConstraints( - strip_whitespace=True, - min_length=1, - max_length=255, - pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$", - ), -] -RepositoryId: TypeAlias = Identifier -MediaId: TypeAlias = Identifier -VideoId: TypeAlias = Identifier -IndexGenerationId: TypeAlias = Identifier -IndexSnapshotId: TypeAlias = Identifier -JobId: TypeAlias = Identifier -ArtifactId: TypeAlias = Identifier T = TypeVar("T") @@ -176,6 +169,7 @@ class CapabilityDependencyCheck(ApplicationModel): class CreateIndexCommand(ApplicationModel): path: Path + media_id: MediaId | None = None modalities: tuple[str, ...] frame_stride: int = Field(default=1, gt=0) capability_options: Mapping[str, Mapping[str, Any]] = Field( @@ -188,6 +182,10 @@ class IndexResult(ApplicationModel): summary: Mapping[str, Any] +class RemoveIndexCommand(ApplicationModel): + media_id: MediaId + + class IndexStatus(ApplicationModel): schema_version: int = Field(ge=1) state: str = Field(min_length=1) diff --git a/src/vidxp/capabilities/actor/cli.py b/src/vidxp/capabilities/actor/cli.py index 41fb944..b9654a5 100644 --- a/src/vidxp/capabilities/actor/cli.py +++ b/src/vidxp/capabilities/actor/cli.py @@ -62,12 +62,14 @@ def actors_list( return table = Table(title="Actor clusters") table.add_column("Cluster") + table.add_column("Media") table.add_column("Detections", justify="right") table.add_column("First", justify="right") table.add_column("Last", justify="right") for cluster in clusters: table.add_row( cluster.cluster_id, + cluster.video_id, str(cluster.detection_count), f"{cluster.first_timestamp:.3f}s", f"{cluster.last_timestamp:.3f}s", diff --git a/src/vidxp/capabilities/actor/indexing.py b/src/vidxp/capabilities/actor/indexing.py index ae83ca4..7f0abdc 100644 --- a/src/vidxp/capabilities/actor/indexing.py +++ b/src/vidxp/capabilities/actor/indexing.py @@ -36,6 +36,23 @@ def _best_face_match(known_encodings, encoding, threshold): return match if similarities[match] >= threshold else None +def _actor_cluster_id( + config: IndexConfig, + local_cluster_id: str | int, +) -> str: + if config.video_id is None: + raise ValueError( + "IndexConfig.video_id is required for actor cluster identity." + ) + return stable_source_id( + config.run_id, + config.video_id, + "actor-cluster", + local_cluster_id, + generation_id=config.generation_id, + ) + + def _actor_records( detections, config: IndexConfig, @@ -47,6 +64,7 @@ def _actor_records( str(config.video_id), "actor", detection["detection_id"], + generation_id=config.generation_id, ) top, right, bottom, left = detection["bbox"] records.append( @@ -110,7 +128,10 @@ def process_actor_samples( settings.match_threshold, ) if match is None: - cluster_id = str(len(state.known_ids) + 1) + cluster_id = _actor_cluster_id( + config, + len(state.known_ids) + 1, + ) state.known_ids.append(cluster_id) state.known_encodings.append(encoding) state.histories[cluster_id] = [encoding] diff --git a/src/vidxp/capabilities/actor/results.py b/src/vidxp/capabilities/actor/results.py index 58de7ed..d78e3cc 100644 --- a/src/vidxp/capabilities/actor/results.py +++ b/src/vidxp/capabilities/actor/results.py @@ -21,30 +21,40 @@ def actor_clusters( *, storage: IndexStore, ) -> tuple[ActorClusterSummary, ...]: - if config.video_id is None: - raise ValueError("IndexConfig.video_id is required for actor results.") - records = storage.records( - "actor", - video_id=config.video_id, - ) + records = storage.records("actor") grouped: dict[str, list[dict]] = {} for record in records: grouped.setdefault(str(record["cluster_id"]), []).append(record) - return tuple( - ActorClusterSummary( - cluster_id=cluster_id, - video_id=config.video_id, - detection_count=len(cluster_records), - first_timestamp=min( - float(record["timestamp"]) for record in cluster_records - ), - last_timestamp=max( - float(record["timestamp"]) for record in cluster_records - ), + summaries = [] + for cluster_id, cluster_records in sorted(grouped.items()): + video_ids = { + str(record["video_id"]) + for record in cluster_records + } + generation_ids = { + str(record["generation_id"]) + for record in cluster_records + if record.get("generation_id") is not None + } + if len(video_ids) != 1 or len(generation_ids) > 1: + raise RuntimeError( + f"Actor cluster {cluster_id!r} spans multiple index identities." + ) + summaries.append( + ActorClusterSummary( + cluster_id=cluster_id, + video_id=next(iter(video_ids)), + detection_count=len(cluster_records), + first_timestamp=min( + float(record["timestamp"]) for record in cluster_records + ), + last_timestamp=max( + float(record["timestamp"]) for record in cluster_records + ), + ) ) - for cluster_id, cluster_records in sorted(grouped.items()) - ) + return tuple(summaries) def actor_detections( @@ -53,11 +63,8 @@ def actor_detections( *, storage: IndexStore, ) -> list[ActorDetection]: - if config.video_id is None: - raise ValueError("IndexConfig.video_id is required for actor results.") records = storage.records( "actor", - video_id=config.video_id, filters={"cluster_id": cluster_id}, ) diff --git a/src/vidxp/capabilities/actor/schemas.py b/src/vidxp/capabilities/actor/schemas.py index 26fcea8..c8d280b 100644 --- a/src/vidxp/capabilities/actor/schemas.py +++ b/src/vidxp/capabilities/actor/schemas.py @@ -40,6 +40,7 @@ class ActorDetection(CapabilityOutput): split: str run_id: str video_id: str + generation_id: str | None = None modality: str source_id: str diff --git a/src/vidxp/capabilities/contracts.py b/src/vidxp/capabilities/contracts.py index 37cc014..b95f262 100644 --- a/src/vidxp/capabilities/contracts.py +++ b/src/vidxp/capabilities/contracts.py @@ -17,7 +17,7 @@ from vidxp.core.contracts import IndexConfig, VideoSource from vidxp.core.indexing_common import ProgressCallback from vidxp.model_contracts import ArtifactSpec, ModelSpec -from vidxp.ports import IndexStore, ModelRuntimePort +from vidxp.ports import IndexReader, ModelRuntimePort from vidxp.application_models import CapabilityProvenance @@ -92,14 +92,14 @@ class CapabilityContext(_ContractModel): config: IndexConfig | None runtime: ModelRuntimePort - storage: IndexStore | None = None + storage: IndexReader | None = None def require_config(self) -> IndexConfig: if self.config is None: raise RuntimeError("This operation requires an active index.") return self.config - def require_storage(self) -> IndexStore: + def require_storage(self) -> IndexReader: if self.storage is None: raise RuntimeError("This operation requires an active index store.") return self.storage diff --git a/src/vidxp/capabilities/dialogue/indexing.py b/src/vidxp/capabilities/dialogue/indexing.py index 976e446..59c9067 100644 --- a/src/vidxp/capabilities/dialogue/indexing.py +++ b/src/vidxp/capabilities/dialogue/indexing.py @@ -178,6 +178,7 @@ def _dialogue_records( str(config.video_id), "dialogue", f"p{phrase.phrase_id:08d}", + generation_id=config.generation_id, ) records.append( StorageRecord( diff --git a/src/vidxp/capabilities/scene/indexing.py b/src/vidxp/capabilities/scene/indexing.py index 8486d8d..d735189 100644 --- a/src/vidxp/capabilities/scene/indexing.py +++ b/src/vidxp/capabilities/scene/indexing.py @@ -57,6 +57,7 @@ def scene_records( str(config.video_id), "scene", f"f{sample.frame_index:012d}", + generation_id=config.generation_id, ) records.append( StorageRecord( diff --git a/src/vidxp/cli_commands/index.py b/src/vidxp/cli_commands/index.py index 02a1971..836c0b0 100644 --- a/src/vidxp/cli_commands/index.py +++ b/src/vidxp/cli_commands/index.py @@ -5,7 +5,7 @@ import typer -from vidxp.application_models import CreateIndexCommand +from vidxp.application_models import CreateIndexCommand, RemoveIndexCommand from vidxp.cli_support import ( CLIState, IndexProgress, @@ -26,6 +26,7 @@ def create_index( state: CLIState, path: Path, *, + media_id: str | None, modalities: Iterable[str], frame_stride: int, capability_options: dict[str, dict], @@ -37,6 +38,7 @@ def create_index( result = state.service.create_index( CreateIndexCommand( path=path, + media_id=media_id, modalities=tuple(modalities), frame_stride=frame_stride, capability_options=capability_options, @@ -76,6 +78,15 @@ def index_create( help="Modality to index; repeat to select more than one.", ), ] = None, + media_id: Annotated[ + str | None, + typer.Option( + "--media-id", + help=( + "Stable media identifier. Defaults to the input content hash." + ), + ), + ] = None, frame_stride: Annotated[ int, typer.Option( @@ -95,12 +106,13 @@ def index_create( ), ] = None, ) -> None: - """Create or replace a local index for one video.""" + """Add media or replace its immutable generation in the active index.""" state = state_from_context(ctx) create_index( state, path, + media_id=media_id, modalities=selected_modalities( modalities, state.service.registry, @@ -110,6 +122,35 @@ def index_create( ) +@app.command("remove") +def index_remove( + ctx: typer.Context, + media_id: Annotated[ + str, + typer.Argument(help="Media identifier to remove from the active index."), + ], + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """Remove one media item from the active snapshot.""" + + state = state_from_context(ctx) + removed = state.service.remove_from_index( + RemoveIndexCommand(media_id=media_id) + ) + payload = {"removed": removed, "media_id": media_id} + if effective_output_format(state, json_output) == OutputFormat.json: + emit_json(payload) + else: + typer.echo( + "Media removed." + if removed + else "The media identifier was not in the active index." + ) + + @app.command("status") def index_status( ctx: typer.Context, @@ -139,12 +180,12 @@ def index_clear( typer.Option("--json", help="Emit machine-readable JSON."), ] = False, ) -> None: - """Clear indexed records and VidXP run state.""" + """Publish an empty active snapshot without deleting retained generations.""" state = state_from_context(ctx) if not yes: typer.confirm( - f"Clear the local index at {state.service.index_directory}?", + f"Clear the active index at {state.service.index_directory}?", abort=True, ) cleared = state.service.clear_index() diff --git a/src/vidxp/composition.py b/src/vidxp/composition.py index 55d8458..5fe9596 100644 --- a/src/vidxp/composition.py +++ b/src/vidxp/composition.py @@ -56,7 +56,7 @@ def create_application( ErrorCategory.unavailable, "The requested runtime backend is unavailable.", ) from exc - backend = LocalIndexBackend(registry, runtime) + backend = LocalIndexBackend(registry, runtime, active_settings.layout) return VidXPApplication( settings=active_settings, layout=active_settings.layout, diff --git a/src/vidxp/core/contracts.py b/src/vidxp/core/contracts.py index ac3d296..5bb6471 100644 --- a/src/vidxp/core/contracts.py +++ b/src/vidxp/core/contracts.py @@ -11,8 +11,8 @@ from urllib.parse import quote -INDEX_SCHEMA_VERSION = 4 -MANIFEST_SCHEMA_VERSION = 1 +INDEX_SCHEMA_VERSION = 5 +MANIFEST_SCHEMA_VERSION = 2 class IndexCancelledError(RuntimeError): @@ -30,6 +30,16 @@ def _require_identifier(label: str, value: str) -> str: return value +def _require_sha256(label: str, value: str) -> str: + checksum = str(value).lower() + if ( + len(checksum) != 64 + or any(character not in string.hexdigits for character in checksum) + ): + raise ValueError(f"{label} must be a 64-character SHA-256 value.") + return checksum + + def _filesystem_component(value: str) -> str: value = _require_identifier("path component", value) if value in {".", ".."}: @@ -50,6 +60,8 @@ def stable_source_id( video_id: str, modality: str, local_id: str | int, + *, + generation_id: str | None = None, ) -> str: """Return an escaped, deterministic record ID. @@ -59,6 +71,9 @@ def stable_source_id( values = (run_id, video_id, modality, str(local_id)) labels = ("run_id", "video_id", "modality", "local_id") + if generation_id is not None: + values = (generation_id, *values) + labels = ("generation_id", *labels) encoded = [ quote(_require_identifier(label, value), safe="") for label, value in zip(labels, values) @@ -82,6 +97,10 @@ class IndexConfig: ) output_root: str | Path = "benchmark_runs" storage_directory: str | Path | None = None + generation_directory: str | Path | None = None + generation_id: str | None = None + snapshot_id: str | None = None + snapshot_sha256: str | None = None collection_names: Mapping[str, str] = field( default_factory=dict ) @@ -94,10 +113,26 @@ def __post_init__(self) -> None: "storage_directory", str(self.storage_directory), ) + if self.generation_directory is not None: + object.__setattr__( + self, + "generation_directory", + str(self.generation_directory), + ) for label in ("dataset", "split", "run_id"): _require_identifier(label, getattr(self, label)) if self.video_id is not None: _require_identifier("video_id", self.video_id) + if self.generation_id is not None: + _require_identifier("generation_id", self.generation_id) + if self.snapshot_id is not None: + _require_identifier("snapshot_id", self.snapshot_id) + if self.snapshot_sha256 is not None: + object.__setattr__( + self, + "snapshot_sha256", + _require_sha256("snapshot_sha256", self.snapshot_sha256), + ) modalities = tuple(dict.fromkeys(self.enabled_modalities)) if not modalities: @@ -170,7 +205,7 @@ def __post_init__(self) -> None: @classmethod def local(cls, **changes: Any) -> "IndexConfig": - """Return the single-video configuration used by the existing CLI/UI.""" + """Return the default configuration for a local repository operation.""" defaults = { "storage_directory": "chroma_data", @@ -182,6 +217,8 @@ def local(cls, **changes: Any) -> "IndexConfig": @property def run_directory(self) -> Path: + if self.generation_directory is not None: + return Path(self.generation_directory) if self.storage_directory is not None: return Path(self.storage_directory) return ( @@ -210,7 +247,7 @@ def record_identity( raise ValueError( f"Capability {modality!r} is not enabled for this run." ) - return { + identity = { "dataset": self.dataset, "split": self.split, "run_id": self.run_id, @@ -218,6 +255,9 @@ def record_identity( "modality": modality, "source_id": source_id, } + if self.generation_id is not None: + identity["generation_id"] = self.generation_id + return identity def options_for(self, capability: str) -> dict[str, Any]: if capability not in self.enabled_modalities: @@ -236,7 +276,16 @@ def to_dict(self) -> dict[str, Any]: def fingerprint(self) -> str: payload = asdict(self) - for excluded in ("video_id", "output_root", "storage_directory"): + for excluded in ( + "video_id", + "device", + "output_root", + "storage_directory", + "generation_directory", + "generation_id", + "snapshot_id", + "snapshot_sha256", + ): payload.pop(excluded, None) encoded = json.dumps( payload, @@ -262,13 +311,11 @@ def __post_init__(self) -> None: if self.path is None and self.transcript is None: raise ValueError("A video path or timestamped transcript is required.") if self.checksum is not None: - checksum = str(self.checksum).lower() - if ( - len(checksum) != 64 - or any(character not in string.hexdigits for character in checksum) - ): - raise ValueError("checksum must be a 64-character SHA-256 value.") - object.__setattr__(self, "checksum", checksum) + object.__setattr__( + self, + "checksum", + _require_sha256("checksum", self.checksum), + ) @dataclass(frozen=True) diff --git a/src/vidxp/core/generations.py b/src/vidxp/core/generations.py new file mode 100644 index 0000000..83deacf --- /dev/null +++ b/src/vidxp/core/generations.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import ( + AwareDatetime, + BaseModel, + ConfigDict, + Field, + JsonValue, + NonNegativeFloat, + NonNegativeInt, + model_validator, +) + +from vidxp.core.contracts import ( + INDEX_SCHEMA_VERSION, + MANIFEST_SCHEMA_VERSION, +) +from vidxp.core.identifiers import Identifier, Sha256, Uuid4Hex + + +class _GenerationManifestModel(BaseModel): + model_config = ConfigDict( + allow_inf_nan=False, + extra="forbid", + frozen=True, + strict=True, + ) + + +class _GenerationInput(_GenerationManifestModel): + sha256: Sha256 + checksums: dict[Identifier, Sha256] + size: NonNegativeInt | None + source_name: str | None + path: str | None + metadata: dict[str, JsonValue] + + +class _GenerationStage(_GenerationManifestModel): + video_id: Identifier + stage: Identifier + seconds: NonNegativeFloat + stats: dict[str, JsonValue] + recorded_at: AwareDatetime + + +class _CompletedGenerationVideo(_GenerationManifestModel): + state: Literal["complete"] + started_at: AwareDatetime + stages: dict[Identifier, _GenerationStage] + completed_at: AwareDatetime + summary: dict[str, JsonValue] + + +class CompletedGenerationManifest(_GenerationManifestModel): + manifest_schema_version: Literal[MANIFEST_SCHEMA_VERSION] + index_schema_version: Literal[INDEX_SCHEMA_VERSION] + dataset: Identifier + split: Identifier + run_id: Identifier + generation_id: Uuid4Hex + state: Literal["complete"] + created_at: AwareDatetime + updated_at: AwareDatetime + completed_at: AwareDatetime + config_fingerprint: Sha256 + execution_fingerprint: Sha256 + configuration: dict[str, JsonValue] + models: dict[str, JsonValue] + git: dict[str, JsonValue] + environment: dict[str, JsonValue] + inputs: dict[Identifier, _GenerationInput] = Field(min_length=1, max_length=1) + videos: dict[Identifier, _CompletedGenerationVideo] = Field( + min_length=1, + max_length=1, + ) + completed_videos: tuple[Identifier, ...] = Field( + min_length=1, + max_length=1, + ) + failed_videos: tuple[Identifier, ...] = Field(max_length=0) + interrupted_videos: tuple[Identifier, ...] = Field(max_length=0) + processed_frames: NonNegativeInt + record_counts: dict[Identifier, NonNegativeInt] + store_size_bytes_at_commit: NonNegativeInt + + @model_validator(mode="after") + def _validate_completed_generation(self) -> CompletedGenerationManifest: + input_ids = set(self.inputs) + video_ids = set(self.videos) + completed_ids = set(self.completed_videos) + if input_ids != video_ids or input_ids != completed_ids: + raise ValueError( + "inputs, videos, and completed_videos must identify the same " + "single media item" + ) + + configured = self.configuration.get("enabled_modalities") + if ( + not isinstance(configured, list) + or not configured + or any(not isinstance(value, str) or not value for value in configured) + or len(configured) != len(set(configured)) + ): + raise ValueError( + "configuration.enabled_modalities must be a non-empty list " + "of unique identifiers" + ) + if set(self.record_counts) != set(configured): + raise ValueError( + "record_counts keys must exactly match enabled_modalities" + ) + return self diff --git a/src/vidxp/core/identifiers.py b/src/vidxp/core/identifiers.py new file mode 100644 index 0000000..3f5b781 --- /dev/null +++ b/src/vidxp/core/identifiers.py @@ -0,0 +1,40 @@ +from typing import Annotated, TypeAlias +from uuid import UUID + +from pydantic import AfterValidator, StringConstraints + + +Identifier: TypeAlias = Annotated[ + str, + StringConstraints( + strip_whitespace=True, + min_length=1, + max_length=255, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$", + ), +] +RepositoryId: TypeAlias = Identifier +MediaId: TypeAlias = Identifier +VideoId: TypeAlias = Identifier +IndexGenerationId: TypeAlias = Identifier +IndexSnapshotId: TypeAlias = Identifier +JobId: TypeAlias = Identifier +ArtifactId: TypeAlias = Identifier + + +def _require_uuid4_hex(value: str) -> str: + identifier = UUID(hex=value) + if identifier.version != 4 or identifier.hex != value: + raise ValueError("identifier must be lowercase UUID4 hex") + return value + + +Uuid4Hex: TypeAlias = Annotated[ + str, + StringConstraints(pattern=r"^[0-9a-f]{32}$"), + AfterValidator(_require_uuid4_hex), +] +Sha256: TypeAlias = Annotated[ + str, + StringConstraints(pattern=r"^[0-9a-f]{64}$"), +] diff --git a/src/vidxp/core/manifest.py b/src/vidxp/core/manifest.py index d2b99cc..8176e93 100644 --- a/src/vidxp/core/manifest.py +++ b/src/vidxp/core/manifest.py @@ -2,9 +2,11 @@ import hashlib import json +import os import platform import subprocess import sys +import tempfile import time from datetime import datetime, timezone from importlib.metadata import PackageNotFoundError, version @@ -36,25 +38,58 @@ def utc_now() -> str: def write_json_atomic(path: Path, payload: Any) -> None: path.parent.mkdir(parents=True, exist_ok=True) - temporary = path.with_suffix(path.suffix + ".tmp") - temporary.write_text( - json.dumps( - payload, - ensure_ascii=False, - indent=2, - sort_keys=True, - ) - + "\n", - encoding="utf-8", - ) - for attempt in range(5): - try: - temporary.replace(path) - return - except PermissionError: - if attempt == 4: - raise - time.sleep(0.05 * (attempt + 1)) + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as destination: + temporary = Path(destination.name) + destination.write( + json.dumps( + payload, + ensure_ascii=False, + indent=2, + sort_keys=True, + ) + + "\n" + ) + destination.flush() + os.fsync(destination.fileno()) + + for attempt in range(5): + try: + temporary.replace(path) + break + except OSError: + if attempt == 4: + raise + time.sleep(0.05 * (attempt + 1)) + + _sync_parent_directory(path.parent) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def _sync_parent_directory(path: Path) -> None: + if os.name == "nt": + return + descriptor: int | None = None + try: + descriptor = os.open(path, os.O_RDONLY) + os.fsync(descriptor) + except OSError: + # The replacement is already visible. Some supported filesystems + # reject directory fsync, which cannot roll that replacement back. + pass + finally: + if descriptor is not None: + os.close(descriptor) def _append_jsonl(path: Path, payload: Mapping[str, Any]) -> None: @@ -272,6 +307,7 @@ def initialize( "dataset": self.config.dataset, "split": self.config.split, "run_id": self.config.run_id, + "generation_id": self.config.generation_id, "state": "running", "created_at": utc_now(), "updated_at": utc_now(), @@ -287,7 +323,8 @@ def initialize( "failed_videos": [], "interrupted_videos": [], "processed_frames": 0, - "index_size_bytes": 0, + "record_counts": {}, + "store_size_bytes_at_commit": 0, } if reset: for path in ( @@ -489,14 +526,20 @@ def interrupt_video(self, video_id: str, stage: str) -> None: manifest["updated_at"] = utc_now() self.write(manifest) - def complete_run(self, *, index_size_bytes: int) -> dict[str, Any]: + def complete_run( + self, + *, + store_size_bytes_at_commit: int, + ) -> dict[str, Any]: manifest = self.read() self._refresh_runtime(manifest) expected = set(manifest["inputs"]) completed = set(manifest["completed_videos"]) if expected != completed or manifest["failed_videos"]: manifest["state"] = "completed_with_failures" - manifest["index_size_bytes"] = index_size_bytes + manifest["store_size_bytes_at_commit"] = ( + store_size_bytes_at_commit + ) manifest["updated_at"] = utc_now() self.write(manifest) return manifest @@ -508,7 +551,7 @@ def complete_run(self, *, index_size_bytes: int) -> dict[str, Any]: manifest["state"] = "complete" manifest["completed_at"] = utc_now() manifest["processed_frames"] = processed_frames - manifest["index_size_bytes"] = index_size_bytes + manifest["store_size_bytes_at_commit"] = store_size_bytes_at_commit manifest["updated_at"] = utc_now() self.write(manifest) completion = { @@ -519,7 +562,31 @@ def complete_run(self, *, index_size_bytes: int) -> dict[str, Any]: "config_fingerprint": self.config.fingerprint(), "completed_at": manifest["completed_at"], "completed_videos": manifest["completed_videos"], - "index_size_bytes": index_size_bytes, + "store_size_bytes_at_commit": store_size_bytes_at_commit, } write_json_atomic(self.completion_path, completion) return manifest + + def record_storage_counts( + self, + record_counts: Mapping[str, int], + ) -> dict[str, Any]: + manifest = self.read() + if manifest.get("state") != "complete": + raise RuntimeError( + "Storage counts can only finalize a completed generation." + ) + counts = { + str(modality): int(count) + for modality, count in record_counts.items() + } + if set(counts) != set(self.config.enabled_modalities): + raise ValueError( + "Storage counts must cover every enabled modality exactly." + ) + if any(count < 0 for count in counts.values()): + raise ValueError("Storage record counts must be nonnegative.") + manifest["record_counts"] = counts + manifest["updated_at"] = utc_now() + self.write(manifest) + return manifest diff --git a/src/vidxp/core/runner.py b/src/vidxp/core/runner.py index 0637db3..1129231 100644 --- a/src/vidxp/core/runner.py +++ b/src/vidxp/core/runner.py @@ -1,7 +1,6 @@ from __future__ import annotations from pathlib import Path -from threading import Lock from time import perf_counter from typing import Any, Callable, Sequence @@ -13,7 +12,6 @@ CancellationToken, IndexCancelledError, IndexConfig, - IndexSchemaError, VideoSource, ) from vidxp.core.manifest import ( @@ -30,7 +28,6 @@ ProgressCallback = Callable[[dict[str, Any]], None] -_INDEXING_LOCK = Lock() def require_dependencies( @@ -77,47 +74,9 @@ def _run_lock_held(run_directory: Path) -> bool: def indexing_in_progress(config: IndexConfig) -> bool: - if _INDEXING_LOCK.locked(): - return True return _run_lock_held(config.run_directory) -def local_config_from_status( - status: dict[str, Any], - *, - storage_directory: str | Path | None = None, -) -> IndexConfig: - summary = status.get("summary") or {} - if summary.get("index_schema_version") != INDEX_SCHEMA_VERSION: - raise IndexSchemaError( - "The saved index predates the benchmark-ready schema. " - "Re-index the video before searching." - ) - stored = dict(summary.get("configuration") or {}) - stored = { - key: value - for key, value in stored.items() - if key in IndexConfig.__dataclass_fields__ - } - stored.update( - { - "dataset": str(summary["dataset"]), - "split": str(summary["split"]), - "run_id": str(summary["run_id"]), - "video_id": str(summary["video_id"]), - } - ) - if storage_directory is not None: - stored["storage_directory"] = str(storage_directory) - else: - stored.setdefault("storage_directory", "chroma_data") - if "enabled_modalities" in stored: - stored["enabled_modalities"] = tuple(stored["enabled_modalities"]) - if "collection_names" in stored: - stored["collection_names"] = dict(stored["collection_names"]) - return IndexConfig(**stored) - - def _resolve_sources( sources: Sequence[VideoSource], config: IndexConfig, @@ -345,8 +304,11 @@ def set_stage(value: str) -> None: registry=registry, ) stage = "preparing_storage" - for modality in config.enabled_modalities: - storage.delete_video(modality, video_id) + if config.generation_id is not None: + storage.delete_generation(config.generation_id) + else: + for modality in config.enabled_modalities: + storage.delete_video(modality, video_id) summary.update( _run_enabled_modalities( @@ -449,7 +411,9 @@ def _run_index_unlocked( runtime=runtime, ) - return manifest_store.complete_run(index_size_bytes=storage.size_bytes()) + return manifest_store.complete_run( + store_size_bytes_at_commit=storage.size_bytes() + ) def run_index( @@ -496,6 +460,7 @@ def index_video( path: str, progress_callback: ProgressCallback | None = None, source_name: str | None = None, + checksum: str | None = None, *, config: IndexConfig, cancellation: CancellationToken | None = None, @@ -507,12 +472,11 @@ def index_video( input_path = Path(path) if not input_path.is_file(): raise FileNotFoundError(f"Video not found: {input_path}") - if not _INDEXING_LOCK.acquire(blocking=False): - raise IndexingInProgressError("Another video is already being indexed.") source = VideoSource( path=input_path, source_name=source_name or input_path.name, + checksum=checksum, ) checksum = source_checksum(source) source = VideoSource( @@ -543,7 +507,7 @@ def report(event: dict[str, Any]) -> None: total=event.get("total"), summary=event.get("summary"), error=event.get("error"), - index_directory=active_config.index_directory, + index_directory=active_config.run_directory, ) if progress_callback is not None: progress_callback(event) @@ -569,7 +533,7 @@ def report(event: dict[str, Any]) -> None: registry=registry, runtime=runtime, resume=False, - reset=True, + reset=False, fail_fast=True, ) summary = dict(manifest["videos"][video_id]["summary"]) @@ -613,5 +577,3 @@ def report(event: dict[str, Any]) -> None: } ) raise - finally: - _INDEXING_LOCK.release() diff --git a/src/vidxp/core/snapshots.py b/src/vidxp/core/snapshots.py new file mode 100644 index 0000000..9e1b9c4 --- /dev/null +++ b/src/vidxp/core/snapshots.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import ( + AwareDatetime, + BaseModel, + ConfigDict, + Field, + JsonValue, + model_validator, +) + +from vidxp.core.identifiers import Identifier, Sha256, Uuid4Hex + + +INDEX_SNAPSHOT_SCHEMA_VERSION = 1 +ACTIVE_SNAPSHOT_POINTER_SCHEMA_VERSION = 1 + +class _SnapshotModel(BaseModel): + model_config = ConfigDict( + allow_inf_nan=False, + extra="forbid", + frozen=True, + strict=True, + ) + + +class GenerationReference(_SnapshotModel): + generation_id: Uuid4Hex + media_id: Identifier + manifest_sha256: Sha256 + input_sha256: Sha256 + config_fingerprint: Sha256 + modalities: tuple[Identifier, ...] = Field(min_length=1) + record_counts: dict[Identifier, int] + store_size_bytes_at_commit: int = Field(ge=0) + + @model_validator(mode="after") + def _validate_record_counts(self) -> GenerationReference: + if set(self.record_counts) != set(self.modalities): + raise ValueError( + "record_counts keys must exactly match modalities" + ) + if any(count < 0 for count in self.record_counts.values()): + raise ValueError("record_counts values must be nonnegative") + return self + + +class IndexSnapshot(_SnapshotModel): + schema_version: Literal[INDEX_SNAPSHOT_SCHEMA_VERSION] = ( + INDEX_SNAPSHOT_SCHEMA_VERSION + ) + snapshot_id: Uuid4Hex + created_at: AwareDatetime + config_fingerprint: Sha256 + configuration: dict[str, JsonValue] + generations: dict[Identifier, GenerationReference] + + @model_validator(mode="after") + def _validate_generation_keys(self) -> IndexSnapshot: + mismatched = sorted( + media_id + for media_id, reference in self.generations.items() + if media_id != reference.media_id + ) + if mismatched: + raise ValueError( + "generation mapping keys must match reference media_id values" + ) + return self + + +class ActiveSnapshotPointer(_SnapshotModel): + schema_version: Literal[ACTIVE_SNAPSHOT_POINTER_SCHEMA_VERSION] = ( + ACTIVE_SNAPSHOT_POINTER_SCHEMA_VERSION + ) + snapshot_id: Uuid4Hex + snapshot_sha256: Sha256 + updated_at: AwareDatetime diff --git a/src/vidxp/core/storage.py b/src/vidxp/core/storage.py index 50ac3db..ba0458f 100644 --- a/src/vidxp/core/storage.py +++ b/src/vidxp/core/storage.py @@ -15,6 +15,7 @@ def metadata_filter( config: IndexConfig, *, video_id: str | None = None, + generation_ids: Iterable[str] | None = None, extra: Mapping[str, Any] | None = None, ) -> dict[str, Any]: values: dict[str, Any] = { @@ -24,6 +25,11 @@ def metadata_filter( } if video_id is not None: values["video_id"] = video_id + selected_generations = _generation_ids(generation_ids) + if selected_generations is not None: + if not selected_generations: + raise ValueError("generation_ids must not be empty.") + values["generation_id"] = {"$in": list(selected_generations)} if extra: reserved = set(values) & set(extra) if reserved: @@ -36,6 +42,19 @@ def metadata_filter( return clauses[0] if len(clauses) == 1 else {"$and": clauses} +def _generation_ids( + generation_ids: Iterable[str] | None, +) -> tuple[str, ...] | None: + if generation_ids is None: + return None + if isinstance(generation_ids, str): + raise TypeError("generation_ids must be an iterable of identifiers.") + values = tuple(generation_ids) + if any(not isinstance(value, str) or not value for value in values): + raise ValueError("generation_ids must contain non-empty strings.") + return tuple(dict.fromkeys(values)) + + def _client_for_path(path: str): import chromadb @@ -43,10 +62,19 @@ def _client_for_path(path: str): class IndexStorage: - def __init__(self, config: IndexConfig): + def __init__(self, config: IndexConfig, *, create: bool = True): self.config = config self.path = config.index_directory - self.path.mkdir(parents=True, exist_ok=True) + self._create = create + if create: + self.path.mkdir(parents=True, exist_ok=True) + elif ( + not self.path.is_dir() + or not (self.path / "chroma.sqlite3").is_file() + ): + raise FileNotFoundError( + f"The committed Chroma store is missing at {self.path}." + ) self.client = _client_for_path(str(self.path.resolve())) self._names = dict(config.collection_names) self._collections: dict[str, Any] = {} @@ -70,10 +98,18 @@ def collection(self, modality: str): name = self._names[modality] except KeyError as exc: raise ValueError(f"Unsupported collection modality: {modality}") from exc - collection = self.client.get_or_create_collection( - name=name, - metadata={"hnsw:space": self.config.vector_distance}, - ) + if self._create: + collection = self.client.get_or_create_collection( + name=name, + metadata={"hnsw:space": self.config.vector_distance}, + ) + else: + try: + collection = self.client.get_collection(name=name) + except Exception as exc: + raise FileNotFoundError( + f"Committed Chroma collection {name!r} is missing." + ) from exc configuration = getattr(collection, "configuration", {}) or {} actual_distance = (configuration.get("hnsw") or {}).get("space") if ( @@ -119,6 +155,23 @@ def delete_records( ), ) + def delete_generation( + self, + generation_id: str, + modalities: Iterable[str] | None = None, + ) -> None: + selected = ( + tuple(self._names) + if modalities is None + else tuple(modalities) + ) + where = metadata_filter( + self.config, + generation_ids=(generation_id,), + ) + for modality in selected: + self.collection(modality).delete(where=where) + def upsert( self, modality: str, @@ -153,10 +206,14 @@ def query( *, top_k: int, video_id: str | None = None, + generation_ids: Iterable[str] | None = None, filters: Mapping[str, Any] | None = None, ) -> list[dict[str, Any]]: if top_k <= 0: raise ValueError("top_k must be greater than zero.") + selected_generations = _generation_ids(generation_ids) + if selected_generations == (): + return [] options: dict[str, Any] = { "query_embeddings": [embedding], @@ -165,6 +222,7 @@ def query( "where": metadata_filter( self.config, video_id=video_id, + generation_ids=selected_generations, extra=filters, ), } @@ -190,12 +248,17 @@ def records( modality: str, *, video_id: str | None = None, + generation_ids: Iterable[str] | None = None, filters: Mapping[str, Any] | None = None, ) -> list[dict[str, Any]]: + selected_generations = _generation_ids(generation_ids) + if selected_generations == (): + return [] result = self.collection(modality).get( where=metadata_filter( self.config, video_id=video_id, + generation_ids=selected_generations, extra=filters, ), include=["metadatas"], @@ -206,10 +269,86 @@ def records( if metadata ] + def count_records( + self, + modality: str, + *, + video_id: str | None = None, + generation_ids: Iterable[str] | None = None, + ) -> int: + selected_generations = _generation_ids(generation_ids) + if selected_generations == (): + return 0 + result = self.collection(modality).get( + where=metadata_filter( + self.config, + video_id=video_id, + generation_ids=selected_generations, + ), + include=[], + ) + return len(result.get("ids") or ()) + def size_bytes(self) -> int: return directory_size(self.path) +class SnapshotScopedIndexStore: + """Read-only view of records referenced by one immutable snapshot.""" + + def __init__( + self, + store: IndexStorage, + generation_ids: Iterable[str], + ) -> None: + self._store = store + self._generation_ids = _generation_ids(generation_ids) or () + + def close(self) -> None: + self._store.close() + + def __enter__(self) -> "SnapshotScopedIndexStore": + self._store.__enter__() + return self + + def __exit__(self, *args: Any) -> Any: + return self._store.__exit__(*args) + + def size_bytes(self) -> int: + return self._store.size_bytes() + + def query( + self, + modality: str, + embedding: list[float], + *, + top_k: int, + video_id: str | None = None, + filters: Mapping[str, Any] | None = None, + ) -> list[dict[str, Any]]: + return self._store.query( + modality, + embedding, + top_k=top_k, + video_id=video_id, + generation_ids=self._generation_ids, + filters=filters, + ) + + def records( + self, + modality: str, + *, + video_id: str | None = None, + filters: Mapping[str, Any] | None = None, + ) -> list[dict[str, Any]]: + return self._store.records( + modality, + video_id=video_id, + generation_ids=self._generation_ids, + filters=filters, + ) + def directory_size(path: str | Path) -> int: root = Path(path) if not root.exists(): diff --git a/src/vidxp/index_state.py b/src/vidxp/index_state.py index c3e4b74..ac1c942 100644 --- a/src/vidxp/index_state.py +++ b/src/vidxp/index_state.py @@ -91,17 +91,3 @@ def write_index_status( ) temporary_path.replace(status_path) return payload - - -def require_ready_index( - index_directory: str | Path = INDEX_DIRECTORY, -) -> dict[str, Any]: - status = read_index_status(index_directory) - if status is None: - raise IndexNotReadyError( - "No completed video index was found. Index a video before searching." - ) - if status.get("state") != "ready": - message = status.get("message", "The video index is not ready.") - raise IndexNotReadyError(message) - return status diff --git a/src/vidxp/infrastructure/local_index.py b/src/vidxp/infrastructure/local_index.py index 83123f2..6106e44 100644 --- a/src/vidxp/infrastructure/local_index.py +++ b/src/vidxp/infrastructure/local_index.py @@ -1,35 +1,29 @@ from __future__ import annotations +import json +import shutil from dataclasses import replace from pathlib import Path from typing import Any +from uuid import UUID from vidxp.capabilities.contracts import ( RuntimeCheckBinding, module_import_check, ) from vidxp.capabilities.registry import CapabilityRegistry -from vidxp.core.contracts import CancellationToken, IndexConfig -from vidxp.core.indexing_common import ProgressCallback -from vidxp.core.runner import ( - index_video, - indexing_in_progress, - local_config_from_status, -) -from vidxp.core.manifest import ( - CHECKPOINT_DIRECTORY, - COMPLETION_FILE, - FAILURES_FILE, - MANIFEST_FILE, - TIMINGS_FILE, - ManifestStore, -) -from vidxp.core.storage import IndexStorage -from vidxp.index_state import ( - INDEX_STATUS_FILE, - read_index_status, - require_ready_index, +from vidxp.core.contracts import ( + CancellationToken, + IndexConfig, + IndexSchemaError, + VideoSource, ) +from vidxp.core.indexing_common import ProgressCallback +from vidxp.core.manifest import MANIFEST_FILE, ManifestStore, source_checksum +from vidxp.core.runner import index_video +from vidxp.core.storage import IndexStorage, SnapshotScopedIndexStore +from vidxp.infrastructure.local_snapshots import LocalSnapshotRepository +from vidxp.repository_layout import RepositoryLayout from vidxp.runtime import ModelRuntime @@ -58,12 +52,29 @@ def __init__( self, registry: CapabilityRegistry, runtime: ModelRuntime, + layout: RepositoryLayout, ) -> None: self.registry = registry self.runtime = runtime + self.layout = layout + self.repository = LocalSnapshotRepository(layout.indexes) + + def _require_index_directory(self, index_directory: str | Path) -> None: + if Path(index_directory) != self.layout.indexes: + raise ValueError( + "The index operation is outside the configured repository." + ) def status(self, index_directory: Path) -> dict[str, Any] | None: - return read_index_status(index_directory) + self._require_index_directory(index_directory) + status = self.repository.status() + if status is not None and status.get("state") == "ready": + config, snapshot = self.repository.active_config( + device=self.runtime.backends.torch_device + ) + with self._open_committed_storage(config) as storage: + self._validate_snapshot_storage(storage, snapshot) + return status def active_config( self, @@ -71,12 +82,18 @@ def active_config( *, device: str, ) -> tuple[IndexConfig, dict[str, Any]]: - status = require_ready_index(index_directory) - config = local_config_from_status( - status, - storage_directory=index_directory, + self._require_index_directory(index_directory) + config, snapshot = self.repository.active_config( + device=device ) - return replace(config, device=device), status + return config, { + "snapshot_id": snapshot.snapshot_id, + "generation_ids": [ + reference.generation_id + for reference in snapshot.generations.values() + ], + "media_ids": sorted(snapshot.generations), + } def create( self, @@ -87,45 +104,337 @@ def create( cancellation: CancellationToken | None, source_name: str | None, ) -> dict[str, Any]: - with IndexStorage(config) as storage: - return index_video( - str(path), - progress_callback=progress, - source_name=source_name, - config=config, - cancellation=cancellation, - storage=storage, - manifest_store=ManifestStore( - config, + self._require_index_directory(config.index_directory) + repository = self.repository + source = VideoSource( + path=path, + source_name=source_name or path.name, + ) + input_sha256 = source_checksum(source) + media_id = config.video_id or input_sha256 + generation_id = repository.new_generation_id() + generation_directory = repository.generation_directory(generation_id) + build_config = replace( + config, + video_id=media_id, + storage_directory=repository.store, + generation_directory=generation_directory, + generation_id=generation_id, + snapshot_id=None, + snapshot_sha256=None, + ) + + with repository.lease(): + active = repository.require_compatible_profile( + media_id=media_id, + config_fingerprint=build_config.fingerprint(), + ) + if active is not None and active.generations: + active_config, _ = repository.active_config( + device=build_config.device + ) + with self._open_committed_storage( + active_config + ) as active_store: + self._validate_snapshot_storage( + active_store, + active, + active_config, + ) + self._cleanup_abandoned_generations( + repository, + build_config, + ) + generation_validated = False + try: + manifest_store = ManifestStore( + build_config, registry=self.registry, runtime=self.runtime, - ), - registry=self.registry, - runtime=self.runtime, + ) + with IndexStorage(build_config) as storage: + summary = index_video( + str(path), + progress_callback=progress, + source_name=source_name, + checksum=input_sha256, + config=build_config, + cancellation=cancellation, + storage=storage, + manifest_store=manifest_store, + registry=self.registry, + runtime=self.runtime, + ) + if cancellation is not None: + cancellation.raise_if_cancelled() + record_counts = self._validate_generation_records( + storage, + build_config, + ) + manifest_store.record_storage_counts(record_counts) + if cancellation is not None: + cancellation.raise_if_cancelled() + reference = repository.generation_reference( + generation_id=generation_id, + media_id=media_id, + ) + generation_validated = True + if cancellation is not None: + cancellation.raise_if_cancelled() + snapshot = repository.publish_generation( + reference, + build_config, + ) + except BaseException: + try: + self._remove_uncommitted_generation( + repository, + build_config, + preserve_completed=generation_validated, + ) + except Exception: + # Preserve the primary build/publication failure. + pass + raise + + result = dict(summary) + result.update( + { + "media_id": media_id, + "generation_id": generation_id, + "snapshot_id": snapshot.snapshot_id, + "active_media_count": len(snapshot.generations), + } + ) + return result + + @staticmethod + def _validate_generation_records( + storage: IndexStorage, + config: IndexConfig, + expected_counts: dict[str, int] | None = None, + ) -> dict[str, int]: + if config.generation_id is None or config.video_id is None: + raise IndexSchemaError( + "Generation and media identity are required before publication." ) + counts: dict[str, int] = {} + for modality in config.enabled_modalities: + records = storage.records( + modality, + generation_ids=(config.generation_id,), + ) + if any( + record.get("generation_id") != config.generation_id + or record.get("video_id") != config.video_id + or record.get("modality") != modality + for record in records + ): + raise IndexSchemaError( + f"Stored {modality} records do not match the generation " + "being published." + ) + counts[modality] = len(records) + if expected_counts is not None and counts != expected_counts: + raise IndexSchemaError( + "Stored generation record counts do not match the " + "authoritative manifest." + ) + return counts - def indexing_in_progress(self, config: IndexConfig) -> bool: - return indexing_in_progress(config) - - def open_store(self, config: IndexConfig) -> IndexStorage: - return IndexStorage(config) - - def clear(self, config: IndexConfig) -> None: - with IndexStorage(config) as storage: - storage.clear() - for name in ( - INDEX_STATUS_FILE, - MANIFEST_FILE, - TIMINGS_FILE, - FAILURES_FILE, - COMPLETION_FILE, - ): - (config.run_directory / name).unlink(missing_ok=True) - checkpoint_directory = config.run_directory / CHECKPOINT_DIRECTORY - if checkpoint_directory.is_dir(): - for checkpoint in checkpoint_directory.glob("*.json"): - checkpoint.unlink() + @classmethod + def _validate_snapshot_storage( + cls, + storage: IndexStorage, + snapshot, + base_config: IndexConfig | None = None, + ) -> None: + active_config = base_config or storage.config + for reference in snapshot.generations.values(): + try: + actual = { + modality: storage.count_records( + modality, + video_id=reference.media_id, + generation_ids=(reference.generation_id,), + ) + for modality in active_config.enabled_modalities + } + except FileNotFoundError as exc: + raise IndexSchemaError( + "A committed Chroma collection is missing." + ) from exc + if actual != dict(reference.record_counts): + raise IndexSchemaError( + "Stored generation record counts do not match the " + "authoritative manifest." + ) + + @staticmethod + def _open_committed_storage(config: IndexConfig) -> IndexStorage: + try: + return IndexStorage(config, create=False) + except FileNotFoundError as exc: + raise IndexSchemaError( + "The committed Chroma store is missing." + ) from exc + + @classmethod + def _cleanup_abandoned_generations( + cls, + repository: LocalSnapshotRepository, + config: IndexConfig, + ) -> None: + completed: set[str] = set() + incomplete: dict[str, Path] = {} + if repository.generations.is_dir(): + for path in repository.generations.iterdir(): + if not path.is_dir() or not cls._is_generation_id(path.name): + continue + manifest_path = path / MANIFEST_FILE + try: + manifest = json.loads( + manifest_path.read_text(encoding="utf-8") + ) + except (OSError, json.JSONDecodeError): + incomplete[path.name] = path + continue + if ( + manifest.get("state") == "complete" + and manifest.get("generation_id") == path.name + ): + completed.add(path.name) + else: + incomplete[path.name] = path + + cleanup_config = replace( + config, + storage_directory=repository.store, + generation_directory=None, + video_id=None, + generation_id=None, + snapshot_id=None, + snapshot_sha256=None, + ) + database = repository.store / "chroma.sqlite3" + if database.is_file(): + with IndexStorage(cleanup_config, create=False) as storage: + stored_ids: set[str] = set() + for modality in cleanup_config.enabled_modalities: + try: + records = storage.records(modality) + except FileNotFoundError: + continue + for record in records: + generation_id = record.get("generation_id") + if ( + isinstance(generation_id, str) + and cls._is_generation_id(generation_id) + ): + stored_ids.add(generation_id) + for generation_id in sorted( + set(incomplete) | (stored_ids - completed) + ): + for modality in cleanup_config.enabled_modalities: + try: + storage.delete_generation( + generation_id, + modalities=(modality,), + ) + except FileNotFoundError: + continue + + for generation_id, path in incomplete.items(): + resolved = path.resolve() + if repository.generations.resolve() not in resolved.parents: + raise RuntimeError( + "Refusing to clean a generation outside the repository." + ) + shutil.rmtree(resolved) + + @staticmethod + def _is_generation_id(value: str) -> bool: + try: + identifier = UUID(hex=value) + except ValueError: + return False + return identifier.version == 4 and identifier.hex == value + + @staticmethod + def _remove_uncommitted_generation( + repository: LocalSnapshotRepository, + config: IndexConfig, + *, + preserve_completed: bool, + ) -> None: + generation_id = config.generation_id + if generation_id is None: + return + generation_directory = config.run_directory.resolve() + generations_root = repository.generations.resolve() + if generations_root not in generation_directory.parents: + raise RuntimeError( + "Refusing to clean a generation outside the repository." + ) + manifest_path = generation_directory / MANIFEST_FILE + state = None + if manifest_path.is_file(): try: - checkpoint_directory.rmdir() - except OSError: - pass + state = json.loads( + manifest_path.read_text(encoding="utf-8") + ).get("state") + except (OSError, json.JSONDecodeError): + state = None + if state == "complete" and preserve_completed: + return + try: + with IndexStorage(config) as storage: + storage.delete_generation(generation_id) + except Exception: + # Cleanup is best-effort and must never replace the build failure. + pass + if generation_directory.is_dir(): + shutil.rmtree(generation_directory) + + def indexing_in_progress(self, config: IndexConfig) -> bool: + self._require_index_directory(config.index_directory) + return self.repository.mutation_in_progress() + + def open_store(self, config: IndexConfig) -> SnapshotScopedIndexStore: + repository = self.repository + if config.snapshot_id is None: + raise IndexSchemaError("A snapshot ID is required for index reads.") + if config.snapshot_sha256 is None: + raise IndexSchemaError( + "A snapshot checksum is required for index reads." + ) + snapshot = repository.read_snapshot( + config.snapshot_id, + expected_sha256=config.snapshot_sha256, + ) + generation_ids = tuple( + reference.generation_id + for reference in snapshot.generations.values() + ) + storage = self._open_committed_storage(config) + try: + self._validate_snapshot_storage(storage, snapshot, config) + return SnapshotScopedIndexStore( + storage, + generation_ids=generation_ids, + ) + except BaseException: + storage.close() + raise + + def remove(self, config: IndexConfig, media_id: str) -> bool: + self._require_index_directory(config.index_directory) + repository = self.repository + with repository.lease(): + return repository.remove(media_id) + + def clear(self, config: IndexConfig) -> bool: + self._require_index_directory(config.index_directory) + repository = self.repository + with repository.lease(): + return repository.clear() diff --git a/src/vidxp/infrastructure/local_snapshots.py b/src/vidxp/infrastructure/local_snapshots.py new file mode 100644 index 0000000..b0f28a0 --- /dev/null +++ b/src/vidxp/infrastructure/local_snapshots.py @@ -0,0 +1,464 @@ +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import asdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterator, Mapping +from uuid import uuid4 + +from filelock import FileLock, Timeout +from pydantic import ValidationError + +from vidxp.core.contracts import ( + INDEX_SCHEMA_VERSION, + IndexConfig, + IndexSchemaError, +) +from vidxp.core.manifest import MANIFEST_FILE, sha256_file, write_json_atomic +from vidxp.core.generations import CompletedGenerationManifest +from vidxp.core.snapshots import ( + ActiveSnapshotPointer, + GenerationReference, + IndexSnapshot, +) +from vidxp.index_state import IndexNotReadyError, IndexingInProgressError +from vidxp.repository_layout import RepositoryLayout + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +class LocalSnapshotRepository: + """Durable snapshot metadata for one local index repository. + + The repository is intended for a local filesystem with working advisory + file locks and atomic same-filesystem replacement semantics. + """ + + def __init__(self, indexes: str | Path): + index_path = Path(indexes) + self.layout = RepositoryLayout(root=index_path.parent) + if index_path != self.layout.indexes: + raise ValueError( + "LocalSnapshotRepository requires a RepositoryLayout indexes " + "directory." + ) + + @property + def indexes(self) -> Path: + return self.layout.indexes + + @property + def store(self) -> Path: + return self.layout.index_store + + @property + def generations(self) -> Path: + return self.layout.generations + + @property + def snapshots(self) -> Path: + return self.layout.snapshots + + @property + def active_pointer(self) -> Path: + return self.layout.active_snapshot + + @property + def lease_path(self) -> Path: + return self.layout.index_lease + + def ensure_directories(self) -> None: + for path in ( + self.indexes, + self.store, + self.generations, + self.snapshots, + ): + path.mkdir(parents=True, exist_ok=True) + + @contextmanager + def lease(self) -> Iterator[None]: + self.ensure_directories() + lock = FileLock(self.lease_path) + try: + lock.acquire(timeout=0) + except Timeout as exc: + raise IndexingInProgressError( + f"An index mutation is already active for {self.indexes}." + ) from exc + try: + yield + finally: + lock.release() + + def mutation_in_progress(self) -> bool: + self.ensure_directories() + lock = FileLock(self.lease_path) + try: + lock.acquire(timeout=0) + except Timeout: + return True + lock.release() + return False + + def new_generation_id(self) -> str: + return uuid4().hex + + def generation_directory(self, generation_id: str) -> Path: + return self.generations / generation_id + + def _snapshot_path(self, snapshot_id: str) -> Path: + return self.snapshots / f"{snapshot_id}.json" + + def read_active(self, *, required: bool = False) -> IndexSnapshot | None: + resolved = self._read_active(required=required) + return None if resolved is None else resolved[1] + + def _read_active( + self, + *, + required: bool = False, + ) -> tuple[ActiveSnapshotPointer, IndexSnapshot] | None: + if not self.active_pointer.is_file(): + if required: + raise IndexNotReadyError( + "No active index snapshot was found. Index media first." + ) + return None + try: + pointer = ActiveSnapshotPointer.model_validate_json( + self.active_pointer.read_text(encoding="utf-8") + ) + snapshot = self.read_snapshot( + pointer.snapshot_id, + expected_sha256=pointer.snapshot_sha256, + ) + return pointer, snapshot + except IndexSchemaError: + raise + except (OSError, ValueError, ValidationError) as exc: + raise IndexSchemaError( + "The active snapshot metadata is invalid." + ) from exc + + def read_snapshot( + self, + snapshot_id: str, + *, + expected_sha256: str | None = None, + ) -> IndexSnapshot: + snapshot_path = self._snapshot_path(snapshot_id) + if not snapshot_path.is_file(): + raise IndexSchemaError( + f"Index snapshot {snapshot_id} is missing." + ) + if ( + expected_sha256 is not None + and sha256_file(snapshot_path) != expected_sha256 + ): + raise IndexSchemaError( + f"Index snapshot {snapshot_id} failed integrity validation." + ) + try: + snapshot = IndexSnapshot.model_validate_json( + snapshot_path.read_text(encoding="utf-8") + ) + except (OSError, ValueError, ValidationError) as exc: + raise IndexSchemaError( + f"Index snapshot {snapshot_id} is invalid." + ) from exc + if snapshot.snapshot_id != snapshot_id: + raise IndexSchemaError( + "The snapshot filename and document identifier differ." + ) + self._validate_generations(snapshot) + return snapshot + + def _validate_generations(self, snapshot: IndexSnapshot) -> None: + for media_id, reference in snapshot.generations.items(): + if reference.config_fingerprint != snapshot.config_fingerprint: + raise IndexSchemaError( + f"Generation metadata for {media_id!r} has a " + "different index profile." + ) + self.validate_generation(reference) + + def validate_generation( + self, + reference: GenerationReference, + ) -> CompletedGenerationManifest: + manifest_path = ( + self.generation_directory(reference.generation_id) / MANIFEST_FILE + ) + if not manifest_path.is_file(): + raise IndexSchemaError( + f"Manifest for generation {reference.generation_id} is missing." + ) + if sha256_file(manifest_path) != reference.manifest_sha256: + raise IndexSchemaError( + f"Manifest for generation {reference.generation_id} " + "failed integrity validation." + ) + try: + manifest = CompletedGenerationManifest.model_validate_json( + manifest_path.read_text(encoding="utf-8") + ) + except (OSError, ValueError, ValidationError) as exc: + raise IndexSchemaError( + f"Manifest for generation {reference.generation_id} is invalid." + ) from exc + if ( + manifest.generation_id != reference.generation_id + or manifest.config_fingerprint != reference.config_fingerprint + ): + raise IndexSchemaError( + f"Manifest for generation {reference.generation_id} " + "does not describe a completed compatible generation." + ) + source = manifest.inputs.get(reference.media_id) + if source is None or source.sha256 != reference.input_sha256: + raise IndexSchemaError( + f"Input checksum for generation {reference.generation_id} " + "does not match its snapshot reference." + ) + if ( + manifest.store_size_bytes_at_commit + != reference.store_size_bytes_at_commit + ): + raise IndexSchemaError( + f"Index size for generation {reference.generation_id} " + "does not match its snapshot reference." + ) + if set(manifest.completed_videos) != {reference.media_id}: + raise IndexSchemaError( + f"Generation {reference.generation_id} is not a complete " + "single-media generation." + ) + if tuple(manifest.configuration["enabled_modalities"]) != ( + reference.modalities + ) or dict(manifest.record_counts) != dict(reference.record_counts): + raise IndexSchemaError( + f"Modalities for generation {reference.generation_id} " + "do not match its snapshot reference." + ) + return manifest + + def generation_reference( + self, + *, + generation_id: str, + media_id: str, + ) -> GenerationReference: + manifest_path = self.generation_directory(generation_id) / MANIFEST_FILE + try: + manifest = CompletedGenerationManifest.model_validate_json( + manifest_path.read_text(encoding="utf-8") + ) + reference = GenerationReference( + generation_id=generation_id, + media_id=media_id, + manifest_sha256=sha256_file(manifest_path), + input_sha256=manifest.inputs[media_id].sha256, + config_fingerprint=manifest.config_fingerprint, + modalities=tuple( + manifest.configuration["enabled_modalities"] + ), + record_counts=dict(manifest.record_counts), + store_size_bytes_at_commit=( + manifest.store_size_bytes_at_commit + ), + ) + except (OSError, KeyError, TypeError, ValueError, ValidationError) as exc: + raise IndexSchemaError( + f"Completed generation {generation_id} has an invalid manifest." + ) from exc + self.validate_generation(reference) + return reference + + def publish_generation( + self, + reference: GenerationReference, + config: IndexConfig, + ) -> IndexSnapshot: + active = self.require_compatible_profile( + media_id=reference.media_id, + config_fingerprint=reference.config_fingerprint, + ) + generations = dict(active.generations) if active is not None else {} + other_media = set(generations) - {reference.media_id} + generations[reference.media_id] = reference + configuration = ( + dict(active.configuration) + if active is not None and other_media + else self.snapshot_configuration(config) + ) + return self._publish( + generations=generations, + config_fingerprint=reference.config_fingerprint, + configuration=configuration, + ) + + def require_compatible_profile( + self, + *, + media_id: str, + config_fingerprint: str, + ) -> IndexSnapshot | None: + active = self.read_active() + if active is None: + return None + other_media = set(active.generations) - {media_id} + if ( + other_media + and active.config_fingerprint != config_fingerprint + ): + raise IndexSchemaError( + "All media in one active snapshot must use the same index " + "profile. Re-index or clear the other media first." + ) + return active + + def remove(self, media_id: str) -> bool: + active = self.read_active() + if active is None or media_id not in active.generations: + return False + generations = dict(active.generations) + del generations[media_id] + self._publish( + generations=generations, + config_fingerprint=active.config_fingerprint, + configuration=dict(active.configuration), + ) + return True + + def clear(self) -> bool: + active = self.read_active() + if active is None or not active.generations: + return False + self._publish( + generations={}, + config_fingerprint=active.config_fingerprint, + configuration=dict(active.configuration), + ) + return True + + def _publish( + self, + *, + generations: Mapping[str, GenerationReference], + config_fingerprint: str, + configuration: dict[str, Any], + ) -> IndexSnapshot: + snapshot = IndexSnapshot( + snapshot_id=uuid4().hex, + created_at=_utc_now(), + config_fingerprint=config_fingerprint, + configuration=configuration, + generations=dict(generations), + ) + snapshot_path = self._snapshot_path(snapshot.snapshot_id) + if snapshot_path.exists(): + raise FileExistsError( + f"Snapshot {snapshot.snapshot_id} already exists." + ) + write_json_atomic( + snapshot_path, + snapshot.model_dump(mode="json"), + ) + pointer = ActiveSnapshotPointer( + snapshot_id=snapshot.snapshot_id, + snapshot_sha256=sha256_file(snapshot_path), + updated_at=_utc_now(), + ) + write_json_atomic( + self.active_pointer, + pointer.model_dump(mode="json"), + ) + return snapshot + + @staticmethod + def snapshot_configuration(config: IndexConfig) -> dict[str, Any]: + payload = asdict(config) + for key in ( + "video_id", + "output_root", + "storage_directory", + "generation_directory", + "generation_id", + "snapshot_id", + "snapshot_sha256", + ): + payload.pop(key, None) + payload["enabled_modalities"] = list(config.enabled_modalities) + payload["collection_names"] = dict(config.collection_names) + payload["capability_options"] = { + name: dict(values) + for name, values in config.capability_options.items() + } + return payload + + def active_config( + self, + *, + device: str, + ) -> tuple[IndexConfig, IndexSnapshot]: + resolved = self._read_active(required=True) + assert resolved is not None + pointer, snapshot = resolved + if not snapshot.generations: + raise IndexNotReadyError( + "The active index snapshot contains no media." + ) + stored = dict(snapshot.configuration) + stored["enabled_modalities"] = tuple(stored["enabled_modalities"]) + stored["collection_names"] = dict(stored["collection_names"]) + stored.update( + { + "storage_directory": str(self.store), + "snapshot_id": snapshot.snapshot_id, + "snapshot_sha256": pointer.snapshot_sha256, + "device": device, + } + ) + try: + config = IndexConfig(**stored) + except (TypeError, ValueError) as exc: + raise IndexSchemaError( + "The active snapshot configuration is invalid." + ) from exc + if config.fingerprint() != snapshot.config_fingerprint: + raise IndexSchemaError( + "The active snapshot configuration fingerprint is invalid." + ) + return config, snapshot + + def status(self) -> dict[str, Any] | None: + resolved = self._read_active() + if resolved is None: + return None + pointer, snapshot = resolved + media_ids = sorted(snapshot.generations) + ready = bool(media_ids) + return { + "schema_version": 1, + "state": "ready" if ready else "empty", + "stage": "status", + "message": ( + f"The active snapshot contains {len(media_ids)} media item(s)." + if ready + else "The active index snapshot is empty." + ), + "updated_at": snapshot.created_at.isoformat(), + "summary": { + "index_schema_version": INDEX_SCHEMA_VERSION, + "snapshot_id": snapshot.snapshot_id, + "snapshot_sha256": pointer.snapshot_sha256, + "media_count": len(media_ids), + "media_ids": media_ids, + "config_fingerprint": snapshot.config_fingerprint, + "configuration": dict(snapshot.configuration), + }, + } diff --git a/src/vidxp/ports.py b/src/vidxp/ports.py index bd152d4..b568dca 100644 --- a/src/vidxp/ports.py +++ b/src/vidxp/ports.py @@ -65,13 +65,36 @@ def describe(self) -> dict[str, Any]: ... @runtime_checkable -class IndexStore(Protocol): - """Vector records used by capability handlers.""" +class IndexReader(Protocol): + """Read-only vector records available to application queries.""" + + def size_bytes(self) -> int: ... + + def query( + self, + modality: str, + embedding: list[float], + *, + top_k: int, + video_id: str | None = None, + filters: Mapping[str, Any] | None = None, + ) -> list[dict[str, Any]]: ... + + +@runtime_checkable +class IndexStore(IndexReader, Protocol): + """Mutable vector records available only to indexing infrastructure.""" def clear(self, modalities: Iterable[str] | None = None) -> None: ... def delete_video(self, modality: str, video_id: str) -> None: ... + def delete_generation( + self, + generation_id: str, + modalities: Iterable[str] | None = None, + ) -> None: ... + def delete_records( self, modality: str, @@ -80,8 +103,6 @@ def delete_records( filters: Mapping[str, Any] | None = None, ) -> None: ... - def size_bytes(self) -> int: ... - def upsert( self, modality: str, @@ -91,16 +112,6 @@ def upsert( cancellation: CancellationToken, ) -> int: ... - def query( - self, - modality: str, - embedding: list[float], - *, - top_k: int, - video_id: str | None = None, - filters: Mapping[str, Any] | None = None, - ) -> list[dict[str, Any]]: ... - def records( self, modality: str, @@ -134,6 +145,8 @@ def create( def indexing_in_progress(self, config: IndexConfig) -> bool: ... - def open_store(self, config: IndexConfig) -> ContextManager[IndexStore]: ... + def open_store(self, config: IndexConfig) -> ContextManager[IndexReader]: ... + + def remove(self, config: IndexConfig, media_id: str) -> bool: ... - def clear(self, config: IndexConfig) -> None: ... + def clear(self, config: IndexConfig) -> bool: ... diff --git a/src/vidxp/repository_layout.py b/src/vidxp/repository_layout.py index 6694567..bb063e7 100644 --- a/src/vidxp/repository_layout.py +++ b/src/vidxp/repository_layout.py @@ -24,14 +24,16 @@ def indexes(self) -> Path: @property def local_index(self) -> Path: - """Single-index location until generation snapshots land in phase 3.""" - - return self.indexes / "current" + return self.indexes @property def generations(self) -> Path: return self.indexes / "generations" + @property + def index_store(self) -> Path: + return self.indexes / "store" + @property def snapshots(self) -> Path: return self.indexes / "snapshots" @@ -40,6 +42,10 @@ def snapshots(self) -> Path: def active_snapshot(self) -> Path: return self.indexes / "active-snapshot.json" + @property + def index_lease(self) -> Path: + return self.indexes / ".repository.lock" + @property def artifacts(self) -> Path: return self.root / "artifacts" @@ -53,6 +59,9 @@ def ensure_local_directories(self) -> None: self.root, self.media, self.indexes, + self.index_store, + self.generations, + self.snapshots, self.artifacts, self.local_workflows, ): diff --git a/tests/test_actor_results.py b/tests/test_actor_results.py index 0b6c239..5d0430b 100644 --- a/tests/test_actor_results.py +++ b/tests/test_actor_results.py @@ -46,7 +46,6 @@ def test_actor_detection_metadata_is_converted_once(self): self.assertEqual(detections[0].bbox, (1, 4, 5, 0)) storage.records.assert_called_once_with( "actor", - video_id="video-1", filters={"cluster_id": "3"}, ) storage.close.assert_not_called() @@ -54,20 +53,52 @@ def test_actor_detection_metadata_is_converted_once(self): def test_actor_clusters_summarize_detection_ranges(self): storage = Mock() storage.records.return_value = [ - {"cluster_id": "1", "timestamp": 4.5}, - {"cluster_id": "1", "timestamp": 1.5}, - {"cluster_id": "2", "timestamp": 9.0}, + { + "cluster_id": "video-1:1", + "video_id": "video-1", + "timestamp": 4.5, + }, + { + "cluster_id": "video-1:1", + "video_id": "video-1", + "timestamp": 1.5, + }, + { + "cluster_id": "video-2:1", + "video_id": "video-2", + "timestamp": 9.0, + }, ] clusters = actor_clusters(self.config, storage=storage) self.assertEqual( [cluster.cluster_id for cluster in clusters], - ["1", "2"], + ["video-1:1", "video-2:1"], ) self.assertEqual(clusters[0].detection_count, 2) self.assertEqual(clusters[0].first_timestamp, 1.5) self.assertEqual(clusters[0].last_timestamp, 4.5) + self.assertEqual(clusters[1].video_id, "video-2") + storage.records.assert_called_once_with("actor") + + def test_actor_clusters_reject_cross_media_identity_collision(self): + storage = Mock() + storage.records.return_value = [ + { + "cluster_id": "legacy-cluster", + "video_id": "video-1", + "timestamp": 1.0, + }, + { + "cluster_id": "legacy-cluster", + "video_id": "video-2", + "timestamp": 2.0, + }, + ] + + with self.assertRaisesRegex(RuntimeError, "multiple index identities"): + actor_clusters(self.config, storage=storage) def test_render_actor_result_rejects_an_empty_cluster(self): storage = Mock() @@ -109,6 +140,7 @@ def test_render_actor_result_returns_output_details(self): "split": "local", "run_id": "default", "video_id": "video-1", + "generation_id": "generation-1", "modality": "actor", "source_id": "actor:d2", } @@ -129,6 +161,10 @@ def test_render_actor_result_returns_output_details(self): renderer.assert_called_once() self.assertEqual(result.output_path, output) self.assertEqual(result.detection_count, 1) + self.assertEqual( + renderer.call_args.args[3][0].generation_id, + "generation-1", + ) if __name__ == "__main__": diff --git a/tests/test_application.py b/tests/test_application.py index 7d15f26..e08f13f 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -16,9 +16,10 @@ DependencyUnavailableError, IndexResult, PrepareModelsCommand, + RemoveIndexCommand, SearchCommand, ) -from vidxp.core.contracts import IndexConfig +from vidxp.core.contracts import IndexConfig, IndexSchemaError from vidxp.infrastructure.local_index import LocalIndexBackend from vidxp.capabilities.contracts import ( CapabilityDefinition, @@ -144,7 +145,7 @@ def test_missing_index_has_shared_status_model(self): self.assertEqual(status.state, "missing") self.assertEqual(status.schema_version, 1) - self.assertEqual(status.index_directory, Path("missing/indexes/current")) + self.assertEqual(status.index_directory, Path("missing/indexes")) def test_create_index_builds_one_central_config(self): with TemporaryDirectory() as directory: @@ -577,72 +578,130 @@ def test_clear_delegates_all_persistence_cleanup_to_backend(self): self.assertTrue((index / "manifest.json").exists()) self.assertTrue(unrelated.exists()) - def test_local_backend_owns_vector_and_metadata_cleanup(self): - with TemporaryDirectory() as directory: - index = Path(directory) / "index" - index.mkdir() - (index / "manifest.json").write_text("{}", encoding="utf-8") - unrelated = index / "keep.txt" - unrelated.write_text("keep", encoding="utf-8") - settings = VidXPSettings( - repository_root=directory, - runtime_backend="cpu", - ) - registry = create_capability_registry() - backend = LocalIndexBackend(registry, ModelRuntime(settings)) - storage = MagicMock() - storage.__enter__.return_value = storage - config = IndexConfig.local(storage_directory=index) + def test_remove_delegates_to_index_backend(self): + application, backend = self.application("repository") + backend.remove.return_value = True - with patch( - "vidxp.infrastructure.local_index.IndexStorage", - return_value=storage, - ): - backend.clear(config) - - storage.clear.assert_called_once_with() - self.assertFalse((index / "manifest.json").exists()) - self.assertTrue(unrelated.exists()) + self.assertTrue( + application.remove_from_index( + RemoveIndexCommand(media_id="episode-1") + ) + ) - def test_local_backend_injects_and_closes_storage_for_indexing(self): - settings = VidXPSettings( - repository_root="unused", - runtime_backend="cpu", + config, media_id = backend.remove.call_args.args + self.assertEqual( + Path(config.storage_directory), + Path("repository") / "indexes", ) - registry = create_capability_registry() - backend = LocalIndexBackend(registry, ModelRuntime(settings)) - storage = MagicMock() - storage.__enter__.return_value = storage + self.assertEqual(media_id, "episode-1") + + def test_generation_storage_validation_checks_identity_and_count(self): config = IndexConfig.local( + video_id="episode-1", + generation_id="generation-1", enabled_modalities=("scene",), collection_names={"scene": "scene"}, ) + storage = Mock() + storage.records.return_value = [ + { + "generation_id": "generation-1", + "video_id": "episode-1", + "modality": "scene", + } + ] + LocalIndexBackend._validate_generation_records( + storage, + config, + {"scene": 1}, + ) - with ( - patch( - "vidxp.infrastructure.local_index.IndexStorage", - return_value=storage, - ), - patch( - "vidxp.infrastructure.local_index.index_video", - return_value={"scene_frames": 1}, - ) as index_video, + with self.assertRaisesRegex( + IndexSchemaError, + "record count", ): - result = backend.create( - Path("video.mp4"), - config=config, - progress=None, - cancellation=None, - source_name=None, + LocalIndexBackend._validate_generation_records( + storage, + config, + {"scene": 2}, ) - self.assertEqual(result, {"scene_frames": 1}) - self.assertIs(index_video.call_args.kwargs["storage"], storage) - self.assertIs( - index_video.call_args.kwargs["manifest_store"].runtime, - backend.runtime, - ) - storage.__exit__.assert_called_once() + def test_local_backend_injects_storage_for_generation_build(self): + with TemporaryDirectory() as directory: + settings = VidXPSettings( + repository_root=directory, + runtime_backend="cpu", + ) + registry = create_capability_registry() + backend = LocalIndexBackend( + registry, + ModelRuntime(settings), + settings.layout, + ) + storage = MagicMock() + storage.__enter__.return_value = storage + config = IndexConfig.local( + enabled_modalities=("scene",), + collection_names={"scene": "scene"}, + storage_directory=settings.layout.indexes, + ) + snapshot = Mock( + snapshot_id="a" * 32, + generations={"media": Mock()}, + ) + with ( + patch( + "vidxp.infrastructure.local_index.IndexStorage", + return_value=storage, + ), + patch( + "vidxp.infrastructure.local_index.index_video", + return_value={"scene_frames": 1}, + ) as index_video, + patch( + "vidxp.infrastructure.local_index.source_checksum", + return_value="1" * 64, + ), + patch( + "vidxp.infrastructure.local_index.LocalSnapshotRepository." + "generation_reference", + return_value=Mock(), + ), + patch( + "vidxp.infrastructure.local_index.LocalSnapshotRepository." + "publish_generation", + return_value=snapshot, + ), + patch.object( + LocalIndexBackend, + "_validate_generation_records", + return_value={"scene": 1}, + ), + patch( + "vidxp.infrastructure.local_index.ManifestStore." + "record_storage_counts", + ), + ): + result = backend.create( + Path("video.mp4"), + config=config, + progress=None, + cancellation=None, + source_name=None, + ) + + self.assertEqual(result["scene_frames"], 1) + self.assertEqual(result["media_id"], "1" * 64) + self.assertEqual(result["snapshot_id"], "a" * 32) + self.assertIs(index_video.call_args.kwargs["storage"], storage) + self.assertIs( + index_video.call_args.kwargs["manifest_store"].runtime, + backend.runtime, + ) + self.assertIsNotNone( + index_video.call_args.kwargs["config"].generation_id + ) + storage.__exit__.assert_called_once() if __name__ == "__main__": diff --git a/tests/test_cli.py b/tests/test_cli.py index 2fb460d..36266d1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -15,6 +15,7 @@ IndexResult, IndexStatus, PrepareModelsResult, + RemoveIndexCommand, SearchCommand, ) from vidxp.capabilities.registry import create_capability_registry @@ -27,7 +28,7 @@ def setUp(self): self.runner = CliRunner() self.service = Mock() self.service.registry = create_capability_registry() - self.service.index_directory = Path("repo/indexes/current") + self.service.index_directory = Path("repo/indexes") self.service.layout.root = Path("repo") self.service.runtime.backends.requested = "cpu" self.registry = Mock(spec=RepositoryRegistry) @@ -105,6 +106,36 @@ def test_index_constructs_shared_command(self): self.assertIsInstance(command, CreateIndexCommand) self.assertEqual(command.modalities, ("scene",)) self.assertEqual(command.frame_stride, 5) + self.assertIsNone(command.media_id) + + def test_media_id_and_remove_use_shared_commands(self): + with TemporaryDirectory() as directory: + video = Path(directory) / "video.mp4" + video.write_bytes(b"video") + self.service.create_index.return_value = IndexResult(summary={}) + created = self.invoke( + [ + "--format", + "json", + "index", + "create", + str(video), + "--media-id", + "episode-1", + ] + ) + + self.assertEqual(created.exit_code, 0, created.output) + self.assertEqual( + self.service.create_index.call_args.args[0].media_id, + "episode-1", + ) + self.service.remove_from_index.return_value = True + removed = self.invoke(["index", "remove", "episode-1", "--json"]) + self.assertEqual(removed.exit_code, 0, removed.output) + self.service.remove_from_index.assert_called_once_with( + RemoveIndexCommand(media_id="episode-1") + ) def test_status_serializes_shared_model(self): self.service.index_status.return_value = IndexStatus( @@ -113,7 +144,7 @@ def test_status_serializes_shared_model(self): stage="status", message="No index.", repository_root=Path("repo"), - index_directory=Path("repo/indexes/current"), + index_directory=Path("repo/indexes"), ) result = self.invoke(["index", "status", "--json"]) diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 33fa14e..92e61af 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -23,6 +23,15 @@ def test_stable_source_ids_escape_delimiters_without_collisions(self): stable_source_id("r", "فيديو:1", "scene", "0"), ) self.assertEqual(first.count(":"), 3) + scoped = stable_source_id( + "run:a", + "video", + "scene", + "1", + generation_id="generation-1", + ) + self.assertNotEqual(first, scoped) + self.assertEqual(scoped.count(":"), 4) def test_config_is_validated_and_run_paths_are_isolated(self): with TemporaryDirectory() as directory: @@ -59,6 +68,17 @@ def test_config_is_validated_and_run_paths_are_isolated(self): enabled_modalities=("scene",), ) self.assertEqual(first.fingerprint(), relocated.fingerprint()) + generation_directory = Path(directory) / "generation" + generated = IndexConfig.local( + storage_directory=Path(directory) / "store", + generation_directory=generation_directory, + generation_id="generation-1", + ) + self.assertEqual(generated.run_directory, generation_directory) + self.assertEqual( + generated.index_directory, + Path(directory) / "store", + ) def test_path_objects_are_normalized_for_manifest_serialization(self): config = IndexConfig.local( diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 2058b06..00726b6 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -114,7 +114,7 @@ def test_search_uses_shared_command_and_returns_timestamp(self): stage="complete", message="ready", repository_root=root, - index_directory=root / "indexes/current", + index_directory=root / "indexes", ) service.search.return_value = SearchResult( query_id="scene:1", diff --git a/tests/test_generation_manifest.py b/tests/test_generation_manifest.py new file mode 100644 index 0000000..96b73d6 --- /dev/null +++ b/tests/test_generation_manifest.py @@ -0,0 +1,184 @@ +import json +import unittest +from copy import deepcopy +from datetime import datetime, timezone +from uuid import uuid1, uuid4 + +from pydantic import ValidationError + +from vidxp.core.contracts import ( + INDEX_SCHEMA_VERSION, + MANIFEST_SCHEMA_VERSION, +) +from vidxp.core.generations import CompletedGenerationManifest + + +SHA256 = "a" * 64 +OTHER_SHA256 = "b" * 64 + + +def completed_manifest() -> dict: + generation_id = uuid4().hex + created_at = datetime(2026, 7, 29, tzinfo=timezone.utc).isoformat() + completed_at = datetime(2026, 7, 29, 0, 1, tzinfo=timezone.utc).isoformat() + return { + "manifest_schema_version": MANIFEST_SCHEMA_VERSION, + "index_schema_version": INDEX_SCHEMA_VERSION, + "dataset": "local", + "split": "local", + "run_id": "default", + "generation_id": generation_id, + "state": "complete", + "created_at": created_at, + "updated_at": completed_at, + "completed_at": completed_at, + "config_fingerprint": SHA256, + "execution_fingerprint": OTHER_SHA256, + "configuration": { + "enabled_modalities": ["scene", "dialogue"], + "frame_stride": 1, + }, + "models": {"runtime": {"requested": "cpu"}}, + "git": {"commit": None, "dirty": None}, + "environment": { + "python": "3.13", + "dependencies": {"chromadb": "1.5.9"}, + }, + "inputs": { + "episode-1": { + "sha256": SHA256, + "checksums": {"video": SHA256}, + "size": 1024, + "source_name": "episode.mp4", + "path": "/media/episode.mp4", + "metadata": {}, + } + }, + "videos": { + "episode-1": { + "state": "complete", + "started_at": created_at, + "stages": { + "scene": { + "video_id": "episode-1", + "stage": "scene", + "seconds": 1.5, + "stats": {"scene_frames": 4}, + "recorded_at": completed_at, + } + }, + "completed_at": completed_at, + "summary": {"processed_frames": 4}, + } + }, + "completed_videos": ["episode-1"], + "failed_videos": [], + "interrupted_videos": [], + "processed_frames": 4, + "record_counts": {"scene": 4, "dialogue": 2}, + "store_size_bytes_at_commit": 4096, + } + + +class CompletedGenerationManifestTests(unittest.TestCase): + def validate(self, payload: dict) -> CompletedGenerationManifest: + return CompletedGenerationManifest.model_validate_json( + json.dumps(payload) + ) + + def test_valid_completed_manifest_is_strict_frozen_and_json_safe(self): + manifest = self.validate(completed_manifest()) + + self.assertEqual(manifest.state, "complete") + self.assertEqual(manifest.record_counts["scene"], 4) + with self.assertRaises(ValidationError): + manifest.state = "running" + + invalid = completed_manifest() + invalid["unexpected"] = True + with self.assertRaises(ValidationError): + self.validate(invalid) + + invalid = completed_manifest() + invalid["store_size_bytes_at_commit"] = "4096" + with self.assertRaises(ValidationError): + CompletedGenerationManifest.model_validate(invalid) + + invalid = completed_manifest() + invalid["models"] = {"runtime": object()} + with self.assertRaises(ValidationError): + CompletedGenerationManifest.model_validate(invalid) + + def test_requires_versions_uuid4_complete_state_and_aware_timestamps(self): + invalid_values = ( + ("manifest_schema_version", MANIFEST_SCHEMA_VERSION + 1), + ("index_schema_version", INDEX_SCHEMA_VERSION + 1), + ("generation_id", uuid1().hex), + ("generation_id", str(uuid4())), + ("state", "running"), + ("completed_at", "2026-07-29T00:01:00"), + ) + for field, value in invalid_values: + with self.subTest(field=field, value=value): + payload = completed_manifest() + payload[field] = value + with self.assertRaises(ValidationError): + self.validate(payload) + + def test_requires_one_consistent_completed_media_without_failures(self): + mutations = [] + + second_input = deepcopy(completed_manifest()["inputs"]["episode-1"]) + mutations.append( + lambda payload: payload["inputs"].update( + {"episode-2": second_input} + ) + ) + mutations.append( + lambda payload: payload.update( + {"completed_videos": ["different-media"]} + ) + ) + mutations.append( + lambda payload: payload["videos"]["episode-1"].update( + {"state": "failed"} + ) + ) + mutations.append( + lambda payload: payload.update({"failed_videos": ["episode-1"]}) + ) + mutations.append( + lambda payload: payload.update( + {"interrupted_videos": ["episode-1"]} + ) + ) + + for mutate in mutations: + payload = completed_manifest() + mutate(payload) + with self.assertRaises(ValidationError): + self.validate(payload) + + def test_record_counts_exactly_match_modalities_and_sizes_are_nonnegative(self): + invalid_counts = ( + {"scene": 4}, + {"scene": 4, "dialogue": 2, "actor": 1}, + {"scene": -1, "dialogue": 2}, + ) + for record_counts in invalid_counts: + with self.subTest(record_counts=record_counts): + payload = completed_manifest() + payload["record_counts"] = record_counts + with self.assertRaises(ValidationError): + self.validate(payload) + + for field in ("processed_frames", "store_size_bytes_at_commit"): + with self.subTest(field=field): + payload = completed_manifest() + payload[field] = -1 + with self.assertRaises(ValidationError): + self.validate(payload) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_index_state.py b/tests/test_index_state.py index 9395802..3103c85 100644 --- a/tests/test_index_state.py +++ b/tests/test_index_state.py @@ -4,24 +4,14 @@ from pathlib import Path from vidxp.index_state import ( - IndexNotReadyError, fingerprint_file, read_index_status, - require_ready_index, write_index_status, ) class IndexStateTests(unittest.TestCase): - def test_missing_status_is_not_ready(self): - with tempfile.TemporaryDirectory() as directory: - with self.assertRaisesRegex( - IndexNotReadyError, - "Index a video before searching", - ): - require_ready_index(directory) - - def test_status_round_trip_and_readiness_guard(self): + def test_progress_status_round_trip(self): with tempfile.TemporaryDirectory() as directory: write_index_status( state="indexing", @@ -35,8 +25,6 @@ def test_status_round_trip_and_readiness_guard(self): status = read_index_status(directory) self.assertEqual(status["state"], "indexing") self.assertEqual(status["current"], 5) - with self.assertRaises(IndexNotReadyError): - require_ready_index(directory) write_index_status( state="ready", @@ -45,7 +33,7 @@ def test_status_round_trip_and_readiness_guard(self): summary={"scene_frames": 10}, index_directory=directory, ) - self.assertEqual(require_ready_index(directory)["state"], "ready") + self.assertEqual(read_index_status(directory)["state"], "ready") def test_unreadable_status_is_failed(self): with tempfile.TemporaryDirectory() as directory: @@ -54,8 +42,6 @@ def test_unreadable_status_is_failed(self): status = read_index_status(directory) self.assertEqual(status["state"], "failed") - with self.assertRaises(IndexNotReadyError): - require_ready_index(directory) def test_file_fingerprint_is_stable(self): with tempfile.TemporaryDirectory() as directory: diff --git a/tests/test_indexing.py b/tests/test_indexing.py index 55dd175..5079366 100644 --- a/tests/test_indexing.py +++ b/tests/test_indexing.py @@ -4,7 +4,10 @@ import numpy as np -from vidxp.capabilities.actor.indexing import _actor_records +from vidxp.capabilities.actor.indexing import ( + _actor_cluster_id, + _actor_records, +) from vidxp.capabilities.contracts import ( CapabilityDefinition, CapabilityExecutor, @@ -287,13 +290,15 @@ def test_actor_records_preserve_stable_detection_metadata(self): split="test", run_id="actors", video_id="video-1", + generation_id="generation-1", enabled_modalities=("actor",), ) + cluster_id = _actor_cluster_id(config, 1) records = _actor_records( [ { "detection_id": "d000000000000-0000", - "cluster_id": "1", + "cluster_id": cluster_id, "frame_index": 0, "timestamp": 0.0, "bbox": (1, 2, 3, 0), @@ -302,9 +307,30 @@ def test_actor_records_preserve_stable_detection_metadata(self): config, ) - self.assertEqual(records[0].metadata["cluster_id"], "1") + self.assertEqual(records[0].metadata["cluster_id"], cluster_id) + self.assertEqual( + cluster_id, + "generation-1:actors:video-1:actor-cluster:1", + ) self.assertEqual(records[0].metadata["bbox_top"], 1) + def test_actor_cluster_identity_is_unique_by_media_and_generation(self): + def config(video_id, generation_id): + return IndexConfig( + run_id="actors", + video_id=video_id, + generation_id=generation_id, + enabled_modalities=("actor",), + ) + + identities = { + _actor_cluster_id(config("video-1", "generation-1"), 1), + _actor_cluster_id(config("video-2", "generation-1"), 1), + _actor_cluster_id(config("video-1", "generation-2"), 1), + } + + self.assertEqual(len(identities), 3) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_local_snapshots.py b/tests/test_local_snapshots.py new file mode 100644 index 0000000..b4af06d --- /dev/null +++ b/tests/test_local_snapshots.py @@ -0,0 +1,623 @@ +import json +import unittest +from dataclasses import replace +from datetime import datetime, timezone +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch +from unittest.mock import MagicMock, Mock + +from vidxp.core.contracts import ( + CancellationToken, + INDEX_SCHEMA_VERSION, + MANIFEST_SCHEMA_VERSION, + IndexConfig, + IndexCancelledError, + IndexSchemaError, + StorageRecord, +) +from vidxp.core.storage import IndexStorage +from vidxp.core.manifest import MANIFEST_FILE, write_json_atomic +from vidxp.infrastructure.local_snapshots import LocalSnapshotRepository +from vidxp.infrastructure.local_index import LocalIndexBackend + + +class LocalSnapshotRepositoryTests(unittest.TestCase): + def setUp(self): + self.temporary = TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.repository = LocalSnapshotRepository( + Path(self.temporary.name) / "indexes" + ) + self.config = IndexConfig.local( + enabled_modalities=("scene",), + collection_names={"scene": "scene"}, + storage_directory=self.repository.store, + ) + + def generation(self, media_id: str, *, input_sha: str): + generation_id = self.repository.new_generation_id() + config = replace( + self.config, + video_id=media_id, + generation_id=generation_id, + generation_directory=self.repository.generation_directory( + generation_id + ), + ) + return config, self.write_generation( + config, + media_id=media_id, + input_sha=input_sha, + ) + + def write_generation( + self, + config: IndexConfig, + *, + media_id: str, + input_sha: str, + record_counts: dict[str, int] | None = None, + ): + now = datetime.now(timezone.utc).isoformat() + manifest = { + "manifest_schema_version": MANIFEST_SCHEMA_VERSION, + "index_schema_version": INDEX_SCHEMA_VERSION, + "dataset": config.dataset, + "split": config.split, + "run_id": config.run_id, + "generation_id": config.generation_id, + "state": "complete", + "created_at": now, + "updated_at": now, + "completed_at": now, + "config_fingerprint": config.fingerprint(), + "execution_fingerprint": "e" * 64, + "configuration": config.to_dict(), + "models": {}, + "git": {}, + "environment": {}, + "inputs": { + media_id: { + "sha256": input_sha, + "checksums": {"video": input_sha}, + "size": 1, + "source_name": f"{media_id}.mp4", + "path": None, + "metadata": {}, + } + }, + "videos": { + media_id: { + "state": "complete", + "started_at": now, + "stages": {}, + "completed_at": now, + "summary": {}, + } + }, + "completed_videos": [media_id], + "failed_videos": [], + "interrupted_videos": [], + "processed_frames": 0, + "record_counts": { + modality: (record_counts or {}).get(modality, 0) + for modality in config.enabled_modalities + }, + "store_size_bytes_at_commit": 123, + } + write_json_atomic( + config.run_directory / MANIFEST_FILE, + manifest, + ) + return self.repository.generation_reference( + generation_id=str(config.generation_id), + media_id=media_id, + ) + + def test_add_reindex_remove_and_clear_publish_immutable_snapshots(self): + config_a1, a1 = self.generation("a", input_sha="a" * 64) + snapshot_a1 = self.repository.publish_generation(a1, config_a1) + original_a1 = ( + self.repository.snapshots / f"{snapshot_a1.snapshot_id}.json" + ).read_bytes() + + config_b1, b1 = self.generation("b", input_sha="b" * 64) + snapshot_b1 = self.repository.publish_generation(b1, config_b1) + self.assertEqual( + set(snapshot_b1.generations), + {"a", "b"}, + ) + + config_a2, a2 = self.generation("a", input_sha="c" * 64) + snapshot_a2 = self.repository.publish_generation(a2, config_a2) + self.assertEqual( + snapshot_a2.generations["a"].generation_id, + a2.generation_id, + ) + self.assertEqual( + snapshot_a2.generations["b"].generation_id, + b1.generation_id, + ) + self.assertEqual( + ( + self.repository.snapshots + / f"{snapshot_a1.snapshot_id}.json" + ).read_bytes(), + original_a1, + ) + self.assertTrue( + self.repository.generation_directory(a1.generation_id).is_dir() + ) + + self.assertTrue(self.repository.remove("a")) + self.assertEqual( + set(self.repository.read_active(required=True).generations), + {"b"}, + ) + self.assertFalse(self.repository.remove("missing")) + self.assertTrue(self.repository.clear()) + self.assertEqual( + self.repository.status()["state"], + "empty", + ) + self.assertFalse(self.repository.clear()) + + def test_pointer_failure_preserves_previous_active_snapshot(self): + config_a1, a1 = self.generation("a", input_sha="a" * 64) + previous = self.repository.publish_generation(a1, config_a1) + pointer_before = self.repository.active_pointer.read_bytes() + config_a2, a2 = self.generation("a", input_sha="b" * 64) + + real_write = write_json_atomic + + def fail_pointer(path, payload): + if path == self.repository.active_pointer: + raise OSError("injected pointer failure") + return real_write(path, payload) + + with ( + patch( + "vidxp.infrastructure.local_snapshots.write_json_atomic", + side_effect=fail_pointer, + ), + self.assertRaisesRegex(OSError, "injected"), + ): + self.repository.publish_generation(a2, config_a2) + + self.assertEqual( + self.repository.active_pointer.read_bytes(), + pointer_before, + ) + self.assertEqual( + self.repository.read_active(required=True).snapshot_id, + previous.snapshot_id, + ) + self.assertTrue( + self.repository.generation_directory(a2.generation_id).is_dir() + ) + + def test_corrupt_or_missing_generation_fails_closed(self): + config, reference = self.generation("a", input_sha="a" * 64) + self.repository.publish_generation(reference, config) + manifest = ( + self.repository.generation_directory(reference.generation_id) + / MANIFEST_FILE + ) + manifest.write_text("{}", encoding="utf-8") + + with self.assertRaisesRegex(IndexSchemaError, "integrity"): + self.repository.read_active(required=True) + + def test_repository_lease_is_scoped_to_one_repository(self): + other = LocalSnapshotRepository( + Path(self.temporary.name) / "other" / "indexes" + ) + with self.repository.lease(): + self.assertTrue(self.repository.mutation_in_progress()) + self.assertFalse(other.mutation_in_progress()) + + def test_snapshot_configuration_moves_between_runtime_devices(self): + config, reference = self.generation("a", input_sha="a" * 64) + snapshot = self.repository.publish_generation(reference, config) + + active, loaded = self.repository.active_config(device="cuda") + + self.assertEqual(loaded.snapshot_id, snapshot.snapshot_id) + self.assertEqual(active.device, "cuda") + self.assertEqual(len(active.snapshot_sha256), 64) + self.assertEqual( + active.fingerprint(), + snapshot.config_fingerprint, + ) + + def test_profile_mismatch_is_rejected_before_replacing_other_media(self): + config_a, a = self.generation("a", input_sha="a" * 64) + self.repository.publish_generation(a, config_a) + config_b, b = self.generation("b", input_sha="b" * 64) + self.repository.publish_generation(b, config_b) + + changed = replace( + config_a, + frame_stride=2, + generation_id=self.repository.new_generation_id(), + ) + changed = replace( + changed, + generation_directory=self.repository.generation_directory( + changed.generation_id + ), + ) + changed_ref = self.write_generation( + changed, + media_id="a", + input_sha="c" * 64, + ) + with self.assertRaisesRegex(IndexSchemaError, "same index profile"): + self.repository.publish_generation(changed_ref, changed) + + def test_snapshot_documents_are_canonical_json_objects(self): + config, reference = self.generation("a", input_sha="a" * 64) + snapshot = self.repository.publish_generation(reference, config) + payload = json.loads( + ( + self.repository.snapshots / f"{snapshot.snapshot_id}.json" + ).read_text(encoding="utf-8") + ) + self.assertEqual(payload["snapshot_id"], snapshot.snapshot_id) + + def test_build_failure_or_cancellation_preserves_active_snapshot(self): + config, reference = self.generation("a", input_sha="a" * 64) + active = self.repository.publish_generation(reference, config) + pointer_before = self.repository.active_pointer.read_bytes() + backend = LocalIndexBackend( + Mock(), + Mock(), + self.repository.layout, + ) + build = IndexConfig.local( + enabled_modalities=("scene",), + collection_names={"scene": "scene"}, + storage_directory=self.repository.indexes, + ) + + for failure in ( + RuntimeError("injected build failure"), + IndexCancelledError("injected cancellation"), + ): + storage = MagicMock() + storage.__enter__.return_value = storage + storage.count_records.return_value = 0 + + def fail_index(*_args, **kwargs): + write_json_atomic( + kwargs["config"].run_directory / MANIFEST_FILE, + {"state": "interrupted"}, + ) + raise failure + + with ( + self.subTest(failure=type(failure).__name__), + patch( + "vidxp.infrastructure.local_index.IndexStorage", + return_value=storage, + ), + patch( + "vidxp.infrastructure.local_index.source_checksum", + return_value="b" * 64, + ), + patch( + "vidxp.infrastructure.local_index.index_video", + side_effect=fail_index, + ), + self.assertRaises(type(failure)), + ): + backend.create( + Path("video.mp4"), + config=build, + progress=None, + cancellation=None, + source_name=None, + ) + + self.assertEqual( + self.repository.active_pointer.read_bytes(), + pointer_before, + ) + self.assertEqual( + self.repository.read_active(required=True).snapshot_id, + active.snapshot_id, + ) + retained = { + path.name + for path in self.repository.generations.iterdir() + if path.is_dir() + } + self.assertEqual(retained, {reference.generation_id}) + + def test_reader_can_finish_against_snapshot_resolved_before_reindex(self): + config_a1, a1 = self.generation("a", input_sha="a" * 64) + first = self.repository.publish_generation(a1, config_a1) + old_config, _ = self.repository.active_config(device="cpu") + config_a2, a2 = self.generation("a", input_sha="b" * 64) + self.repository.publish_generation(a2, config_a2) + backend = LocalIndexBackend( + Mock(), + Mock(), + self.repository.layout, + ) + storage = MagicMock() + storage.records.return_value = [] + storage.count_records.return_value = 0 + + with patch( + "vidxp.infrastructure.local_index.IndexStorage", + return_value=storage, + ): + scoped_store = backend.open_store(old_config) + storage.reset_mock() + with scoped_store as scoped: + scoped.records("scene") + + self.assertNotEqual( + self.repository.read_active(required=True).snapshot_id, + first.snapshot_id, + ) + storage.records.assert_called_once_with( + "scene", + video_id=None, + generation_ids=(a1.generation_id,), + filters=None, + ) + + def test_reader_rejects_tampered_resolved_snapshot(self): + config, reference = self.generation("a", input_sha="a" * 64) + self.repository.publish_generation(reference, config) + resolved, _ = self.repository.active_config(device="cpu") + snapshot_path = ( + self.repository.snapshots / f"{resolved.snapshot_id}.json" + ) + snapshot_path.write_text("{}\n", encoding="utf-8") + backend = LocalIndexBackend( + Mock(), + Mock(), + self.repository.layout, + ) + + with self.assertRaisesRegex(IndexSchemaError, "integrity"): + backend.open_store(resolved) + + def test_reader_fails_closed_after_committed_record_loss(self): + generation_id = self.repository.new_generation_id() + config = replace( + self.config, + video_id="a", + generation_id=generation_id, + generation_directory=self.repository.generation_directory( + generation_id + ), + ) + with IndexStorage(config) as storage: + storage.upsert( + "scene", + [ + StorageRecord( + source_id="source-1", + embedding=[1.0, 0.0], + metadata={ + **config.record_identity("scene", "source-1"), + }, + ) + ], + batch_size=1, + cancellation=CancellationToken(), + ) + reference = self.write_generation( + config, + media_id="a", + input_sha="a" * 64, + record_counts={"scene": 1}, + ) + self.repository.publish_generation(reference, config) + active, _ = self.repository.active_config(device="cpu") + runtime = Mock() + runtime.backends.torch_device = "cpu" + backend = LocalIndexBackend( + Mock(), + runtime, + self.repository.layout, + ) + + with backend.open_store(active): + pass + with IndexStorage(config) as storage: + storage.delete_generation(generation_id) + + with self.assertRaisesRegex(IndexSchemaError, "record counts"): + backend.open_store(active) + with self.assertRaisesRegex(IndexSchemaError, "record counts"): + backend.status(self.repository.indexes) + + def test_cancellation_after_validation_does_not_publish(self): + config_a, reference_a = self.generation("a", input_sha="a" * 64) + previous = self.repository.publish_generation(reference_a, config_a) + pointer_before = self.repository.active_pointer.read_bytes() + runtime = Mock() + runtime.backends.torch_device = "cpu" + backend = LocalIndexBackend( + Mock(), + runtime, + self.repository.layout, + ) + build = IndexConfig.local( + enabled_modalities=("scene",), + collection_names={"scene": "scene"}, + storage_directory=self.repository.indexes, + ) + storage = MagicMock() + storage.__enter__.return_value = storage + storage.records.return_value = [] + storage.count_records.return_value = 0 + cancellation = Mock() + cancellation.raise_if_cancelled.side_effect = ( + None, + None, + IndexCancelledError("late cancellation"), + ) + + def complete_index(*_args, **kwargs): + self.write_generation( + kwargs["config"], + media_id="b" * 64, + input_sha="b" * 64, + ) + return {} + + with ( + patch( + "vidxp.infrastructure.local_index.IndexStorage", + return_value=storage, + ), + patch( + "vidxp.infrastructure.local_index.source_checksum", + return_value="b" * 64, + ), + patch( + "vidxp.infrastructure.local_index.index_video", + side_effect=complete_index, + ), + self.assertRaises(IndexCancelledError), + ): + backend.create( + Path("video.mp4"), + config=build, + progress=None, + cancellation=cancellation, + source_name=None, + ) + + self.assertEqual( + self.repository.active_pointer.read_bytes(), + pointer_before, + ) + self.assertEqual( + self.repository.read_active(required=True).snapshot_id, + previous.snapshot_id, + ) + + def test_abandoned_generation_directory_and_records_are_cleaned(self): + generation_id = self.repository.new_generation_id() + generation_directory = self.repository.generation_directory( + generation_id + ) + write_json_atomic( + generation_directory / MANIFEST_FILE, + {"state": "running", "generation_id": generation_id}, + ) + config = IndexConfig.local( + video_id="a", + generation_id=generation_id, + generation_directory=generation_directory, + enabled_modalities=("scene",), + collection_names={"scene": "scene"}, + storage_directory=self.repository.store, + ) + with IndexStorage(config) as storage: + storage.upsert( + "scene", + [ + StorageRecord( + source_id="abandoned-source", + embedding=[1.0], + metadata={ + **config.record_identity( + "scene", + "abandoned-source", + ), + }, + ) + ], + batch_size=1, + cancellation=CancellationToken(), + ) + + LocalIndexBackend._cleanup_abandoned_generations( + self.repository, + config, + ) + + self.assertFalse(generation_directory.exists()) + with IndexStorage(config, create=False) as storage: + self.assertEqual(storage.records("scene"), []) + + def test_real_chroma_reader_remains_pinned_across_reindex(self): + references = [] + for input_sha, embedding in ( + ("a" * 64, [1.0, 0.0]), + ("b" * 64, [0.0, 1.0]), + ): + generation_id = self.repository.new_generation_id() + config = replace( + self.config, + video_id="a", + generation_id=generation_id, + generation_directory=self.repository.generation_directory( + generation_id + ), + ) + with IndexStorage(config) as storage: + storage.upsert( + "scene", + [ + StorageRecord( + source_id=f"source-{generation_id}", + embedding=embedding, + metadata={ + **config.record_identity( + "scene", + f"source-{generation_id}", + ), + }, + ) + ], + batch_size=1, + cancellation=CancellationToken(), + ) + reference = self.write_generation( + config, + media_id="a", + input_sha=input_sha, + record_counts={"scene": 1}, + ) + snapshot = self.repository.publish_generation(reference, config) + active_config, _ = self.repository.active_config(device="cpu") + references.append( + (reference, snapshot, active_config) + ) + + runtime = Mock() + runtime.backends.torch_device = "cpu" + backend = LocalIndexBackend( + Mock(), + runtime, + self.repository.layout, + ) + first_reference, _, first_config = references[0] + second_reference, _, second_config = references[1] + with backend.open_store(first_config) as first_reader: + first_records = first_reader.records("scene") + with backend.open_store(second_config) as second_reader: + second_records = second_reader.records("scene") + + self.assertEqual( + {record["generation_id"] for record in first_records}, + {first_reference.generation_id}, + ) + self.assertEqual( + {record["generation_id"] for record in second_records}, + {second_reference.generation_id}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 0b855e2..18ef357 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -7,6 +7,7 @@ from vidxp.core.contracts import IndexConfig, VideoSource from vidxp.core.manifest import ( ManifestStore, + _sync_parent_directory, source_checksums, write_json_atomic, ) @@ -16,6 +17,35 @@ class ManifestIdentityTests(unittest.TestCase): + def test_unsupported_directory_fsync_does_not_false_fail_commit(self): + with ( + patch("vidxp.core.manifest.os.name", "posix"), + patch("vidxp.core.manifest.os.open", return_value=17), + patch( + "vidxp.core.manifest.os.fsync", + side_effect=OSError("unsupported"), + ), + patch("vidxp.core.manifest.os.close") as close, + ): + _sync_parent_directory(Path("repository")) + + close.assert_called_once_with(17) + + def test_atomic_write_leaves_no_temporary_file(self): + with TemporaryDirectory() as directory: + path = Path(directory) / "manifest.json" + + write_json_atomic(path, {"state": "ready"}) + + self.assertEqual( + path.read_text(encoding="utf-8"), + '{\n "state": "ready"\n}\n', + ) + self.assertEqual( + list(path.parent.glob(f".{path.name}.*.tmp")), + [], + ) + def test_atomic_write_retries_transient_windows_reader_lock(self): with TemporaryDirectory() as directory: path = Path(directory) / "manifest.json" @@ -36,6 +66,32 @@ def test_atomic_write_retries_transient_windows_reader_lock(self): self.assertEqual(replace.call_count, 3) self.assertEqual(sleep.call_count, 2) + def test_atomic_write_failure_preserves_target_and_cleans_temporary_file( + self, + ): + with TemporaryDirectory() as directory: + path = Path(directory) / "manifest.json" + original = '{\n "state": "ready"\n}\n' + path.write_text(original, encoding="utf-8") + + with ( + patch.object( + Path, + "replace", + side_effect=OSError("replace failed"), + ) as replace, + patch("vidxp.core.manifest.time.sleep"), + self.assertRaisesRegex(OSError, "replace failed"), + ): + write_json_atomic(path, {"state": "running"}) + + self.assertEqual(replace.call_count, 5) + self.assertEqual(path.read_text(encoding="utf-8"), original) + self.assertEqual( + list(path.parent.glob(f".{path.name}.*.tmp")), + [], + ) + def test_declared_video_checksum_does_not_suppress_transcript_hash(self): transcript = ({"text": "hello", "start": 0.0, "end": 1.0},) checksums = source_checksums( diff --git a/tests/test_runner.py b/tests/test_runner.py index 06f5277..67442a2 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -5,10 +5,8 @@ from unittest.mock import Mock, patch from vidxp.core.contracts import ( - INDEX_SCHEMA_VERSION, IndexCancelledError, IndexConfig, - IndexSchemaError, VideoSource, ) from vidxp.core.manifest import COMPLETION_FILE, ManifestStore @@ -18,7 +16,6 @@ _RunLock, index_video as _index_video, indexing_in_progress, - local_config_from_status, run_index as _run_index, ) from vidxp.capabilities.registry import create_capability_registry @@ -124,16 +121,6 @@ def _config(self, root, modalities=("scene",)): output_root=root, ) - def test_local_config_rejects_an_older_index_schema(self): - status = { - "summary": { - "index_schema_version": INDEX_SCHEMA_VERSION - 1, - } - } - - with self.assertRaisesRegex(IndexSchemaError, "Re-index"): - local_config_from_status(status) - def test_two_videos_complete_one_isolated_resumable_run(self): with TemporaryDirectory() as directory: root = Path(directory) @@ -181,7 +168,10 @@ def scene_indexer(source, *, config, **_): ) self.assertEqual(resumed["state"], "complete") self.assertEqual(manifest["git"]["commit"], "abc123") - self.assertEqual(manifest["index_size_bytes"], 321) + self.assertEqual( + manifest["store_size_bytes_at_commit"], + 321, + ) self.assertEqual(manifest["processed_frames"], 4) self.assertTrue((config.run_directory / COMPLETION_FILE).is_file()) self.assertEqual( diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py new file mode 100644 index 0000000..714c683 --- /dev/null +++ b/tests/test_snapshots.py @@ -0,0 +1,161 @@ +import json +import unittest +from datetime import datetime, timezone +from uuid import uuid1, uuid4 + +from pydantic import ValidationError + +from vidxp.core.snapshots import ( + ACTIVE_SNAPSHOT_POINTER_SCHEMA_VERSION, + INDEX_SNAPSHOT_SCHEMA_VERSION, + ActiveSnapshotPointer, + GenerationReference, + IndexSnapshot, +) + + +SHA256 = "a" * 64 +OTHER_SHA256 = "b" * 64 + + +class SnapshotModelTests(unittest.TestCase): + def generation_reference(self, **changes): + values = { + "generation_id": uuid4().hex, + "media_id": "media-1", + "manifest_sha256": SHA256, + "input_sha256": OTHER_SHA256, + "config_fingerprint": SHA256, + "modalities": ("dialogue", "scene"), + "record_counts": {"dialogue": 3, "scene": 4}, + "store_size_bytes_at_commit": 2048, + } + values.update(changes) + return GenerationReference(**values) + + def index_snapshot(self, **changes): + reference = changes.pop("reference", self.generation_reference()) + values = { + "snapshot_id": uuid4().hex, + "created_at": datetime(2026, 7, 29, tzinfo=timezone.utc), + "config_fingerprint": SHA256, + "configuration": { + "device": "cpu", + "modalities": ["dialogue", "scene"], + "options": {"batch_size": 8}, + }, + "generations": {reference.media_id: reference}, + } + values.update(changes) + return IndexSnapshot(**values) + + def test_models_round_trip_through_json(self): + snapshot = self.index_snapshot() + pointer = ActiveSnapshotPointer( + snapshot_id=snapshot.snapshot_id, + snapshot_sha256=OTHER_SHA256, + updated_at=datetime(2026, 7, 29, 1, 2, tzinfo=timezone.utc), + ) + + reference = next(iter(snapshot.generations.values())) + self.assertEqual( + GenerationReference.model_validate_json( + reference.model_dump_json() + ), + reference, + ) + self.assertEqual( + IndexSnapshot.model_validate_json(snapshot.model_dump_json()), + snapshot, + ) + self.assertEqual( + ActiveSnapshotPointer.model_validate_json( + pointer.model_dump_json() + ), + pointer, + ) + self.assertEqual( + json.loads(snapshot.model_dump_json())["schema_version"], + INDEX_SNAPSHOT_SCHEMA_VERSION, + ) + self.assertEqual( + json.loads(pointer.model_dump_json())["schema_version"], + ACTIVE_SNAPSHOT_POINTER_SCHEMA_VERSION, + ) + + def test_models_are_strict_frozen_and_forbid_extra_fields(self): + reference = self.generation_reference() + with self.assertRaises(ValidationError): + reference.store_size_bytes_at_commit = 1 + with self.assertRaises(ValidationError): + self.generation_reference(store_size_bytes_at_commit="2048") + with self.assertRaises(ValidationError): + GenerationReference( + **reference.model_dump(), + unexpected=True, + ) + + def test_ids_must_be_lowercase_uuid4_hex(self): + invalid_ids = ( + str(uuid4()), + uuid4().hex.upper(), + uuid1().hex, + "0" * 32, + ) + for invalid_id in invalid_ids: + with self.subTest(invalid_id=invalid_id): + with self.assertRaises(ValidationError): + self.generation_reference(generation_id=invalid_id) + with self.assertRaises(ValidationError): + self.index_snapshot(snapshot_id=invalid_id) + + def test_sha256_fields_require_lowercase_64_character_hex(self): + for invalid_hash in ("a" * 63, "A" * 64, "g" * 64): + with self.subTest(invalid_hash=invalid_hash): + with self.assertRaises(ValidationError): + self.generation_reference( + manifest_sha256=invalid_hash + ) + with self.assertRaises(ValidationError): + ActiveSnapshotPointer( + snapshot_id=uuid4().hex, + snapshot_sha256=invalid_hash, + updated_at=datetime.now(timezone.utc), + ) + + def test_snapshot_rejects_mismatched_media_mapping(self): + reference = self.generation_reference(media_id="media-1") + with self.assertRaisesRegex( + ValidationError, + "mapping keys must match", + ): + self.index_snapshot( + reference=reference, + generations={"media-2": reference}, + ) + + def test_generation_counts_must_match_modalities(self): + with self.assertRaisesRegex(ValidationError, "exactly match"): + self.generation_reference(record_counts={"scene": 1}) + with self.assertRaises(ValidationError): + self.generation_reference( + record_counts={"dialogue": -1, "scene": 1} + ) + + def test_snapshot_configuration_must_be_json_safe(self): + with self.assertRaises(ValidationError): + self.index_snapshot(configuration={"runtime": object()}) + + def test_timestamps_must_be_timezone_aware(self): + with self.assertRaises(ValidationError): + self.index_snapshot(created_at=datetime(2026, 7, 29)) + with self.assertRaises(ValidationError): + ActiveSnapshotPointer( + snapshot_id=uuid4().hex, + snapshot_sha256=SHA256, + updated_at=datetime(2026, 7, 29), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_storage.py b/tests/test_storage.py index b52d322..c8adab4 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -1,13 +1,19 @@ import unittest from pathlib import Path from tempfile import TemporaryDirectory +from unittest.mock import MagicMock from vidxp.core.contracts import ( CancellationToken, IndexConfig, StorageRecord, ) -from vidxp.core.storage import IndexStorage, directory_size, metadata_filter +from vidxp.core.storage import ( + IndexStorage, + SnapshotScopedIndexStore, + directory_size, + metadata_filter, +) class FakeCollection: @@ -34,6 +40,7 @@ def query(self, **options): def get(self, **options): self.get_options = options return { + "ids": ["source-3", "source-1"], "metadatas": [ { "frame_index": 3, @@ -66,6 +73,7 @@ def fake_storage(config, collection): storage.config = config storage.path = config.index_directory storage.client = FakeClient(collection) + storage._create = True storage._collections = {} storage._names = { "dialogue": "dialogue", @@ -142,6 +150,61 @@ def test_query_requests_distances_and_applies_run_and_video_filter(self): self.assertIn({"video_id": "video-1"}, clauses) self.assertIn({"run_id": "run-1"}, clauses) + def test_query_and_records_scope_to_generation_ids(self): + collection = FakeCollection() + storage = fake_storage(self.config, collection) + generation_ids = ("generation-1", "generation-2") + + storage.query( + "scene", + [0.1, 0.2], + top_k=7, + generation_ids=generation_ids, + ) + query_clauses = collection.query_options["where"]["$and"] + self.assertIn( + { + "generation_id": { + "$in": ["generation-1", "generation-2"] + } + }, + query_clauses, + ) + + storage.records( + "scene", + generation_ids=generation_ids, + ) + record_clauses = collection.get_options["where"]["$and"] + self.assertIn( + { + "generation_id": { + "$in": ["generation-1", "generation-2"] + } + }, + record_clauses, + ) + + def test_empty_generation_scope_returns_no_records(self): + collection = FakeCollection() + storage = fake_storage(self.config, collection) + + self.assertEqual( + storage.query( + "scene", + [0.1, 0.2], + top_k=7, + generation_ids=(), + ), + [], + ) + self.assertEqual( + storage.records("scene", generation_ids=()), + [], + ) + self.assertIsNone(collection.query_options) + self.assertIsNone(collection.get_options) + def test_records_apply_capability_filters(self): collection = FakeCollection() storage = fake_storage(self.config, collection) @@ -161,6 +224,19 @@ def test_records_apply_capability_filters(self): self.assertIn({"video_id": "video-1"}, clauses) self.assertIn({"cluster_id": "1"}, clauses) + def test_count_records_uses_ids_without_loading_metadata(self): + collection = FakeCollection() + storage = fake_storage(self.config, collection) + + count = storage.count_records( + "scene", + video_id="video-1", + generation_ids=("generation-1",), + ) + + self.assertEqual(count, 2) + self.assertEqual(collection.get_options["include"], []) + def test_record_cleanup_remains_scoped_to_capability_and_run(self): collection = FakeCollection() storage = fake_storage(self.config, collection) @@ -176,6 +252,19 @@ def test_record_cleanup_remains_scoped_to_capability_and_run(self): self.assertIn({"video_id": "video-1"}, clauses) self.assertIn({"cluster_id": "3"}, clauses) + def test_generation_cleanup_uses_generation_filter(self): + collection = FakeCollection() + storage = fake_storage(self.config, collection) + + storage.delete_generation("generation-1", modalities=("scene",)) + + clauses = collection.deletes[0]["where"]["$and"] + self.assertIn( + {"generation_id": {"$in": ["generation-1"]}}, + clauses, + ) + self.assertIn({"run_id": "run-1"}, clauses) + def test_directory_size_only_counts_files_under_requested_path(self): with TemporaryDirectory() as directory: root = Path(directory) @@ -189,6 +278,70 @@ def test_metadata_filter_keeps_run_identity_without_video_filter(self): where = metadata_filter(self.config) self.assertNotIn({"video_id": None}, where["$and"]) + def test_snapshot_scope_delegates_reads_and_lifecycle(self): + store = MagicMock(spec=IndexStorage) + store.query.return_value = [{"source_id": "source-1"}] + store.records.return_value = [{"source_id": "source-1"}] + store.size_bytes.return_value = 42 + scoped = SnapshotScopedIndexStore( + store, + ("generation-1", "generation-2", "generation-1"), + ) + + with scoped as entered: + self.assertIs(entered, scoped) + self.assertEqual( + scoped.query("scene", [0.1], top_k=2), + [{"source_id": "source-1"}], + ) + self.assertEqual( + scoped.records("scene"), + [{"source_id": "source-1"}], + ) + self.assertEqual(scoped.size_bytes(), 42) + + store.__enter__.assert_called_once_with() + store.__exit__.assert_called_once() + store.query.assert_called_once_with( + "scene", + [0.1], + top_k=2, + video_id=None, + generation_ids=("generation-1", "generation-2"), + filters=None, + ) + store.records.assert_called_once_with( + "scene", + video_id=None, + generation_ids=("generation-1", "generation-2"), + filters=None, + ) + + scoped.close() + store.close.assert_called_once_with() + + def test_snapshot_scope_exposes_no_mutation_api(self): + scoped = SnapshotScopedIndexStore( + MagicMock(spec=IndexStorage), + ("generation-1",), + ) + for method in ( + "clear", + "delete_video", + "delete_records", + "delete_generation", + "upsert", + ): + self.assertFalse(hasattr(scoped, method)) + + def test_explicit_empty_generation_cleanup_selects_nothing(self): + collection = FakeCollection() + storage = fake_storage(self.config, collection) + + storage.delete_generation("generation-1", modalities=()) + + self.assertEqual(collection.deletes, []) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_storage_integration.py b/tests/test_storage_integration.py index c51b45b..6bdc7a3 100644 --- a/tests/test_storage_integration.py +++ b/tests/test_storage_integration.py @@ -1,4 +1,5 @@ import unittest +from pathlib import Path from tempfile import TemporaryDirectory from vidxp.core.contracts import ( @@ -72,6 +73,87 @@ def test_two_videos_can_coexist_query_and_delete_by_video(self): ["video-2"], ) + def test_generation_scope_and_cleanup_use_chroma_in_filter(self): + with TemporaryDirectory() as directory: + config = IndexConfig( + dataset="sample", + split="test", + run_id="run-1", + enabled_modalities=("scene",), + storage_directory=directory, + ) + with IndexStorage(config) as storage: + storage.upsert( + "scene", + [ + StorageRecord( + source_id=f"source-{index}", + embedding=embedding, + metadata={ + "dataset": "sample", + "split": "test", + "run_id": "run-1", + "video_id": f"video-{index}", + "generation_id": generation_id, + }, + ) + for index, generation_id, embedding in ( + (1, "generation-1", [1.0, 0.0]), + (2, "generation-2", [0.0, 1.0]), + ) + ], + batch_size=2, + cancellation=CancellationToken(), + ) + + scoped = storage.query( + "scene", + [1.0, 0.0], + top_k=2, + generation_ids=("generation-1",), + ) + self.assertEqual( + [ + row["metadata"]["generation_id"] + for row in scoped + ], + ["generation-1"], + ) + + storage.delete_generation( + "generation-1", + modalities=("scene",), + ) + remaining = storage.records("scene") + self.assertEqual( + [item["generation_id"] for item in remaining], + ["generation-2"], + ) + + def test_read_only_store_fails_closed_without_database_or_collection(self): + with TemporaryDirectory() as directory: + path = Path(directory) + config = IndexConfig( + dataset="sample", + split="test", + run_id="run-1", + enabled_modalities=("scene",), + storage_directory=path / "missing", + ) + with self.assertRaises(FileNotFoundError): + IndexStorage(config, create=False) + self.assertFalse(config.index_directory.exists()) + + with IndexStorage(config) as storage: + storage.collection("scene") + read_only = IndexStorage(config, create=False) + read_only.client.delete_collection("scene") + with ( + self.assertRaises(FileNotFoundError), + read_only, + ): + read_only.records("scene") + if __name__ == "__main__": unittest.main() From f43a8c34186760f9c411c42e585e56f83a9aa3e6 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 03:42:14 +0500 Subject: [PATCH 05/46] feat: add managed media and artifact catalogs --- README.md | 32 +- changes/artifact-catalog.feature.md | 8 + changes/media-catalog.feature.md | 6 + changes/snippet-artifacts.feature.md | 5 + docs/CONTRIBUTING.md | 4 +- docs/architecture/platform.md | 8 + src/vidxp/application.py | 247 +++++++-- src/vidxp/application_models.py | 141 ++++- src/vidxp/artifact_service.py | 292 ++++++++++ src/vidxp/capabilities/actor/definition.py | 8 - src/vidxp/capabilities/actor/operations.py | 34 +- src/vidxp/capabilities/actor/results.py | 299 ++++++++--- src/vidxp/capabilities/actor/schemas.py | 38 +- src/vidxp/capabilities/contracts.py | 59 ++- src/vidxp/capabilities/schemas.py | 45 +- src/vidxp/capabilities/search.py | 24 +- src/vidxp/cli.py | 6 +- .../actor/cli.py => cli_commands/actors.py} | 103 ++-- src/vidxp/cli_commands/artifacts.py | 87 +++ src/vidxp/cli_commands/index.py | 30 +- src/vidxp/cli_commands/media.py | 93 ++++ src/vidxp/cli_support.py | 9 +- src/vidxp/composition.py | 35 ++ src/vidxp/core/artifacts.py | 93 ++++ src/vidxp/core/generations.py | 4 +- src/vidxp/core/identifiers.py | 26 +- src/vidxp/core/media.py | 164 ++++++ src/vidxp/core/runner.py | 1 - src/vidxp/core/snapshots.py | 13 +- src/vidxp/core/storage.py | 32 +- src/vidxp/core/storage_keys.py | 13 + src/vidxp/frontend.py | 175 +++--- src/vidxp/index_worker.py | 12 +- src/vidxp/infrastructure/local_artifacts.py | 154 ++++++ src/vidxp/infrastructure/local_catalog.py | 267 ++++++++++ src/vidxp/infrastructure/local_files.py | 65 +++ src/vidxp/infrastructure/local_index.py | 34 +- src/vidxp/infrastructure/local_media.py | 315 +++++++++++ src/vidxp/infrastructure/local_objects.py | 78 +++ src/vidxp/infrastructure/local_snapshots.py | 8 +- src/vidxp/media_service.py | 246 +++++++++ src/vidxp/ports.py | 169 +++++- src/vidxp/repository_layout.py | 12 + src/vidxp/settings.py | 22 + tests/test_actor_results.py | 208 +++++--- tests/test_application.py | 65 ++- tests/test_benchmarks.py | 12 +- tests/test_cli.py | 125 +++-- tests/test_contracts.py | 12 +- tests/test_frontend.py | 21 +- tests/test_index_worker.py | 25 +- tests/test_local_snapshots.py | 12 +- tests/test_media_catalog.py | 349 ++++++++++++ tests/test_media_services.py | 497 ++++++++++++++++++ tests/test_public_path_contracts.py | 150 ++++++ tests/test_search.py | 15 +- 56 files changed, 4449 insertions(+), 558 deletions(-) create mode 100644 changes/artifact-catalog.feature.md create mode 100644 changes/media-catalog.feature.md create mode 100644 changes/snippet-artifacts.feature.md create mode 100644 src/vidxp/artifact_service.py rename src/vidxp/{capabilities/actor/cli.py => cli_commands/actors.py} (64%) create mode 100644 src/vidxp/cli_commands/artifacts.py create mode 100644 src/vidxp/cli_commands/media.py create mode 100644 src/vidxp/core/artifacts.py create mode 100644 src/vidxp/core/media.py create mode 100644 src/vidxp/core/storage_keys.py create mode 100644 src/vidxp/infrastructure/local_artifacts.py create mode 100644 src/vidxp/infrastructure/local_catalog.py create mode 100644 src/vidxp/infrastructure/local_files.py create mode 100644 src/vidxp/infrastructure/local_media.py create mode 100644 src/vidxp/infrastructure/local_objects.py create mode 100644 src/vidxp/media_service.py create mode 100644 tests/test_media_catalog.py create mode 100644 tests/test_media_services.py create mode 100644 tests/test_public_path_contracts.py diff --git a/README.md b/README.md index 670ed7e..718b25a 100644 --- a/README.md +++ b/README.md @@ -125,22 +125,29 @@ vidxp prepare Build an index containing dialogue, scene, and actor information: ```bash -vidxp index create samplevideo.mp4 +vidxp media import samplevideo.mp4 --json +vidxp index create ``` +The import command copies and validates the video in managed local storage and +returns its stable `media_id`. Indexing, search results, and generated artifacts +use that ID instead of exposing repository file paths. + Search the completed index: ```bash vidxp search dialogue "the bread just came out of the oven" vidxp search scene "a yellow taxi on a city street" --top-k 5 vidxp actors list -vidxp actors render 1 samplevideo.mp4 +vidxp actors render --json +vidxp artifacts snippet 30 45 --json +vidxp artifacts show ``` Index only selected capabilities or sample fewer visual frames: ```bash -vidxp index create samplevideo.mp4 --modality scene --frame-stride 5 +vidxp index create --modality scene --frame-stride 5 ``` Repeat `--modality` to combine `dialogue`, `scene`, and `actor`. @@ -229,7 +236,7 @@ VidXP is an evolving beta. We'd love to hear your feedback and where you'd like |---|---|---| | Search results | Top result in the CLI; structured top-k Python results | Rich ranked results, metadata, previews, and filtering across interfaces | | Temporal search | Frame and transcript-phrase timestamps | Better time ranges, scene boundaries, aggregation, and ranking | -| Video collections | Persistent local multi-video snapshots with incremental re-index/remove | Media catalog, remote ingestion, and richer library management | +| Video collections | Validated managed-media catalog and persistent multi-video snapshots | Remote resumable ingestion and richer library management | | Actor workflows | Face clustering and highlighted video export | Cluster browsing, labeling, actor search, and stronger tracking | | Speaker context | Timestamped dialogue search | Active-speaker detection and links between speech and visible people | | Product experience | CLI and browser indexing/search | Clearer progress, result navigation, recovery, and long-running job controls | @@ -246,13 +253,16 @@ VidXP is an evolving beta. We'd love to hear your feedback and where you'd like | Actor recognition | OpenCV Zoo SFace | VidXP maintains the standard local CLI/UI repository in `chroma_data/`. Each -successful indexing run creates an immutable generation and atomically publishes -a multi-media snapshot. Re-indexing replaces only that media item's active -generation; removing or clearing media publishes a new snapshot without deleting -the retained generations first. Failed and cancelled runs do not replace the -active snapshot. Model caches normally live outside this directory and outside -the virtual environment. Provider revisions and weight checksums are pinned in -capability specs and recorded in each generation manifest. +local import is streamed into managed storage, validated with `ffprobe`, and +published through the repository catalog before it can be indexed. Each successful +indexing run creates an immutable generation and atomically publishes a multi-media +snapshot. Actor overlay videos are immutable cataloged artifacts. Re-indexing +replaces only that media item's active generation; removing or clearing media +publishes a new snapshot without deleting retained generations or media. Failed +and cancelled runs do not replace the active snapshot. Model caches normally live +outside this directory and outside the virtual environment. Provider revisions +and weight checksums are pinned in capability specs and recorded in each generation +manifest. ## Documentation and project links diff --git a/changes/artifact-catalog.feature.md b/changes/artifact-catalog.feature.md new file mode 100644 index 0000000..e8f367d --- /dev/null +++ b/changes/artifact-catalog.feature.md @@ -0,0 +1,8 @@ +Add managed actor-overlay artifacts: + +- publish actor overlay videos under opaque artifact IDs +- bind overlays to their media and index-generation identity +- validate rendered video before marking an artifact ready +- enforce the requested output profile before publication +- resolve source playback and artifact delivery through confined internal handles +- keep storage keys and local paths out of shared command and result schemas diff --git a/changes/media-catalog.feature.md b/changes/media-catalog.feature.md new file mode 100644 index 0000000..cf34bb5 --- /dev/null +++ b/changes/media-catalog.feature.md @@ -0,0 +1,6 @@ +Add managed local media catalogs: + +- stream and hash local imports without loading whole videos into memory +- validate media with ffprobe before publishing a ready catalog entry +- index registered media by opaque ID instead of public filesystem path +- list media through a bounded cursor page shared by every adapter diff --git a/changes/snippet-artifacts.feature.md b/changes/snippet-artifacts.feature.md new file mode 100644 index 0000000..bf53f6d --- /dev/null +++ b/changes/snippet-artifacts.feature.md @@ -0,0 +1,5 @@ +Add managed video snippets: + +- create source-preserving or broadly compatible MP4 snippets by media ID +- reject intervals outside the source or configured duration limit +- reuse ready output for the same media checksum, interval, and profile diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index c031987..293869a 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -73,7 +73,9 @@ are stored, state whether an existing `chroma_data` index must be rebuilt. reload, and search. 4. Confirm that a failed or incomplete generation leaves the previous active snapshot searchable. -5. Do not commit model weights, generated indexes, benchmark runs, or local +5. For media or artifact changes, verify that public models contain IDs and + metadata only; storage keys and local paths remain behind their ports. +6. Do not commit model weights, generated indexes, benchmark runs, or local sample media. Run the complete automated suite with: diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md index 262f68e..552ce2c 100644 --- a/docs/architecture/platform.md +++ b/docs/architecture/platform.md @@ -166,6 +166,11 @@ Required identifiers: - `JobId` - `ArtifactId` +For the current single-source media pipeline, `VideoId` is the same opaque value +as `MediaId`. A separate video-track identity is introduced only when one media +asset can produce multiple independently addressable video tracks; adapters must +not invent a second identifier before then. + Required shared models: - `MediaAsset` @@ -238,7 +243,9 @@ One `RepositoryLayout` defines all persistent paths: ```text repository/ repository.json + catalog.sqlite3 media/ + objects/ indexes/ store/ generations/ @@ -247,6 +254,7 @@ repository/ .json active-snapshot.json artifacts/ + objects/ local-workflows/ ``` diff --git a/src/vidxp/application.py b/src/vidxp/application.py index d2f0235..1a8b154 100644 --- a/src/vidxp/application.py +++ b/src/vidxp/application.py @@ -9,7 +9,10 @@ from vidxp.application_models import ( ApplicationError, + Artifact, CreateIndexCommand, + CreateActorOverlayCommand, + CreateSnippetCommand, DependencyCheckCommand, DependencyCheckResult, DependencyUnavailableError, @@ -17,6 +20,10 @@ InvalidRequestError, IndexResult, IndexStatus, + ImportMediaCommand, + ListMediaCommand, + MediaAsset, + MediaPage, PrepareModelsCommand, PrepareModelsResult, RemoveIndexCommand, @@ -24,9 +31,8 @@ SearchCommand, ) from vidxp.capabilities.actor.schemas import ( - ActorClusterSummary, - ActorDetection, - ActorRenderResult, + ActorClustersOutput, + ActorDetectionsOutput, ) from vidxp.capabilities.actor.results import ActorClusterNotFoundError from vidxp.capabilities.contracts import ( @@ -43,6 +49,11 @@ IndexConfig, IndexSchemaError, ) +from vidxp.core.artifacts import ( + ArtifactIntegrityError, + ArtifactRenderError, + ArtifactRendererUnavailableError, +) from vidxp.core.indexing_common import ProgressCallback from vidxp.index_state import ( INDEX_STATUS_SCHEMA, @@ -50,9 +61,27 @@ IndexNotReadyError, ) from vidxp.ports import IndexBackend, ModelRuntimePort, ResourceLimitError +from vidxp.ports import LocalFileResource from vidxp.model_contracts import ModelArtifactUnavailableError from vidxp.repository_layout import RepositoryLayout from vidxp.settings import VidXPSettings +from vidxp.artifact_service import ( + ArtifactRequestError, + ArtifactService, + ArtifactUnavailableError, + InvalidArtifactError, +) +from vidxp.core.media import ( + InvalidMediaError, + MediaImportLimitError, + MediaProbeUnavailableError, + MediaStoreIntegrityError, + MediaUnavailableError, +) +from vidxp.media_service import ( + MediaImportNotAllowedError, + MediaService, +) def _validation_details( @@ -126,6 +155,68 @@ def wrapped(*args: Any, **kwargs: Any) -> Any: ) from exc except CapabilityRequestError as exc: raise InvalidRequestError() from exc + except InvalidMediaError as exc: + raise ApplicationError( + "media_invalid", + ErrorCategory.validation, + "The selected file is not a valid supported video.", + ) from exc + except MediaImportLimitError as exc: + raise ApplicationError( + "media_too_large", + ErrorCategory.resource_limit, + "The selected media exceeds the configured import limit.", + ) from exc + except MediaProbeUnavailableError as exc: + raise ApplicationError( + "media_probe_unavailable", + ErrorCategory.unavailable, + "The media probe dependency is unavailable.", + retryable=True, + ) from exc + except MediaImportNotAllowedError as exc: + raise ApplicationError( + "media_import_forbidden", + ErrorCategory.authorization, + "The selected local media cannot be imported.", + ) from exc + except MediaStoreIntegrityError as exc: + raise ApplicationError( + "media_integrity_failed", + ErrorCategory.conflict, + "The media changed or failed an integrity check.", + ) from exc + except MediaUnavailableError as exc: + raise ResourceNotFoundError("media") from exc + except ArtifactUnavailableError as exc: + raise ResourceNotFoundError("artifact") from exc + except ArtifactIntegrityError as exc: + raise ApplicationError( + "artifact_integrity_failed", + ErrorCategory.conflict, + "The artifact failed an integrity check.", + ) from exc + except ArtifactRendererUnavailableError as exc: + raise ApplicationError( + "artifact_renderer_unavailable", + ErrorCategory.unavailable, + "The configured artifact renderer is unavailable.", + retryable=True, + ) from exc + except ArtifactRenderError as exc: + raise ApplicationError( + "artifact_render_failed", + ErrorCategory.internal, + "The requested artifact could not be rendered.", + ) from exc + except ArtifactRequestError as exc: + raise InvalidRequestError() from exc + except InvalidArtifactError as exc: + raise ApplicationError( + "artifact_render_invalid", + ErrorCategory.internal, + "The generated artifact failed media validation.", + ) from exc return wrapped @@ -141,12 +232,16 @@ def __init__( registry: CapabilityRegistry, runtime: ModelRuntimePort, index_backend: IndexBackend, + media_service: MediaService, + artifact_service: ArtifactService, ) -> None: self.settings = settings self.layout = layout self.registry = registry self.runtime = runtime self.index_backend = index_backend + self.media_service = media_service + self.artifact_service = artifact_service @contextmanager def _capability_dependencies( @@ -195,21 +290,46 @@ def index_status(self) -> IndexStatus: "message": "No local video index was found.", } ) - payload.update( - { - "repository_root": self.layout.root, - "index_directory": self.index_directory, - } - ) return IndexStatus.model_validate(payload) @application_boundary - def active_config(self) -> tuple[IndexConfig, dict[str, Any]]: + def _active_config(self) -> tuple[IndexConfig, dict[str, Any]]: return self.index_backend.active_config( self.index_directory, device=self.device, ) + @application_boundary + def import_media(self, command: ImportMediaCommand) -> MediaAsset: + self.layout.ensure_local_directories() + return self.media_service.import_local(command) + + @application_boundary + def get_media(self, media_id: str) -> MediaAsset: + return self.media_service.get(media_id) + + @application_boundary + def list_media( + self, + command: ListMediaCommand, + ) -> MediaPage: + try: + return self.media_service.list(command) + except ValueError as exc: + raise InvalidRequestError() from exc + + @application_boundary + def open_media_content(self, media_id: str) -> LocalFileResource: + return self.media_service.content(media_id) + + @application_boundary + def get_artifact(self, artifact_id: str) -> Artifact: + return self.artifact_service.get(artifact_id) + + @application_boundary + def open_artifact_content(self, artifact_id: str) -> LocalFileResource: + return self.artifact_service.content(artifact_id) + @application_boundary def create_index( self, @@ -228,8 +348,8 @@ def create_index( raise CapabilityRequestError( "One or more selected capabilities do not support indexing." ) - if not command.path.is_file(): - raise ResourceNotFoundError("media") + media = self.media_service.require_record(command.media_id) + content = self.media_service.content(command.media_id) self.layout.ensure_local_directories() config = IndexConfig.local( video_id=command.media_id, @@ -245,15 +365,15 @@ def create_index( ) with self.runtime.scheduler.indexing(): with self._capability_dependencies(selected): - return IndexResult( - summary=self.index_backend.create( - command.path, - config=config, - progress=progress_callback, - cancellation=cancellation, - source_name=command.source_name, - ) + result = self.index_backend.create( + content.path, + config=config, + progress=progress_callback, + cancellation=cancellation, + source_name=media.original_filename, + source_checksum=media.sha256, ) + return IndexResult.model_validate(result) @application_boundary def indexing_in_progress(self) -> bool: @@ -313,7 +433,7 @@ def prepare_models( return PrepareModelsResult( prepared=tuple(prepared), modalities=selected, - runtime=self.runtime.describe(), + runtime=self.runtime.backends, ) @application_boundary @@ -336,7 +456,7 @@ def execute( config = None if contract.requires_index: - config, _ = self.active_config() + config, _ = self._active_config() if capability not in config.enabled_modalities: raise CapabilityRequestError( f"The {capability} capability is not present in this index." @@ -377,39 +497,84 @@ def search(self, command: SearchCommand) -> SearchResult: ) @application_boundary - def actor_clusters(self) -> tuple[ActorClusterSummary, ...]: - result = self.execute("actor", "clusters", {}) - return tuple(result.clusters) - - @application_boundary - def actor_detections(self, cluster_id: str) -> list[ActorDetection]: - result = self.execute( - "actor", - "detections", - {"cluster_id": cluster_id}, + def actor_clusters( + self, + *, + page_size: int = 50, + cursor: str | None = None, + ) -> ActorClustersOutput: + return cast( + ActorClustersOutput, + self.execute( + "actor", + "clusters", + {"page_size": page_size, "cursor": cursor}, + ), ) - return list(result.detections) @application_boundary - def render_actor( + def actor_detections( self, cluster_id: str, - input_path: str | Path, - output_path: str | Path, - ) -> ActorRenderResult: + *, + page_size: int = 50, + cursor: str | None = None, + ) -> ActorDetectionsOutput: return cast( - ActorRenderResult, + ActorDetectionsOutput, self.execute( "actor", - "render", + "detections", { "cluster_id": cluster_id, - "input_path": input_path, - "output_path": output_path, + "page_size": page_size, + "cursor": cursor, }, ), ) + @application_boundary + def render_actor( + self, + command: CreateActorOverlayCommand, + ) -> Artifact: + detections = [] + cursor = None + while True: + page = self.actor_detections( + command.cluster_id, + page_size=100, + cursor=cursor, + ) + detections.extend(page.detections) + cursor = page.next_cursor + if cursor is None: + break + identities = { + (detection.media_id, detection.generation_id) + for detection in detections + } + if identities != {(command.media_id, command.generation_id)}: + raise ApplicationError( + "actor_cluster_identity_invalid", + ErrorCategory.conflict, + "The actor cluster does not match the requested index identity.", + ) + return self.artifact_service.create_actor_overlay( + media_id=command.media_id, + generation_id=command.generation_id, + cluster_id=command.cluster_id, + detections=[ + detection.model_dump(mode="python") + for detection in detections + ], + profile=command.profile, + ) + + @application_boundary + def create_snippet(self, command: CreateSnippetCommand) -> Artifact: + return self.artifact_service.create_snippet(command) + @application_boundary def clear_index(self) -> bool: base_config = self._base_config() diff --git a/src/vidxp/application_models.py b/src/vidxp/application_models.py index 588fe08..a218c0a 100644 --- a/src/vidxp/application_models.py +++ b/src/vidxp/application_models.py @@ -2,13 +2,16 @@ from enum import StrEnum from pathlib import Path -from typing import Any, Generic, Mapping, TypeVar +from typing import Any, Generic, Literal, Mapping, TypeVar from pydantic import ( + AwareDatetime, BaseModel, ConfigDict, Field, JsonValue, + NonNegativeInt, + field_validator, ) from vidxp.core.identifiers import ( @@ -18,15 +21,32 @@ IndexSnapshotId as IndexSnapshotId, JobId as JobId, MediaId as MediaId, + MimeType, RepositoryId as RepositoryId, + Sha256, VideoId as VideoId, ) +from vidxp.core.artifacts import ( + ARTIFACT_SCHEMA_VERSION, + ArtifactKind, + ArtifactState, +) +from vidxp.core.media import ( + MEDIA_SCHEMA_VERSION, + MediaState, + MediaStream, + validate_display_filename, +) T = TypeVar("T") class ApplicationModel(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True) + model_config = ConfigDict( + extra="forbid", + frozen=True, + allow_inf_nan=False, + ) class Page(ApplicationModel, Generic[T]): @@ -167,38 +187,125 @@ class CapabilityDependencyCheck(ApplicationModel): error: str | None = None -class CreateIndexCommand(ApplicationModel): +class ImportMediaCommand(ApplicationModel): + """Local-adapter command; remote ingestion uses the upload workflow.""" + path: Path - media_id: MediaId | None = None + original_filename: str | None = Field(default=None, min_length=1) + declared_mime_type: MimeType | None = None + + @field_validator("original_filename") + @classmethod + def _filename_only(cls, value: str | None) -> str | None: + return None if value is None else validate_display_filename(value) + + +class MediaAsset(ApplicationModel): + schema_version: Literal[MEDIA_SCHEMA_VERSION] = MEDIA_SCHEMA_VERSION + media_id: MediaId + video_id: VideoId + original_filename: str = Field(min_length=1) + sha256: Sha256 + byte_size: int = Field(gt=0) + declared_mime_type: MimeType | None = None + detected_mime_type: MimeType + container: str = Field(min_length=1) + duration_seconds: float = Field(gt=0) + streams: tuple[MediaStream, ...] = Field(min_length=1) + state: MediaState + created_at: AwareDatetime + + +class ListMediaCommand(ApplicationModel): + page_size: int = Field(default=50, gt=0, le=100) + cursor: str | None = Field(default=None, min_length=1, max_length=512) + + +class MediaPage(Page[MediaAsset]): + pass + + +class CreateIndexCommand(ApplicationModel): + media_id: MediaId modalities: tuple[str, ...] frame_stride: int = Field(default=1, gt=0) - capability_options: Mapping[str, Mapping[str, Any]] = Field( + capability_options: Mapping[str, Mapping[str, JsonValue]] = Field( default_factory=dict ) - source_name: str | None = Field(default=None, min_length=1) class IndexResult(ApplicationModel): - summary: Mapping[str, Any] + media_id: MediaId + generation_id: IndexGenerationId + snapshot_id: IndexSnapshotId + active_media_count: int = Field(gt=0) + record_counts: dict[str, NonNegativeInt] = Field(default_factory=dict) class RemoveIndexCommand(ApplicationModel): media_id: MediaId +class Artifact(ApplicationModel): + schema_version: Literal[ARTIFACT_SCHEMA_VERSION] = ( + ARTIFACT_SCHEMA_VERSION + ) + artifact_id: ArtifactId + media_id: MediaId + generation_id: IndexGenerationId | None = None + job_id: JobId | None = None + kind: ArtifactKind + profile: str = Field(min_length=1) + mime_type: MimeType + byte_size: int = Field(gt=0) + sha256: Sha256 + state: ArtifactState + created_at: AwareDatetime + expires_at: AwareDatetime | None = None + + +class ActorOverlayProfile(StrEnum): + default = "default" + + +class CreateActorOverlayCommand(ApplicationModel): + media_id: MediaId + generation_id: IndexGenerationId + cluster_id: str = Field(min_length=1) + profile: ActorOverlayProfile = ActorOverlayProfile.default + + +class SnippetProfile(StrEnum): + source = "source" + compatible_mp4 = "compatible_mp4" + + +class CreateSnippetCommand(ApplicationModel): + media_id: MediaId + start_seconds: float = Field(ge=0) + end_seconds: float = Field(gt=0) + profile: SnippetProfile = SnippetProfile.compatible_mp4 + + def model_post_init(self, _context: Any) -> None: + if self.end_seconds <= self.start_seconds: + raise ValueError("end_seconds must be greater than start_seconds") + + class IndexStatus(ApplicationModel): schema_version: int = Field(ge=1) state: str = Field(min_length=1) stage: str = Field(min_length=1) message: str = Field(min_length=1) - repository_root: Path - index_directory: Path - updated_at: str | None = None - current: int | None = Field(default=None, ge=0) - total: int | None = Field(default=None, ge=0) - video: Mapping[str, Any] | None = None - summary: Mapping[str, Any] | None = None - error: str | None = None + updated_at: AwareDatetime | None = None + summary: "IndexStatusSummary | None" = None + + +class IndexStatusSummary(ApplicationModel): + index_schema_version: int = Field(ge=1) + snapshot_id: IndexSnapshotId + media_count: int = Field(ge=0) + media_ids: tuple[MediaId, ...] = () + modalities: tuple[str, ...] = () class SearchCommand(ApplicationModel): @@ -209,7 +316,7 @@ class SearchCommand(ApplicationModel): class PrepareModelsCommand(ApplicationModel): modalities: tuple[str, ...] - capability_options: Mapping[str, Mapping[str, Any]] = Field( + capability_options: Mapping[str, Mapping[str, JsonValue]] = Field( default_factory=dict ) @@ -227,4 +334,4 @@ class DependencyCheckResult(ApplicationModel): class PrepareModelsResult(ApplicationModel): prepared: tuple[str, ...] modalities: tuple[str, ...] - runtime: Mapping[str, Any] + runtime: RuntimeProfile diff --git a/src/vidxp/artifact_service.py b/src/vidxp/artifact_service.py new file mode 100644 index 0000000..7ff4881 --- /dev/null +++ b/src/vidxp/artifact_service.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +import hashlib +import json +from collections.abc import Callable +from pathlib import Path +from uuid import uuid4 + +from vidxp.application_models import ( + ActorOverlayProfile, + Artifact, + CreateSnippetCommand, + SnippetProfile, +) +from vidxp.core.artifacts import ( + ArtifactIntegrityError, + ArtifactKind, + ArtifactRecord, + ArtifactState, +) +from vidxp.core.media import InvalidMediaError, utc_now +from vidxp.media_service import MediaService +from vidxp.ports import ( + ActorRendererPort, + ArtifactCatalogPort, + ArtifactStorePort, + LocalFileResource, + MediaProbePort, + SnippetRendererPort, +) + + +class ArtifactUnavailableError(FileNotFoundError): + """Raised when an artifact is missing, expired, or fails integrity checks.""" + + +class ArtifactRequestError(ValueError): + """Raised when an artifact request is invalid for its source media.""" + + +class InvalidArtifactError(RuntimeError): + """Raised when rendered output is not valid media.""" + + +ARTIFACT_RENDER_CONTRACT_VERSION = 1 + + +def artifact_result(record: ArtifactRecord) -> Artifact: + return Artifact( + schema_version=record.schema_version, + artifact_id=record.artifact_id, + media_id=record.media_id, + generation_id=record.generation_id, + job_id=record.job_id, + kind=record.kind, + profile=record.profile, + mime_type=record.mime_type, + byte_size=record.byte_size, + sha256=record.sha256, + state=record.state, + created_at=record.created_at, + expires_at=record.expires_at, + ) + + +def _request_key(payload: dict) -> str: + encoded = json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + ).encode() + return hashlib.sha256(encoded).hexdigest() + + +class ArtifactService: + def __init__( + self, + *, + catalog: ArtifactCatalogPort, + store: ArtifactStorePort, + media: MediaService, + probe: MediaProbePort, + actor_renderer: ActorRendererPort, + snippet_renderer: SnippetRendererPort, + max_snippet_duration_seconds: float, + ) -> None: + self.catalog = catalog + self.store = store + self.media = media + self.probe = probe + self.actor_renderer = actor_renderer + self.snippet_renderer = snippet_renderer + self.max_snippet_duration_seconds = max_snippet_duration_seconds + + def create_actor_overlay( + self, + *, + media_id: str, + generation_id: str, + cluster_id: str, + detections: list[dict], + profile: ActorOverlayProfile, + ) -> Artifact: + source = self.media.content(media_id) + request_key = _request_key( + { + "kind": ArtifactKind.actor_overlay, + "render_contract_version": ARTIFACT_RENDER_CONTRACT_VERSION, + "media_sha256": source.etag, + "generation_id": generation_id, + "cluster_id": cluster_id, + "profile": profile, + } + ) + return self._create( + media_id=media_id, + generation_id=generation_id, + kind=ArtifactKind.actor_overlay, + profile=profile.value, + request_key=request_key, + suffix=".mp4", + expected_mime_type="video/mp4", + render=lambda destination: self.actor_renderer.render( + source.path, + destination, + cluster_id, + detections, + ), + ) + + def create_snippet(self, command: CreateSnippetCommand) -> Artifact: + media_record = self.media.require_record(command.media_id) + duration = command.end_seconds - command.start_seconds + if command.end_seconds > media_record.duration_seconds: + raise ArtifactRequestError( + "The snippet interval exceeds the media duration." + ) + if duration > self.max_snippet_duration_seconds: + raise ArtifactRequestError( + "The snippet exceeds the configured duration limit." + ) + source = self.media.content(command.media_id) + compatible = command.profile == SnippetProfile.compatible_mp4 + request_key = _request_key( + { + "kind": ArtifactKind.snippet, + "render_contract_version": ARTIFACT_RENDER_CONTRACT_VERSION, + "media_sha256": source.etag, + "start_seconds": command.start_seconds, + "end_seconds": command.end_seconds, + "profile": command.profile, + } + ) + return self._create( + media_id=command.media_id, + generation_id=None, + kind=ArtifactKind.snippet, + profile=command.profile.value, + request_key=request_key, + suffix=".mp4" if compatible else ".mkv", + expected_mime_type=( + "video/mp4" if compatible else "video/x-matroska" + ), + render=lambda destination: self.snippet_renderer.render( + source.path, + destination, + start_seconds=command.start_seconds, + end_seconds=command.end_seconds, + compatible_mp4=compatible, + ), + ) + + def _create( + self, + *, + media_id: str, + generation_id: str | None, + kind: ArtifactKind, + profile: str, + request_key: str, + suffix: str, + expected_mime_type: str, + render: Callable[[Path], None], + ) -> Artifact: + cached = self.catalog.get_artifact_by_request(request_key) + if cached is not None: + try: + self._require_ready(cached) + self._verified_content(cached) + except (ArtifactIntegrityError, ArtifactUnavailableError): + self.catalog.invalidate_artifact_request( + request_key, + cached.artifact_id, + ) + else: + return artifact_result(cached) + + artifact_id = uuid4().hex + staged = self.store.stage(artifact_id, suffix=suffix) + stored = None + try: + render(staged.path) + try: + probed = self.probe.probe(staged.path) + except InvalidMediaError as exc: + raise InvalidArtifactError( + "The rendered artifact is not valid video." + ) from exc + if probed.detected_mime_type != expected_mime_type: + raise InvalidArtifactError( + "The rendered artifact does not match its output profile." + ) + stored = self.store.publish(staged) + record = ArtifactRecord( + artifact_id=artifact_id, + media_id=media_id, + generation_id=generation_id, + request_key=request_key, + kind=kind, + profile=profile, + mime_type=probed.detected_mime_type, + byte_size=stored.byte_size, + sha256=stored.sha256, + storage_key=stored.storage_key, + state=ArtifactState.ready, + created_at=utc_now(), + ) + authoritative = self.catalog.put_artifact(record) + if authoritative.artifact_id != artifact_id: + self._delete_quietly(stored.storage_key) + return artifact_result(authoritative) + except BaseException: + if stored is not None: + self._delete_quietly(stored.storage_key) + raise + finally: + self.store.discard(staged) + + def _delete_quietly(self, storage_key: str) -> None: + try: + self.store.delete(storage_key) + except OSError: + pass + + def get(self, artifact_id: str) -> Artifact: + return artifact_result(self.require_record(artifact_id)) + + def require_record(self, artifact_id: str) -> ArtifactRecord: + record = self.catalog.get_artifact(artifact_id) + if record is None: + raise ArtifactUnavailableError("The artifact is unavailable.") + self._require_ready(record) + return record + + @staticmethod + def _require_ready(record: ArtifactRecord) -> None: + if ( + record.state != ArtifactState.ready + or ( + record.expires_at is not None + and record.expires_at <= utc_now() + ) + ): + raise ArtifactUnavailableError("The artifact is unavailable.") + + def content(self, artifact_id: str) -> LocalFileResource: + record = self.require_record(artifact_id) + path = self._verified_content(record) + suffix = ".mp4" if record.mime_type == "video/mp4" else ".mkv" + return LocalFileResource( + path=path, + filename=f"{record.kind.value}-{record.artifact_id}{suffix}", + mime_type=record.mime_type, + byte_size=record.byte_size, + etag=record.sha256, + ) + + def _verified_content(self, record: ArtifactRecord) -> Path: + try: + return self.store.verify( + record.storage_key, + sha256=record.sha256, + byte_size=record.byte_size, + ) + except (FileNotFoundError, PermissionError) as exc: + raise ArtifactUnavailableError( + "The artifact content is unavailable." + ) from exc + except RuntimeError as exc: + raise ArtifactIntegrityError( + "The artifact failed its integrity check." + ) from exc diff --git a/src/vidxp/capabilities/actor/definition.py b/src/vidxp/capabilities/actor/definition.py index 0f8b974..77154a9 100644 --- a/src/vidxp/capabilities/actor/definition.py +++ b/src/vidxp/capabilities/actor/definition.py @@ -10,7 +10,6 @@ from vidxp.capabilities.actor.operations import ( clusters_operation, detections_operation, - render_operation, ) from vidxp.capabilities.actor.specs import SFACE_MODEL, YUNET_MODEL from vidxp.capabilities.contracts import ( @@ -26,8 +25,6 @@ ActorClustersOutput, ActorDetectionsInput, ActorDetectionsOutput, - ActorRenderInput, - ActorRenderResult, ) from vidxp.capabilities.visual import index_capabilities from vidxp.core.contracts import IndexConfig, VideoSource @@ -87,10 +84,6 @@ def model_manifest( input_model=ActorDetectionsInput, output_model=ActorDetectionsOutput, ), - "render": OperationDefinition( - input_model=ActorRenderInput, - output_model=ActorRenderResult, - ), }, ) @@ -102,7 +95,6 @@ def create_executor() -> CapabilityExecutor: operations={ "clusters": clusters_operation, "detections": detections_operation, - "render": render_operation, }, model_manifest=model_manifest, prepare=prepare_models, diff --git a/src/vidxp/capabilities/actor/operations.py b/src/vidxp/capabilities/actor/operations.py index 5a48893..9f3f09d 100644 --- a/src/vidxp/capabilities/actor/operations.py +++ b/src/vidxp/capabilities/actor/operations.py @@ -3,7 +3,6 @@ from vidxp.capabilities.actor.results import ( actor_clusters, actor_detections, - render_actor_result, ) from vidxp.capabilities.contracts import CapabilityContext from vidxp.capabilities.actor.schemas import ( @@ -11,8 +10,6 @@ ActorClustersOutput, ActorDetectionsInput, ActorDetectionsOutput, - ActorRenderInput, - ActorRenderResult, ) @@ -20,11 +17,11 @@ def clusters_operation( context: CapabilityContext, _request: ActorClustersInput, ) -> ActorClustersOutput: - return ActorClustersOutput( - clusters=actor_clusters( - context.require_config(), - storage=context.require_storage(), - ) + return actor_clusters( + context.require_config(), + storage=context.require_storage(), + page_size=_request.page_size, + cursor=_request.cursor, ) @@ -33,25 +30,10 @@ def detections_operation( request: ActorDetectionsInput, ) -> ActorDetectionsOutput: config = context.require_config() - return ActorDetectionsOutput( - cluster_id=request.cluster_id, - detections=actor_detections( - config, - request.cluster_id, - storage=context.require_storage(), - ), - ) - - -def render_operation( - context: CapabilityContext, - request: ActorRenderInput, -) -> ActorRenderResult: - config = context.require_config() - return render_actor_result( + return actor_detections( config, request.cluster_id, - request.input_path, - request.output_path, storage=context.require_storage(), + page_size=request.page_size, + cursor=request.cursor, ) diff --git a/src/vidxp/capabilities/actor/results.py b/src/vidxp/capabilities/actor/results.py index d78e3cc..d16a4a5 100644 --- a/src/vidxp/capabilities/actor/results.py +++ b/src/vidxp/capabilities/actor/results.py @@ -1,111 +1,266 @@ from __future__ import annotations -from pathlib import Path +import base64 +import json from vidxp.capabilities.actor.schemas import ( ActorClusterSummary, + ActorClustersOutput, ActorDetection, - ActorRenderResult, + ActorDetectionsOutput, ) from vidxp.core.contracts import IndexConfig -from vidxp.ports import IndexStore -from vidxp.core.video import render_actor_video +from vidxp.capabilities.contracts import CapabilityRequestError +from vidxp.ports import IndexReader class ActorClusterNotFoundError(LookupError): """Raised when an actor cluster has no retained detections.""" +def _decode_cursor(cursor: str | None, scope: str) -> int: + if cursor is None: + return 0 + try: + payload = json.loads( + base64.urlsafe_b64decode(cursor.encode()).decode() + ) + if not isinstance(payload, dict): + raise TypeError + offset = int(payload["offset"]) + except ( + KeyError, + TypeError, + UnicodeDecodeError, + ValueError, + json.JSONDecodeError, + ) as exc: + raise CapabilityRequestError("The actor cursor is invalid.") from exc + if payload.get("version") != 1 or payload.get("scope") != scope or offset < 0: + raise CapabilityRequestError("The actor cursor is invalid.") + return offset + + +def _encode_cursor( + offset: int, + *, + scope: str, + has_more: bool, +) -> str | None: + if not has_more: + return None + payload = json.dumps( + {"version": 1, "scope": scope, "offset": offset}, + separators=(",", ":"), + sort_keys=True, + ).encode() + return base64.urlsafe_b64encode(payload).decode() + + +def _snapshot_scope(config: IndexConfig) -> str: + return str(config.snapshot_id or config.fingerprint()) + + +def _decode_detection_cursor( + cursor: str | None, + scope: str, +) -> tuple[int, str] | None: + if cursor is None: + return None + try: + payload = json.loads( + base64.urlsafe_b64decode(cursor.encode()).decode() + ) + after = payload["after"] + if ( + not isinstance(payload, dict) + or payload.get("version") != 1 + or payload.get("scope") != scope + or not isinstance(after, list) + or len(after) != 2 + ): + raise ValueError + key = (int(after[0]), str(after[1])) + except ( + KeyError, + TypeError, + UnicodeDecodeError, + ValueError, + json.JSONDecodeError, + ) as exc: + raise CapabilityRequestError("The actor cursor is invalid.") from exc + if key[0] < 0 or not key[1]: + raise CapabilityRequestError("The actor cursor is invalid.") + return key + + +def _encode_detection_cursor( + key: tuple[int, str], + scope: str, +) -> str: + payload = json.dumps( + {"version": 1, "scope": scope, "after": list(key)}, + separators=(",", ":"), + sort_keys=True, + ).encode() + return base64.urlsafe_b64encode(payload).decode() + + +def _actor_detection(record: dict) -> ActorDetection: + return ActorDetection( + **{ + key: value + for key, value in record.items() + if not key.startswith("bbox_") and key != "video_id" + }, + media_id=str(record["video_id"]), + bbox=( + int(record["bbox_top"]), + int(record["bbox_right"]), + int(record["bbox_bottom"]), + int(record["bbox_left"]), + ), + ) + + def actor_clusters( config: IndexConfig, *, - storage: IndexStore, -) -> tuple[ActorClusterSummary, ...]: - records = storage.records("actor") + storage: IndexReader, + page_size: int, + cursor: str | None, +) -> ActorClustersOutput: + scope = f"actor:clusters:{_snapshot_scope(config)}" + grouped: dict[str, dict] = {} + storage_offset = 0 + while True: + records = storage.records( + "actor", + limit=1000, + offset=storage_offset, + ) + for record in records: + cluster_id = str(record["cluster_id"]) + timestamp = float(record["timestamp"]) + summary = grouped.setdefault( + cluster_id, + { + "media_ids": set(), + "generation_ids": set(), + "detection_count": 0, + "first_timestamp": timestamp, + "last_timestamp": timestamp, + }, + ) + summary["media_ids"].add(str(record["video_id"])) + if record.get("generation_id") is not None: + summary["generation_ids"].add(str(record["generation_id"])) + summary["detection_count"] += 1 + summary["first_timestamp"] = min( + summary["first_timestamp"], + timestamp, + ) + summary["last_timestamp"] = max( + summary["last_timestamp"], + timestamp, + ) + if len(records) < 1000: + break + storage_offset += len(records) - grouped: dict[str, list[dict]] = {} - for record in records: - grouped.setdefault(str(record["cluster_id"]), []).append(record) summaries = [] - for cluster_id, cluster_records in sorted(grouped.items()): - video_ids = { - str(record["video_id"]) - for record in cluster_records - } - generation_ids = { - str(record["generation_id"]) - for record in cluster_records - if record.get("generation_id") is not None - } - if len(video_ids) != 1 or len(generation_ids) > 1: + for cluster_id, summary in sorted(grouped.items()): + media_ids = summary["media_ids"] + generation_ids = summary["generation_ids"] + if len(media_ids) != 1 or len(generation_ids) != 1: raise RuntimeError( f"Actor cluster {cluster_id!r} spans multiple index identities." ) summaries.append( ActorClusterSummary( cluster_id=cluster_id, - video_id=next(iter(video_ids)), - detection_count=len(cluster_records), - first_timestamp=min( - float(record["timestamp"]) for record in cluster_records - ), - last_timestamp=max( - float(record["timestamp"]) for record in cluster_records - ), + media_id=next(iter(media_ids)), + generation_id=next(iter(generation_ids)), + detection_count=summary["detection_count"], + first_timestamp=summary["first_timestamp"], + last_timestamp=summary["last_timestamp"], ) ) - return tuple(summaries) + + offset = _decode_cursor(cursor, scope) + total = len(summaries) + if offset > total: + raise CapabilityRequestError( + "The actor cursor is outside the result set." + ) + selected = tuple(summaries[offset : offset + page_size]) + return ActorClustersOutput( + clusters=selected, + total=total, + next_cursor=_encode_cursor( + offset + len(selected), + scope=scope, + has_more=offset + len(selected) < total, + ), + ) def actor_detections( config: IndexConfig, cluster_id: str, *, - storage: IndexStore, -) -> list[ActorDetection]: - records = storage.records( - "actor", - filters={"cluster_id": cluster_id}, + storage: IndexReader, + page_size: int, + cursor: str | None, +) -> ActorDetectionsOutput: + scope = ( + f"actor:detections:{_snapshot_scope(config)}:{cluster_id}" ) - - detections = [ - ActorDetection( - **{ - key: value - for key, value in record.items() - if not key.startswith("bbox_") - }, - bbox=( - int(record["bbox_top"]), - int(record["bbox_right"]), - int(record["bbox_bottom"]), - int(record["bbox_left"]), - ), + after = _decode_detection_cursor(cursor, scope) + filters = {"cluster_id": cluster_id} + candidates: list[ActorDetection] = [] + storage_offset = 0 + found_any = False + while True: + records = storage.records( + "actor", + filters=filters, + limit=1000, + offset=storage_offset, ) - for record in records - ] - if not detections: + found_any = found_any or bool(records) + for record in records: + detection = _actor_detection(record) + key = (detection.frame_index, detection.detection_id) + if after is None or key > after: + candidates.append(detection) + candidates = sorted( + candidates, + key=lambda item: (item.frame_index, item.detection_id), + )[: page_size + 1] + if len(records) < 1000: + break + storage_offset += len(records) + if not found_any: raise ActorClusterNotFoundError( f"Actor cluster {cluster_id} was not found in the completed index." ) - return sorted( - detections, - key=lambda item: (item.frame_index, item.detection_id), - ) - - -def render_actor_result( - config: IndexConfig, - cluster_id: str, - input_path: str | Path, - output_path: str | Path, - *, - storage: IndexStore, -) -> ActorRenderResult: - detections = actor_detections(config, cluster_id, storage=storage) - destination = Path(output_path) - render_actor_video(input_path, destination, cluster_id, detections) - return ActorRenderResult( - output_path=destination, - detection_count=len(detections), + if after is not None and not candidates: + raise CapabilityRequestError( + "The actor cursor is outside the result set." + ) + has_more = len(candidates) > page_size + detections = tuple(candidates[:page_size]) + next_cursor = None + if has_more: + last = detections[-1] + next_cursor = _encode_detection_cursor( + (last.frame_index, last.detection_id), + scope, + ) + return ActorDetectionsOutput( + cluster_id=cluster_id, + detections=detections, + total=None, + next_cursor=next_cursor, ) diff --git a/src/vidxp/capabilities/actor/schemas.py b/src/vidxp/capabilities/actor/schemas.py index c8d280b..0366c6a 100644 --- a/src/vidxp/capabilities/actor/schemas.py +++ b/src/vidxp/capabilities/actor/schemas.py @@ -1,33 +1,44 @@ from __future__ import annotations -from pathlib import Path - -from pydantic import Field +from pydantic import Field, model_validator from vidxp.capabilities.contracts import CapabilityInput, CapabilityOutput +from vidxp.core.identifiers import IndexGenerationId, MediaId class ActorClustersInput(CapabilityInput): - pass + page_size: int = Field(default=50, gt=0, le=100) + cursor: str | None = Field(default=None, min_length=1, max_length=512) class ActorClusterSummary(CapabilityOutput): cluster_id: str = Field(min_length=1) - video_id: str = Field(min_length=1) + media_id: MediaId + generation_id: IndexGenerationId detection_count: int = Field(ge=0) first_timestamp: float = Field(ge=0) last_timestamp: float = Field(ge=0) + @model_validator(mode="after") + def _validate_interval(self) -> "ActorClusterSummary": + if self.last_timestamp < self.first_timestamp: + raise ValueError("last_timestamp must not precede first_timestamp") + return self + def to_dict(self) -> dict: return self.model_dump(mode="json") class ActorClustersOutput(CapabilityOutput): clusters: tuple[ActorClusterSummary, ...] = () + total: int = Field(ge=0) + next_cursor: str | None = None class ActorDetectionsInput(CapabilityInput): cluster_id: str = Field(min_length=1) + page_size: int = Field(default=50, gt=0, le=100) + cursor: str | None = Field(default=None, min_length=1, max_length=512) class ActorDetection(CapabilityOutput): @@ -39,8 +50,8 @@ class ActorDetection(CapabilityOutput): dataset: str split: str run_id: str - video_id: str - generation_id: str | None = None + media_id: MediaId + generation_id: IndexGenerationId modality: str source_id: str @@ -48,14 +59,5 @@ class ActorDetection(CapabilityOutput): class ActorDetectionsOutput(CapabilityOutput): cluster_id: str = Field(min_length=1) detections: tuple[ActorDetection, ...] = () - - -class ActorRenderInput(CapabilityInput): - cluster_id: str = Field(min_length=1) - input_path: Path - output_path: Path - - -class ActorRenderResult(CapabilityOutput): - output_path: Path - detection_count: int = Field(gt=0) + total: int | None = Field(default=None, ge=0) + next_cursor: str | None = None diff --git a/src/vidxp/capabilities/contracts.py b/src/vidxp/capabilities/contracts.py index b95f262..9448077 100644 --- a/src/vidxp/capabilities/contracts.py +++ b/src/vidxp/capabilities/contracts.py @@ -35,12 +35,19 @@ class _ContractModel(BaseModel): class CapabilityInput(BaseModel): """Base model for validated capability input.""" - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", allow_inf_nan=False) class CapabilityOutput(_ContractModel): """Base model for validated capability output.""" + model_config = ConfigDict( + arbitrary_types_allowed=True, + extra="forbid", + frozen=True, + allow_inf_nan=False, + ) + class CapabilityConfig(_ContractModel): """Base model for settings owned and validated by one capability.""" @@ -121,6 +128,7 @@ class OperationDefinition(_ContractModel): input_model: type[BaseModel] output_model: type[BaseModel] requires_index: bool = True + public: bool = True @field_validator("input_model", "output_model") @classmethod @@ -132,6 +140,55 @@ def _require_model( raise ValueError("Operation schemas must be Pydantic models.") return value + @model_validator(mode="after") + def _validate_public_schemas(self) -> "OperationDefinition": + if not self.public: + return self + forbidden_names = { + "path", + "input_path", + "output_path", + "storage_key", + "repository_root", + "index_directory", + } + + def inspect(value: Any) -> None: + if isinstance(value, dict): + properties = value.get("properties") + if isinstance(properties, dict): + unsafe = { + name + for name in properties + if ( + name in forbidden_names + or name.endswith("_path") + or name.endswith("_directory") + ) + } + if unsafe: + fields = ", ".join(sorted(unsafe)) + raise ValueError( + f"Public operation schema exposes {fields}." + ) + if value.get("format") in {"path", "binary"}: + raise ValueError( + "Public operation schema exposes local or binary data." + ) + if value.get("contentEncoding") == "base64": + raise ValueError( + "Public operation schema embeds binary content." + ) + for nested in value.values(): + inspect(nested) + elif isinstance(value, list): + for nested in value: + inspect(nested) + + inspect(self.input_model.model_json_schema()) + inspect(self.output_model.model_json_schema()) + return self + class CapabilityIndexResult(_ContractModel): """Summary and timing data returned by an indexing handler.""" diff --git a/src/vidxp/capabilities/schemas.py b/src/vidxp/capabilities/schemas.py index e026159..276d13d 100644 --- a/src/vidxp/capabilities/schemas.py +++ b/src/vidxp/capabilities/schemas.py @@ -2,10 +2,11 @@ from typing import Any -from pydantic import Field +from pydantic import Field, JsonValue, field_validator, model_validator from vidxp.capabilities.contracts import CapabilityInput, CapabilityOutput from vidxp.core.contracts import INDEX_SCHEMA_VERSION +from vidxp.core.identifiers import IndexGenerationId, MediaId, VideoId class SearchInput(CapabilityInput): @@ -15,14 +16,52 @@ class SearchInput(CapabilityInput): class SearchHit(CapabilityOutput): rank: int = Field(gt=0) - video_id: str = Field(min_length=1) + media_id: MediaId + video_id: VideoId + generation_id: IndexGenerationId start: float = Field(ge=0) end: float = Field(gt=0) score: float raw_distance: float modality: str = Field(min_length=1) source_id: str = Field(min_length=1) - metadata: dict[str, Any] = Field(default_factory=dict) + metadata: dict[str, JsonValue] = Field(default_factory=dict) + + @field_validator("metadata") + @classmethod + def _reject_internal_metadata( + cls, + value: dict[str, JsonValue], + ) -> dict[str, JsonValue]: + forbidden: set[str] = set() + + def inspect(item: JsonValue) -> None: + if isinstance(item, dict): + for key, nested in item.items(): + if ( + key == "path" + or key == "storage_key" + or key.endswith("_path") + or key.endswith("_directory") + ): + forbidden.add(key) + inspect(nested) + elif isinstance(item, list): + for nested in item: + inspect(nested) + + inspect(value) + if forbidden: + raise ValueError( + "Search metadata contains internal location fields." + ) + return value + + @model_validator(mode="after") + def _validate_interval(self) -> "SearchHit": + if self.end <= self.start: + raise ValueError("end must be greater than start") + return self def to_dict(self) -> dict[str, Any]: return self.model_dump(mode="json") diff --git a/src/vidxp/capabilities/search.py b/src/vidxp/capabilities/search.py index 4741522..484abe6 100644 --- a/src/vidxp/capabilities/search.py +++ b/src/vidxp/capabilities/search.py @@ -14,6 +14,18 @@ from vidxp.ports import IndexStore +PUBLIC_SEARCH_METADATA = frozenset( + { + "text", + "phrase_id", + "frame_index", + "timestamp", + "fps", + "duration", + } +) + + def distance_to_score(raw_distance: float) -> float: """Map distance to an ordering score without claiming probability. @@ -57,7 +69,9 @@ def _to_hits( hits = [] for rank, row in enumerate(ordered, start=1): metadata = row["metadata"] - missing = sorted(required_metadata - metadata.keys()) + missing = sorted( + (required_metadata | {"generation_id"}) - metadata.keys() + ) if missing: raise IndexSchemaError( "The saved index predates the benchmark-ready schema and must " @@ -74,14 +88,20 @@ def _to_hits( hits.append( SearchHit( rank=rank, + media_id=str(metadata["video_id"]), video_id=str(metadata["video_id"]), + generation_id=str(metadata["generation_id"]), start=start, end=end, score=distance_to_score(distance), raw_distance=distance, modality=modality, source_id=str(row["source_id"]), - metadata=metadata, + metadata={ + key: value + for key, value in metadata.items() + if key in PUBLIC_SEARCH_METADATA + }, ) ) return tuple(hits) diff --git a/src/vidxp/cli.py b/src/vidxp/cli.py index e003098..bfe62b0 100644 --- a/src/vidxp/cli.py +++ b/src/vidxp/cli.py @@ -10,8 +10,10 @@ from vidxp import __version__ from vidxp.application_models import ApplicationError -from vidxp.capabilities.actor.cli import app as actor_app +from vidxp.cli_commands.actors import app as actor_app from vidxp.cli_commands.index import app as index_app +from vidxp.cli_commands.media import app as media_app +from vidxp.cli_commands.artifacts import app as artifacts_app from vidxp.cli_commands.repositories import app as repositories_app from vidxp.cli_commands.runtime import doctor, prepare, ui from vidxp.cli_commands.search import search @@ -25,6 +27,8 @@ help="Index and search video with installable capabilities.", ) app.add_typer(index_app, name="index") +app.add_typer(media_app, name="media") +app.add_typer(artifacts_app, name="artifacts") app.command("search")(search) app.add_typer(repositories_app, name="repositories") app.add_typer(actor_app, name="actors") diff --git a/src/vidxp/capabilities/actor/cli.py b/src/vidxp/cli_commands/actors.py similarity index 64% rename from src/vidxp/capabilities/actor/cli.py rename to src/vidxp/cli_commands/actors.py index b9654a5..2569859 100644 --- a/src/vidxp/capabilities/actor/cli.py +++ b/src/vidxp/cli_commands/actors.py @@ -1,6 +1,5 @@ from __future__ import annotations -from pathlib import Path from typing import Annotated, Iterable import typer @@ -14,6 +13,7 @@ emit_json, state_from_context, ) +from vidxp.application_models import CreateActorOverlayCommand app = typer.Typer( @@ -30,7 +30,7 @@ def complete_cluster( if not isinstance(state, CLIState): return try: - clusters = state.service.actor_clusters() + clusters = state.service.actor_clusters(page_size=100).clusters except Exception: return for cluster in clusters: @@ -44,6 +44,14 @@ def complete_cluster( @app.command("list") def actors_list( ctx: typer.Context, + page_size: Annotated[ + int, + typer.Option("--page-size", min=1, max=100), + ] = 50, + cursor: Annotated[ + str | None, + typer.Option("--cursor", help="Cursor returned by the previous page."), + ] = None, json_output: Annotated[ bool, typer.Option("--json", help="Emit machine-readable JSON."), @@ -52,10 +60,16 @@ def actors_list( """List actor clusters in the selected index.""" state = state_from_context(ctx) - clusters = state.service.actor_clusters() + page = state.service.actor_clusters( + page_size=page_size, + cursor=cursor, + ) + clusters = page.clusters payload = { "clusters": [cluster.to_dict() for cluster in clusters], "count": len(clusters), + "total": page.total, + "next_cursor": page.next_cursor, } if effective_output_format(state, json_output) == OutputFormat.json: emit_json(payload) @@ -69,7 +83,7 @@ def actors_list( for cluster in clusters: table.add_row( cluster.cluster_id, - cluster.video_id, + cluster.media_id, str(cluster.detection_count), f"{cluster.first_timestamp:.3f}s", f"{cluster.last_timestamp:.3f}s", @@ -91,6 +105,10 @@ def actors_inspect( int, typer.Option("--limit", min=1, help="Maximum detections to display."), ] = 20, + cursor: Annotated[ + str | None, + typer.Option("--cursor", help="Cursor returned by the previous page."), + ] = None, json_output: Annotated[ bool, typer.Option("--json", help="Emit machine-readable JSON."), @@ -99,15 +117,21 @@ def actors_inspect( """Inspect retained detections for one actor cluster.""" state = state_from_context(ctx) - detections = state.service.actor_detections(cluster_id) + page = state.service.actor_detections( + cluster_id, + page_size=min(limit, 100), + cursor=cursor, + ) + detections = page.detections payload = { "cluster_id": cluster_id, - "detection_count": len(detections), + "detection_count": page.total, "detections": [ detection.model_dump(mode="json") - for detection in detections[:limit] + for detection in detections ], - "truncated": len(detections) > limit, + "truncated": page.next_cursor is not None, + "next_cursor": page.next_cursor, } if effective_output_format(state, json_output) == OutputFormat.json: emit_json(payload) @@ -116,15 +140,17 @@ def actors_inspect( table.add_column("Frame", justify="right") table.add_column("Timestamp", justify="right") table.add_column("Detection") - for detection in detections[:limit]: + for detection in detections: table.add_row( str(detection.frame_index), f"{detection.timestamp:.3f}s", detection.detection_id, ) Console().print(table) - if len(detections) > limit: - typer.echo(f"Showing {limit} of {len(detections)} detections.") + if page.next_cursor is not None: + typer.echo( + f"Showing {len(detections)} detections; more are available." + ) @app.command("render") @@ -137,25 +163,6 @@ def actors_render( help="Actor cluster identifier.", ), ], - input_path: Annotated[ - Path, - typer.Argument( - exists=True, - dir_okay=False, - readable=True, - resolve_path=True, - help="Source video used to create the active index.", - ), - ], - output_path: Annotated[ - Path, - typer.Option( - "--output", - "-o", - dir_okay=False, - help="Rendered video destination.", - ), - ] = Path("output.mp4"), json_output: Annotated[ bool, typer.Option("--json", help="Emit machine-readable JSON."), @@ -164,20 +171,38 @@ def actors_render( """Render one actor cluster as a result video.""" state = state_from_context(ctx) + cursor = None + selected = None + while True: + page = state.service.actor_clusters( + page_size=100, + cursor=cursor, + ) + selected = next( + ( + cluster + for cluster in page.clusters + if cluster.cluster_id == cluster_id + ), + None, + ) + if selected is not None or page.next_cursor is None: + break + cursor = page.next_cursor + if selected is None: + raise typer.BadParameter("Actor cluster was not found.") result = state.service.render_actor( - cluster_id, - input_path, - output_path, + CreateActorOverlayCommand( + cluster_id=cluster_id, + media_id=selected.media_id, + generation_id=selected.generation_id, + ) ) - payload = { - "cluster_id": cluster_id, - "output_path": str(result.output_path), - "detection_count": result.detection_count, - } + payload = result.model_dump(mode="json") if effective_output_format(state, json_output) == OutputFormat.json: emit_json(payload) else: typer.secho( - f"Video saved as {result.output_path}", + f"Actor overlay artifact created: {result.artifact_id}", fg=typer.colors.GREEN, ) diff --git a/src/vidxp/cli_commands/artifacts.py b/src/vidxp/cli_commands/artifacts.py new file mode 100644 index 0000000..65b600e --- /dev/null +++ b/src/vidxp/cli_commands/artifacts.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from typing import Annotated + +import typer + +from vidxp.application_models import CreateSnippetCommand, SnippetProfile +from vidxp.cli_support import ( + OutputFormat, + effective_output_format, + emit_json, + state_from_context, +) + + +app = typer.Typer(no_args_is_help=True, help="Inspect generated artifacts.") + + +@app.command("snippet") +def create_snippet( + ctx: typer.Context, + media_id: Annotated[ + str, + typer.Argument(help="Cataloged media identifier."), + ], + start_seconds: Annotated[ + float, + typer.Argument(min=0, help="Snippet start in seconds."), + ], + end_seconds: Annotated[ + float, + typer.Argument(min=0, help="Snippet end in seconds."), + ], + profile: Annotated[ + SnippetProfile, + typer.Option(help="Output compatibility profile."), + ] = SnippetProfile.compatible_mp4, + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """Create a managed video snippet artifact.""" + + state = state_from_context(ctx) + artifact = state.service.create_snippet( + CreateSnippetCommand( + media_id=media_id, + start_seconds=start_seconds, + end_seconds=end_seconds, + profile=profile, + ) + ) + payload = artifact.model_dump(mode="json") + if effective_output_format(state, json_output) == OutputFormat.json: + emit_json(payload) + else: + typer.secho( + f"Snippet artifact created: {artifact.artifact_id}", + fg=typer.colors.GREEN, + ) + + +@app.command("show") +def show_artifact( + ctx: typer.Context, + artifact_id: Annotated[ + str, + typer.Argument(help="Artifact identifier."), + ], + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """Show metadata for one generated artifact.""" + + state = state_from_context(ctx) + artifact = state.service.get_artifact(artifact_id) + payload = artifact.model_dump(mode="json") + if effective_output_format(state, json_output) == OutputFormat.json: + emit_json(payload) + else: + typer.echo( + f"{artifact.kind.value} {artifact.artifact_id} " + f"({artifact.byte_size:,} bytes)" + ) diff --git a/src/vidxp/cli_commands/index.py b/src/vidxp/cli_commands/index.py index 836c0b0..3e457c8 100644 --- a/src/vidxp/cli_commands/index.py +++ b/src/vidxp/cli_commands/index.py @@ -1,6 +1,5 @@ from __future__ import annotations -from pathlib import Path from typing import Annotated, Iterable import typer @@ -24,9 +23,8 @@ def create_index( state: CLIState, - path: Path, + media_id: str, *, - media_id: str | None, modalities: Iterable[str], frame_stride: int, capability_options: dict[str, dict], @@ -37,7 +35,6 @@ def create_index( with IndexProgress(show_progress) as progress: result = state.service.create_index( CreateIndexCommand( - path=path, media_id=media_id, modalities=tuple(modalities), frame_stride=frame_stride, @@ -45,7 +42,7 @@ def create_index( ), progress_callback=progress.update, ) - summary = dict(result.summary) + summary = result.model_dump(mode="json") if state.output_format == OutputFormat.json: emit_json(summary) else: @@ -60,15 +57,9 @@ def create_index( @app.command("create") def index_create( ctx: typer.Context, - path: Annotated[ - Path, - typer.Argument( - exists=True, - dir_okay=False, - readable=True, - resolve_path=True, - help="Local video file to index.", - ), + media_id: Annotated[ + str, + typer.Argument(help="Registered media identifier to index."), ], modalities: Annotated[ list[str] | None, @@ -78,15 +69,6 @@ def index_create( help="Modality to index; repeat to select more than one.", ), ] = None, - media_id: Annotated[ - str | None, - typer.Option( - "--media-id", - help=( - "Stable media identifier. Defaults to the input content hash." - ), - ), - ] = None, frame_stride: Annotated[ int, typer.Option( @@ -111,7 +93,6 @@ def index_create( state = state_from_context(ctx) create_index( state, - path, media_id=media_id, modalities=selected_modalities( modalities, @@ -191,7 +172,6 @@ def index_clear( cleared = state.service.clear_index() payload = { "cleared": cleared, - "index_directory": str(state.service.index_directory), } if effective_output_format(state, json_output) == OutputFormat.json: emit_json(payload) diff --git a/src/vidxp/cli_commands/media.py b/src/vidxp/cli_commands/media.py new file mode 100644 index 0000000..248e1b6 --- /dev/null +++ b/src/vidxp/cli_commands/media.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Annotated + +import typer +from rich.console import Console +from rich.table import Table + +from vidxp.application_models import ImportMediaCommand, ListMediaCommand +from vidxp.cli_support import ( + OutputFormat, + effective_output_format, + emit_json, + state_from_context, +) + + +app = typer.Typer(no_args_is_help=True, help="Import and inspect local media.") + + +@app.command("import") +def import_media( + ctx: typer.Context, + path: Annotated[ + Path, + typer.Argument( + exists=True, + dir_okay=False, + readable=True, + resolve_path=True, + help="Local video file to copy into managed storage.", + ), + ], + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """Validate and register a local video, returning its stable media ID.""" + + state = state_from_context(ctx) + result = state.service.import_media(ImportMediaCommand(path=path)) + payload = result.model_dump(mode="json") + if effective_output_format(state, json_output) == OutputFormat.json: + emit_json(payload) + else: + typer.secho( + f"Imported {result.original_filename} as {result.media_id}", + fg=typer.colors.GREEN, + ) + + +@app.command("list") +def list_media( + ctx: typer.Context, + limit: Annotated[ + int, + typer.Option("--limit", min=1, max=100), + ] = 100, + cursor: Annotated[ + str | None, + typer.Option("--cursor", help="Cursor returned by the previous page."), + ] = None, + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """List ready media assets in the selected repository.""" + + state = state_from_context(ctx) + page = state.service.list_media( + ListMediaCommand(page_size=limit, cursor=cursor) + ) + assets = page.items + payload = page.model_dump(mode="json") + if effective_output_format(state, json_output) == OutputFormat.json: + emit_json(payload) + return + table = Table(title="Media") + table.add_column("ID") + table.add_column("Filename") + table.add_column("Duration", justify="right") + table.add_column("Size", justify="right") + for asset in assets: + table.add_row( + asset.media_id, + asset.original_filename, + f"{asset.duration_seconds:.3f}s", + f"{asset.byte_size:,}", + ) + Console().print(table) diff --git a/src/vidxp/cli_support.py b/src/vidxp/cli_support.py index 56ef440..2b7b489 100644 --- a/src/vidxp/cli_support.py +++ b/src/vidxp/cli_support.py @@ -104,15 +104,8 @@ def emit_status( table.add_row("Message", str(status.get("message", "—"))) if updated_at := status.get("updated_at"): table.add_row("Updated", str(updated_at)) - if video := status.get("video"): - table.add_row( - "Video", - str(video.get("source_name") or video.get("path") or "—"), - ) summary = status.get("summary") or {} - if modalities := (summary.get("configuration") or {}).get( - "enabled_modalities" - ): + if modalities := summary.get("modalities"): table.add_row("Modalities", ", ".join(map(str, modalities))) Console().print(table) diff --git a/src/vidxp/composition.py b/src/vidxp/composition.py index 5fe9596..b25d3c5 100644 --- a/src/vidxp/composition.py +++ b/src/vidxp/composition.py @@ -12,6 +12,15 @@ LOCAL_INDEX_RUNTIME_CHECKS, LocalIndexBackend, ) +from vidxp.infrastructure.local_artifacts import ( + FFmpegSnippetRenderer, + LocalActorRenderer, + LocalArtifactStore, +) +from vidxp.infrastructure.local_catalog import LocalCatalog +from vidxp.infrastructure.local_media import FFprobeMediaProbe, LocalMediaStore +from vidxp.artifact_service import ArtifactService +from vidxp.media_service import MediaService from vidxp.runtime import ModelRuntime, RuntimeBackendUnavailableError from vidxp.repositories import ( RepositoryConfig, @@ -57,12 +66,38 @@ def create_application( "The requested runtime backend is unavailable.", ) from exc backend = LocalIndexBackend(registry, runtime, active_settings.layout) + active_settings.layout.ensure_local_directories() + catalog = LocalCatalog(active_settings.layout.catalog) + media = MediaService( + settings=active_settings, + catalog=catalog, + store=LocalMediaStore( + active_settings.layout.media, + max_bytes=active_settings.max_local_import_bytes, + ), + probe=FFprobeMediaProbe(active_settings.ffprobe_executable), + ) + artifacts = ArtifactService( + catalog=catalog, + store=LocalArtifactStore(active_settings.layout.artifacts), + media=media, + probe=FFprobeMediaProbe(active_settings.ffprobe_executable), + actor_renderer=LocalActorRenderer(), + snippet_renderer=FFmpegSnippetRenderer( + active_settings.ffmpeg_executable + ), + max_snippet_duration_seconds=( + active_settings.max_snippet_duration_seconds + ), + ) return VidXPApplication( settings=active_settings, layout=active_settings.layout, registry=registry, runtime=runtime, index_backend=backend, + media_service=media, + artifact_service=artifacts, ) diff --git a/src/vidxp/core/artifacts.py b/src/vidxp/core/artifacts.py new file mode 100644 index 0000000..f305d36 --- /dev/null +++ b/src/vidxp/core/artifacts.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from enum import StrEnum +from pathlib import Path +from typing import Literal + +from pydantic import ( + AwareDatetime, + BaseModel, + ConfigDict, + Field, + field_validator, +) + +from vidxp.core.identifiers import ( + ArtifactId, + IndexGenerationId, + JobId, + MediaId, + MimeType, + Sha256, +) +from vidxp.core.storage_keys import validate_storage_key + + +ARTIFACT_SCHEMA_VERSION = 1 + + +class ArtifactIntegrityError(RuntimeError): + """Raised when managed artifact bytes no longer match catalog authority.""" + + +class ArtifactRenderError(RuntimeError): + """Raised when an artifact renderer cannot produce the requested output.""" + + +class ArtifactRendererUnavailableError(RuntimeError): + """Raised when the configured artifact renderer is unavailable.""" + + +class ArtifactKind(StrEnum): + actor_overlay = "actor_overlay" + snippet = "snippet" + + +class ArtifactState(StrEnum): + ready = "ready" + + +class _ArtifactModel(BaseModel): + model_config = ConfigDict( + extra="forbid", + frozen=True, + allow_inf_nan=False, + strict=True, + ) + + +class ArtifactRecord(_ArtifactModel): + """Authoritative internal artifact entry.""" + + schema_version: Literal[ARTIFACT_SCHEMA_VERSION] = ARTIFACT_SCHEMA_VERSION + artifact_id: ArtifactId + media_id: MediaId + generation_id: IndexGenerationId | None = None + request_key: Sha256 + job_id: JobId | None = None + kind: ArtifactKind + profile: str = Field(min_length=1) + mime_type: MimeType + byte_size: int = Field(gt=0) + sha256: Sha256 + storage_key: str = Field(min_length=1) + state: ArtifactState = ArtifactState.ready + created_at: AwareDatetime + expires_at: AwareDatetime | None = None + + @field_validator("storage_key") + @classmethod + def _validate_storage_key(cls, value: str) -> str: + return validate_storage_key(value) + + +class StagedArtifact(_ArtifactModel): + artifact_id: ArtifactId + path: Path + + +class StoredArtifact(_ArtifactModel): + sha256: Sha256 + byte_size: int = Field(gt=0) + storage_key: str = Field(min_length=1) + local_path: Path diff --git a/src/vidxp/core/generations.py b/src/vidxp/core/generations.py index 83deacf..03961eb 100644 --- a/src/vidxp/core/generations.py +++ b/src/vidxp/core/generations.py @@ -17,7 +17,7 @@ INDEX_SCHEMA_VERSION, MANIFEST_SCHEMA_VERSION, ) -from vidxp.core.identifiers import Identifier, Sha256, Uuid4Hex +from vidxp.core.identifiers import Identifier, IndexGenerationId, Sha256 class _GenerationManifestModel(BaseModel): @@ -60,7 +60,7 @@ class CompletedGenerationManifest(_GenerationManifestModel): dataset: Identifier split: Identifier run_id: Identifier - generation_id: Uuid4Hex + generation_id: IndexGenerationId state: Literal["complete"] created_at: AwareDatetime updated_at: AwareDatetime diff --git a/src/vidxp/core/identifiers.py b/src/vidxp/core/identifiers.py index 3f5b781..8708b6d 100644 --- a/src/vidxp/core/identifiers.py +++ b/src/vidxp/core/identifiers.py @@ -13,15 +13,6 @@ pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$", ), ] -RepositoryId: TypeAlias = Identifier -MediaId: TypeAlias = Identifier -VideoId: TypeAlias = Identifier -IndexGenerationId: TypeAlias = Identifier -IndexSnapshotId: TypeAlias = Identifier -JobId: TypeAlias = Identifier -ArtifactId: TypeAlias = Identifier - - def _require_uuid4_hex(value: str) -> str: identifier = UUID(hex=value) if identifier.version != 4 or identifier.hex != value: @@ -38,3 +29,20 @@ def _require_uuid4_hex(value: str) -> str: str, StringConstraints(pattern=r"^[0-9a-f]{64}$"), ] +MimeType: TypeAlias = Annotated[ + str, + StringConstraints( + strip_whitespace=True, + min_length=3, + max_length=127, + pattern=r"^[A-Za-z0-9!#$&^_.+-]+/[A-Za-z0-9!#$&^_.+-]+$", + ), +] + +RepositoryId: TypeAlias = Identifier +MediaId: TypeAlias = Uuid4Hex +VideoId: TypeAlias = MediaId +IndexGenerationId: TypeAlias = Uuid4Hex +IndexSnapshotId: TypeAlias = Uuid4Hex +JobId: TypeAlias = Identifier +ArtifactId: TypeAlias = Uuid4Hex diff --git a/src/vidxp/core/media.py b/src/vidxp/core/media.py new file mode 100644 index 0000000..274381b --- /dev/null +++ b/src/vidxp/core/media.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from pathlib import Path +from typing import Literal + +from pydantic import ( + AwareDatetime, + BaseModel, + ConfigDict, + Field, + field_validator, + model_validator, +) + +from vidxp.core.identifiers import MediaId, MimeType, Sha256, VideoId +from vidxp.core.storage_keys import validate_storage_key + + +MEDIA_SCHEMA_VERSION = 1 + + +class InvalidMediaError(ValueError): + """Raised when media cannot be safely probed as a supported video.""" + + +class MediaProbeUnavailableError(RuntimeError): + """Raised when the configured media probe is unavailable.""" + + +class MediaStoreIntegrityError(RuntimeError): + """Raised when managed content no longer matches catalog authority.""" + + +class MediaImportLimitError(ValueError): + """Raised when a local import exceeds the configured byte limit.""" + + +class MediaUnavailableError(FileNotFoundError): + """Raised when a cataloged media asset cannot be materialized.""" + + +class MediaState(StrEnum): + ready = "ready" + + +class _MediaModel(BaseModel): + model_config = ConfigDict( + extra="forbid", + frozen=True, + allow_inf_nan=False, + strict=True, + ) + + +class MediaStream(_MediaModel): + index: int = Field(ge=0) + kind: str = Field(min_length=1) + codec: str = Field(min_length=1) + width: int | None = Field(default=None, gt=0) + height: int | None = Field(default=None, gt=0) + channels: int | None = Field(default=None, gt=0) + sample_rate: int | None = Field(default=None, gt=0) + + +class MediaProbe(_MediaModel): + detected_mime_type: MimeType + container: str = Field(min_length=1) + duration_seconds: float = Field(gt=0) + streams: tuple[MediaStream, ...] = Field(min_length=1) + + @property + def has_video(self) -> bool: + return any(stream.kind == "video" for stream in self.streams) + + @model_validator(mode="after") + def _require_video_stream(self) -> "MediaProbe": + if not self.has_video: + raise ValueError("media probe must contain a video stream") + return self + + +class StoredMedia(_MediaModel): + """Internal managed-media location produced by a media store.""" + + sha256: Sha256 + byte_size: int = Field(gt=0) + storage_key: str = Field(min_length=1) + local_path: Path + + @field_validator("storage_key") + @classmethod + def _validate_storage_key(cls, value: str) -> str: + return validate_storage_key(value) + + +class StagedMedia(_MediaModel): + """Quarantined media awaiting probe and managed-store publication.""" + + sha256: Sha256 + byte_size: int = Field(gt=0) + storage_key: str = Field(min_length=1) + path: Path + + @field_validator("storage_key") + @classmethod + def _validate_storage_key(cls, value: str) -> str: + return validate_storage_key(value) + + +class MediaRecord(_MediaModel): + """Authoritative internal catalog entry; storage_key is never projected.""" + + schema_version: Literal[MEDIA_SCHEMA_VERSION] = MEDIA_SCHEMA_VERSION + media_id: MediaId + video_id: VideoId + sha256: Sha256 + original_filename: str = Field(min_length=1, max_length=255) + byte_size: int = Field(gt=0) + declared_mime_type: MimeType | None = None + detected_mime_type: MimeType + container: str = Field(min_length=1) + duration_seconds: float = Field(gt=0) + streams: tuple[MediaStream, ...] = Field(min_length=1) + storage_key: str = Field(min_length=1) + state: MediaState = MediaState.ready + created_at: AwareDatetime + + @field_validator("original_filename") + @classmethod + def _filename_only(cls, value: str) -> str: + return validate_display_filename(value) + + @field_validator("storage_key") + @classmethod + def _validate_storage_key(cls, value: str) -> str: + return validate_storage_key(value) + + @model_validator(mode="after") + def _require_video_stream(self) -> "MediaRecord": + if not any(stream.kind == "video" for stream in self.streams): + raise ValueError("ready media must contain a video stream") + return self + + +def utc_now() -> datetime: + from datetime import timezone + + return datetime.now(timezone.utc) + + +def validate_display_filename(value: str) -> str: + if ( + Path(value).name != value + or "/" in value + or "\\" in value + or value in {".", ".."} + or any(ord(character) < 32 or ord(character) == 127 for character in value) + ): + raise ValueError( + "original_filename must be a basename without control characters" + ) + return value diff --git a/src/vidxp/core/runner.py b/src/vidxp/core/runner.py index 1129231..bd19c9b 100644 --- a/src/vidxp/core/runner.py +++ b/src/vidxp/core/runner.py @@ -544,7 +544,6 @@ def report(event: dict[str, Any]) -> None: "split": active_config.split, "run_id": active_config.run_id, "video_id": video_id, - "configuration": active_config.to_dict(), } ) report( diff --git a/src/vidxp/core/snapshots.py b/src/vidxp/core/snapshots.py index 9e1b9c4..d300920 100644 --- a/src/vidxp/core/snapshots.py +++ b/src/vidxp/core/snapshots.py @@ -11,7 +11,12 @@ model_validator, ) -from vidxp.core.identifiers import Identifier, Sha256, Uuid4Hex +from vidxp.core.identifiers import ( + Identifier, + IndexGenerationId, + IndexSnapshotId, + Sha256, +) INDEX_SNAPSHOT_SCHEMA_VERSION = 1 @@ -27,7 +32,7 @@ class _SnapshotModel(BaseModel): class GenerationReference(_SnapshotModel): - generation_id: Uuid4Hex + generation_id: IndexGenerationId media_id: Identifier manifest_sha256: Sha256 input_sha256: Sha256 @@ -51,7 +56,7 @@ class IndexSnapshot(_SnapshotModel): schema_version: Literal[INDEX_SNAPSHOT_SCHEMA_VERSION] = ( INDEX_SNAPSHOT_SCHEMA_VERSION ) - snapshot_id: Uuid4Hex + snapshot_id: IndexSnapshotId created_at: AwareDatetime config_fingerprint: Sha256 configuration: dict[str, JsonValue] @@ -75,6 +80,6 @@ class ActiveSnapshotPointer(_SnapshotModel): schema_version: Literal[ACTIVE_SNAPSHOT_POINTER_SCHEMA_VERSION] = ( ACTIVE_SNAPSHOT_POINTER_SCHEMA_VERSION ) - snapshot_id: Uuid4Hex + snapshot_id: IndexSnapshotId snapshot_sha256: Sha256 updated_at: AwareDatetime diff --git a/src/vidxp/core/storage.py b/src/vidxp/core/storage.py index ba0458f..f37260c 100644 --- a/src/vidxp/core/storage.py +++ b/src/vidxp/core/storage.py @@ -250,10 +250,16 @@ def records( video_id: str | None = None, generation_ids: Iterable[str] | None = None, filters: Mapping[str, Any] | None = None, + limit: int | None = None, + offset: int = 0, ) -> list[dict[str, Any]]: selected_generations = _generation_ids(generation_ids) if selected_generations == (): return [] + if limit is not None and limit <= 0: + raise ValueError("limit must be positive") + if offset < 0: + raise ValueError("offset must be nonnegative") result = self.collection(modality).get( where=metadata_filter( self.config, @@ -262,6 +268,8 @@ def records( extra=filters, ), include=["metadatas"], + limit=limit, + offset=offset, ) return [ dict(metadata) @@ -275,6 +283,7 @@ def count_records( *, video_id: str | None = None, generation_ids: Iterable[str] | None = None, + filters: Mapping[str, Any] | None = None, ) -> int: selected_generations = _generation_ids(generation_ids) if selected_generations == (): @@ -284,6 +293,7 @@ def count_records( self.config, video_id=video_id, generation_ids=selected_generations, + extra=filters, ), include=[], ) @@ -341,8 +351,28 @@ def records( *, video_id: str | None = None, filters: Mapping[str, Any] | None = None, + limit: int | None = None, + offset: int = 0, ) -> list[dict[str, Any]]: - return self._store.records( + options: dict[str, Any] = { + "video_id": video_id, + "generation_ids": self._generation_ids, + "filters": filters, + } + if limit is not None: + options["limit"] = limit + if offset: + options["offset"] = offset + return self._store.records(modality, **options) + + def count_records( + self, + modality: str, + *, + video_id: str | None = None, + filters: Mapping[str, Any] | None = None, + ) -> int: + return self._store.count_records( modality, video_id=video_id, generation_ids=self._generation_ids, diff --git a/src/vidxp/core/storage_keys.py b/src/vidxp/core/storage_keys.py new file mode 100644 index 0000000..f1fc183 --- /dev/null +++ b/src/vidxp/core/storage_keys.py @@ -0,0 +1,13 @@ +from pathlib import PurePosixPath + + +def validate_storage_key(value: str) -> str: + key = PurePosixPath(value) + if ( + key.is_absolute() + or ".." in key.parts + or "\\" in value + or value != key.as_posix() + ): + raise ValueError("storage_key must be a normalized relative key") + return value diff --git a/src/vidxp/frontend.py b/src/vidxp/frontend.py index 3bee718..cbf7541 100644 --- a/src/vidxp/frontend.py +++ b/src/vidxp/frontend.py @@ -1,7 +1,8 @@ import argparse -import hashlib import logging +import shutil import sys +import tempfile from functools import lru_cache from pathlib import Path from typing import Sequence @@ -11,7 +12,9 @@ from vidxp.application import VidXPApplication from vidxp.application_models import ( ApplicationError, + CreateActorOverlayCommand, DependencyCheckCommand, + ImportMediaCommand, SearchCommand, ) from vidxp.composition import create_application @@ -51,21 +54,26 @@ def _settings_from_arguments( INDEX_ERROR_KEY = "_vidxp_index_error" SEARCH_RESULT_KEY = "_vidxp_search_result" CANCEL_REQUESTED_KEY = "_vidxp_cancel_requested" +MEDIA_ID_KEY = "_vidxp_media_id" +UPLOAD_TOKEN_KEY = "_vidxp_upload_token" -def _video_hash(uploaded_video) -> str | None: +def _upload_token(uploaded_video): if uploaded_video is None: return None - return hashlib.sha256(uploaded_video.getvalue()).hexdigest() + return ( + getattr(uploaded_video, "file_id", None), + uploaded_video.name, + getattr(uploaded_video, "size", None), + ) -def _is_search_ready(status, uploaded_video) -> bool: - saved_video_path = _configured_service().layout.media / "source-video.mp4" +def _is_search_ready(status, media_id: str | None) -> bool: if not status or status.get("state") != "ready": return False - if uploaded_video is None: - return saved_video_path.is_file() - return status.get("video", {}).get("sha256") == _video_hash(uploaded_video) + return media_id is not None and media_id in ( + (status.get("summary") or {}).get("media_ids") or () + ) def _render_summary(summary): @@ -74,10 +82,9 @@ def _render_summary(summary): st.caption( " · ".join( ( - f"Language: {summary.get('language', '—')}", - f"Dialogue phrases: {summary.get('dialogue_phrases', 0):,}", - f"Scene frames: {summary.get('scene_frames', 0):,}", - f"Actor clusters: {summary.get('actor_clusters', 0):,}", + f"Media: {summary.get('media_count', 0):,}", + "Capabilities: " + + ", ".join(summary.get("modalities", ())), ) ) ) @@ -93,7 +100,7 @@ def _render_progress(event): ) -def _render_index_status(status, active, uploaded_video, request_error=None): +def _render_index_status(status, active, media_id, request_error=None): if request_error: st.error(request_error) return @@ -108,7 +115,7 @@ def _render_index_status(status, active, uploaded_video, request_error=None): elif not status or status.get("state") == "missing": st.caption("First indexing may download missing runtime model weights.") elif status["state"] == "ready": - if _is_search_ready(status, uploaded_video): + if _is_search_ready(status, media_id): st.success(status.get("message", "The video index is ready.")) _render_summary(status.get("summary")) else: @@ -157,21 +164,37 @@ def _available_index_modalities() -> tuple[str, ...]: def _run_indexing(uploaded_video, status, modalities): service = _configured_service() - saved_video_path = service.layout.media / "source-video.mp4" + temporary_path = None try: if uploaded_video is not None: - saved_video_path.parent.mkdir(parents=True, exist_ok=True) - saved_video_path.write_bytes(uploaded_video.getvalue()) - source_name = uploaded_video.name - else: - source_name = ( - status.get("video", {}).get("source_name", saved_video_path.name) - if status - else saved_video_path.name + suffix = Path(uploaded_video.name).suffix + with tempfile.NamedTemporaryFile( + mode="w+b", + suffix=suffix, + delete=False, + ) as temporary: + temporary_path = Path(temporary.name) + uploaded_video.seek(0) + shutil.copyfileobj(uploaded_video, temporary) + asset = service.import_media( + ImportMediaCommand( + path=temporary_path, + original_filename=Path(uploaded_video.name).name, + declared_mime_type=getattr(uploaded_video, "type", None), + ) ) + media_id = asset.media_id + st.session_state[MEDIA_ID_KEY] = media_id + st.session_state[UPLOAD_TOKEN_KEY] = _upload_token(uploaded_video) + else: + media_id = st.session_state.get(MEDIA_ID_KEY) + if media_id is None and status: + media_ids = (status.get("summary") or {}).get("media_ids") or () + media_id = media_ids[0] if len(media_ids) == 1 else None + if media_id is None: + raise ValueError("Select or import media before indexing.") start_indexing( - str(saved_video_path), - source_name, + media_id, service, modalities=modalities, ) @@ -185,34 +208,41 @@ def _run_indexing(uploaded_video, status, modalities): else: st.session_state.pop(INDEX_ERROR_KEY, None) finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) st.session_state[INDEX_REQUESTED_KEY] = False st.rerun() def _run_search(search_type, query): service = _configured_service() - saved_video_path = service.layout.media / "source-video.mp4" - actor_output_path = service.layout.artifacts / "actor-result.mp4" try: status = service.index_status().model_dump(mode="json") if status.get("state") != "ready": raise IndexNotReadyError("The video index is not ready.") if search_type == "actor": - actor_output_path.unlink(missing_ok=True) - service.render_actor( - query, - saved_video_path, - actor_output_path, + clusters = service.actor_clusters(page_size=100).clusters + cluster = next( + ( + item + for item in clusters + if item.cluster_id == query + ), + None, + ) + if cluster is None: + return {"error": "No matching actor cluster was found."} + artifact = service.render_actor( + CreateActorOverlayCommand( + cluster_id=query, + media_id=cluster.media_id, + generation_id=cluster.generation_id, + ) ) - if ( - not actor_output_path.is_file() - or actor_output_path.stat().st_size == 0 - ): - return {"error": "Actor result video could not be generated."} return { "type": search_type, "query": query, - "video_path": str(actor_output_path), + "artifact_id": artifact.artifact_id, } result = service.search( @@ -230,7 +260,7 @@ def _run_search(search_type, query): "query": query, "timestamp": hit.start, "hit": hit.to_dict(), - "video_path": str(saved_video_path), + "media_id": hit.media_id, } except IndexNotReadyError as exc: return {"error": str(exc)} @@ -248,34 +278,44 @@ def _render_search_result(result): st.error(error) return - video_path = Path(result["video_path"]) - if not video_path.is_file(): + service = _configured_service() + try: + resource = ( + service.open_artifact_content(result["artifact_id"]) + if result["type"] == "actor" + else service.open_media_content(result["media_id"]) + ) + except ApplicationError: st.error("The search result video is no longer available.") return search_type = result["type"] if search_type == "actor": st.success(f"Actor cluster {result['query']}") - st.video(str(video_path), format="video/mp4", width="stretch") + st.video( + str(resource.path), + format=resource.mime_type, + width="stretch", + ) return timestamp = result["timestamp"] st.success(f"Best {search_type} match: {timestamp:.3f} seconds") st.video( - str(video_path), + str(resource.path), start_time=timestamp, width="stretch", ) -def _select_video(busy): - saved_video_path = _configured_service().layout.media / "source-video.mp4" +def _select_video(busy, media_id): + service = _configured_service() st.subheader("Video") upload_slot = st.empty() has_session_upload = st.session_state.get("video_upload") is not None - if busy and not has_session_upload and saved_video_path.is_file(): + if busy and not has_session_upload and media_id is not None: uploaded_video = None - st.caption("Indexing the saved video.") + st.caption("Indexing the registered video.") else: with upload_slot: uploaded_video = st.file_uploader( @@ -287,10 +327,15 @@ def _select_video(busy): if uploaded_video is not None: st.video(uploaded_video, width=560) - elif saved_video_path.is_file(): - if not busy: - st.caption("Using the saved video.") - st.video(str(saved_video_path), width=560) + elif media_id is not None: + try: + resource = service.open_media_content(media_id) + except ApplicationError: + st.warning("The registered video is no longer available.") + else: + if not busy: + st.caption("Using the registered video.") + st.video(str(resource.path), width=560) return uploaded_video @@ -331,7 +376,6 @@ def _search_controls(ready, uploaded_video, available_modalities): def run(): service = _configured_service() - saved_video_path = service.layout.media / "source-video.mp4" st.set_page_config(page_title="VidXP", page_icon="🎬", layout="wide") st.title("VidXP") st.caption("Index and search video by dialogue, scene, and actor.") @@ -343,6 +387,12 @@ def run(): requested = st.session_state.get(INDEX_REQUESTED_KEY, False) busy = active or requested status = service.index_status().model_dump(mode="json") + media_id = st.session_state.get(MEDIA_ID_KEY) + if media_id is None: + indexed_media = (status.get("summary") or {}).get("media_ids") or () + if len(indexed_media) == 1: + media_id = indexed_media[0] + st.session_state[MEDIA_ID_KEY] = media_id installed_modalities = _available_index_modalities() video_column, workflow_column = st.columns( [0.95, 1.05], @@ -351,7 +401,14 @@ def run(): ) with video_column: - uploaded_video = _select_video(busy) + uploaded_video = _select_video(busy, media_id) + selected_media_id = ( + media_id + if uploaded_video is None + or st.session_state.get(UPLOAD_TOKEN_KEY) + == _upload_token(uploaded_video) + else None + ) with workflow_column: st.subheader("Build index") @@ -374,7 +431,7 @@ def run(): type="primary", disabled=busy or not selected_modalities - or (uploaded_video is None and not saved_video_path.is_file()), + or (uploaded_video is None and media_id is None), help=( "Indexing is already running." if busy @@ -404,7 +461,7 @@ def poll_index_status(): _render_index_status( service.index_status().model_dump(mode="json"), latest_active, - uploaded_video, + selected_media_id, st.session_state.get(INDEX_ERROR_KEY), ) if not latest_active: @@ -415,14 +472,14 @@ def poll_index_status(): _render_index_status( status, False, - uploaded_video, + selected_media_id, st.session_state.get(INDEX_ERROR_KEY), ) - ready = not busy and _is_search_ready(status, uploaded_video) + ready = not busy and _is_search_ready(status, selected_media_id) configured_modalities = ( - (status.get("summary", {}).get("configuration") or {}).get( - "enabled_modalities", + (status.get("summary") or {}).get( + "modalities", ("scene", "dialogue", "actor"), ) if ready diff --git a/src/vidxp/index_worker.py b/src/vidxp/index_worker.py index ae4593f..8d412ff 100644 --- a/src/vidxp/index_worker.py +++ b/src/vidxp/index_worker.py @@ -16,8 +16,7 @@ def _run_indexing( - path: str, - source_name: str, + media_id: str, cancel_event, settings_payload: Mapping[str, Any], modalities: tuple[str, ...], @@ -26,9 +25,8 @@ def _run_indexing( VidXPSettings.model_validate(settings_payload) ).create_index( CreateIndexCommand( - path=path, + media_id=media_id, modalities=modalities, - source_name=source_name, ), cancellation=CancellationToken(cancel_event), ) @@ -41,8 +39,7 @@ def indexing_in_progress(service: VidXPApplication) -> bool: def start_indexing( - path: str, - source_name: str, + media_id: str, service: VidXPApplication, *, modalities: tuple[str, ...], @@ -58,8 +55,7 @@ def start_indexing( _process = context.Process( target=_run_indexing, args=( - path, - source_name, + media_id, _cancel_event, service.settings.model_dump(mode="python"), modalities, diff --git a/src/vidxp/infrastructure/local_artifacts.py b/src/vidxp/infrastructure/local_artifacts.py new file mode 100644 index 0000000..52361c3 --- /dev/null +++ b/src/vidxp/infrastructure/local_artifacts.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +from pydantic import TypeAdapter + +from vidxp.core.artifacts import ( + ArtifactRenderError, + ArtifactRendererUnavailableError, + StagedArtifact, + StoredArtifact, +) +from vidxp.core.identifiers import ArtifactId +from vidxp.core.video import render_actor_video +from vidxp.infrastructure.local_files import ( + prepare_managed_destination, +) +from vidxp.infrastructure.local_objects import LocalObjectStore + + +class LocalArtifactStore: + def __init__(self, root: Path) -> None: + self.root = root + self.objects = root / "objects" + self.staging = root / ".staging" + self._managed = LocalObjectStore(root) + + def stage(self, artifact_id: str, *, suffix: str) -> StagedArtifact: + validated_id = TypeAdapter(ArtifactId).validate_python(artifact_id) + if ( + not suffix.startswith(".") + or not suffix[1:].isalnum() + or len(suffix) > 10 + ): + raise ValueError("Artifact suffix is invalid.") + path = prepare_managed_destination( + self.root, + f".staging/{validated_id}.tmp{suffix.lower()}", + ) + path.unlink(missing_ok=True) + return StagedArtifact(artifact_id=artifact_id, path=path) + + def publish(self, staged: StagedArtifact) -> StoredArtifact: + suffix = staged.path.suffix.lower() + storage_key = ( + f"objects/{staged.artifact_id[:2]}/" + f"{staged.artifact_id}{suffix}" + ) + path, checksum, byte_size = self._managed.publish( + staged.path, + storage_key, + expected_sha256=None, + replace_corrupt=False, + ) + return StoredArtifact( + sha256=checksum, + byte_size=byte_size, + storage_key=storage_key, + local_path=path, + ) + + def discard(self, staged: StagedArtifact) -> None: + staged.path.unlink(missing_ok=True) + + def delete(self, storage_key: str) -> None: + self._managed.delete(storage_key) + + def verify( + self, + storage_key: str, + *, + sha256: str, + byte_size: int, + ) -> Path: + return self._managed.verify( + storage_key, + sha256=sha256, + byte_size=byte_size, + ) + + def resolve(self, storage_key: str) -> Path: + self.objects.mkdir(parents=True, exist_ok=True) + return self._managed.resolve(storage_key) + + +class LocalActorRenderer: + def render( + self, + source: Path, + destination: Path, + cluster_id: str, + detections: list[dict], + ) -> None: + render_actor_video(source, destination, cluster_id, detections) + + +class FFmpegSnippetRenderer: + def __init__( + self, + executable: str = "ffmpeg", + *, + timeout_seconds: float = 600, + ) -> None: + self.executable = executable + self.timeout_seconds = timeout_seconds + + def render( + self, + source: Path, + destination: Path, + *, + start_seconds: float, + end_seconds: float, + compatible_mp4: bool, + ) -> None: + duration = end_seconds - start_seconds + encoding = ( + ["-c:v", "libx264", "-c:a", "aac", "-movflags", "+faststart"] + if compatible_mp4 + else ["-c", "copy"] + ) + try: + subprocess.run( + [ + self.executable, + "-nostdin", + "-v", + "error", + "-protocol_whitelist", + "file,pipe", + "-ss", + str(start_seconds), + "-i", + str(source), + "-t", + str(duration), + *encoding, + str(destination), + ], + check=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + timeout=self.timeout_seconds, + ) + except FileNotFoundError as exc: + raise ArtifactRendererUnavailableError( + "The configured ffmpeg executable is unavailable." + ) from exc + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + raise ArtifactRenderError( + "The requested snippet could not be rendered." + ) from exc diff --git a/src/vidxp/infrastructure/local_catalog.py b/src/vidxp/infrastructure/local_catalog.py new file mode 100644 index 0000000..c93a35c --- /dev/null +++ b/src/vidxp/infrastructure/local_catalog.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import sqlite3 +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + +from vidxp.core.artifacts import ArtifactRecord, ArtifactState +from vidxp.core.media import MediaRecord, utc_now + + +CATALOG_SCHEMA_VERSION = 1 + + +class LocalCatalog: + """Repository-scoped SQLite catalog for media and generated artifacts.""" + + def __init__(self, database: Path) -> None: + self.database = database + self.database.parent.mkdir(parents=True, exist_ok=True) + self._initialize() + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self.database, timeout=30) + connection.execute("PRAGMA foreign_keys = ON") + connection.execute("PRAGMA busy_timeout = 30000") + return connection + + @contextmanager + def _session(self) -> Iterator[sqlite3.Connection]: + connection = self._connect() + try: + with connection: + yield connection + finally: + connection.close() + + def _initialize(self) -> None: + with self._session() as connection: + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS catalog_metadata ( + schema_version INTEGER NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS catalog_metadata_singleton + ON catalog_metadata((1)); + CREATE TABLE IF NOT EXISTS media ( + media_id TEXT PRIMARY KEY, + sha256 TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + payload TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS artifacts ( + artifact_id TEXT PRIMARY KEY, + media_id TEXT NOT NULL, + created_at TEXT NOT NULL, + payload TEXT NOT NULL, + FOREIGN KEY(media_id) REFERENCES media(media_id) + ); + CREATE INDEX IF NOT EXISTS artifacts_media_id + ON artifacts(media_id); + CREATE TABLE IF NOT EXISTS artifact_requests ( + request_key TEXT PRIMARY KEY, + artifact_id TEXT NOT NULL UNIQUE, + FOREIGN KEY(artifact_id) REFERENCES artifacts(artifact_id) + ); + """ + ) + row = connection.execute( + "SELECT schema_version FROM catalog_metadata" + ).fetchone() + if row is None: + connection.execute( + "INSERT OR IGNORE INTO catalog_metadata" + "(schema_version) VALUES (?)", + (CATALOG_SCHEMA_VERSION,), + ) + elif row[0] != CATALOG_SCHEMA_VERSION: + raise RuntimeError( + "The repository catalog schema is incompatible." + ) + + def get_media(self, media_id: str) -> MediaRecord | None: + with self._session() as connection: + row = connection.execute( + "SELECT payload FROM media WHERE media_id = ?", + (media_id,), + ).fetchone() + return None if row is None else MediaRecord.model_validate_json(row[0]) + + def get_media_by_checksum(self, sha256: str) -> MediaRecord | None: + with self._session() as connection: + row = connection.execute( + "SELECT payload FROM media WHERE sha256 = ?", + (sha256,), + ).fetchone() + return None if row is None else MediaRecord.model_validate_json(row[0]) + + def put_media(self, record: MediaRecord) -> MediaRecord: + with self._session() as connection: + connection.execute("BEGIN IMMEDIATE") + id_row = connection.execute( + "SELECT payload FROM media WHERE media_id = ?", + (record.media_id,), + ).fetchone() + if id_row is not None: + authoritative = MediaRecord.model_validate_json(id_row[0]) + if authoritative != record: + raise FileExistsError( + f"Media {record.media_id} already has another record." + ) + return authoritative + checksum_row = connection.execute( + "SELECT payload FROM media WHERE sha256 = ?", + (record.sha256,), + ).fetchone() + if checksum_row is not None: + return MediaRecord.model_validate_json(checksum_row[0]) + connection.execute( + """ + INSERT INTO media(media_id, sha256, created_at, payload) + VALUES (?, ?, ?, ?) + """, + ( + record.media_id, + record.sha256, + record.created_at.isoformat(), + record.model_dump_json(), + ), + ) + return record + + def list_media( + self, + *, + limit: int, + offset: int = 0, + ) -> tuple[MediaRecord, ...]: + if limit <= 0 or offset < 0: + raise ValueError("limit must be positive and offset nonnegative") + with self._session() as connection: + rows = connection.execute( + """ + SELECT payload FROM media + ORDER BY created_at, media_id + LIMIT ? OFFSET ? + """, + (limit, offset), + ).fetchall() + return tuple(MediaRecord.model_validate_json(row[0]) for row in rows) + + def count_media(self) -> int: + with self._session() as connection: + row = connection.execute("SELECT COUNT(*) FROM media").fetchone() + return int(row[0]) + + def get_artifact(self, artifact_id: str) -> ArtifactRecord | None: + with self._session() as connection: + row = connection.execute( + "SELECT payload FROM artifacts WHERE artifact_id = ?", + (artifact_id,), + ).fetchone() + return ( + None + if row is None + else ArtifactRecord.model_validate_json(row[0]) + ) + + def get_artifact_by_request( + self, + request_key: str, + ) -> ArtifactRecord | None: + with self._session() as connection: + row = connection.execute( + """ + SELECT artifacts.payload + FROM artifact_requests + JOIN artifacts USING (artifact_id) + WHERE artifact_requests.request_key = ? + """, + (request_key,), + ).fetchone() + if row is None: + return None + record = ArtifactRecord.model_validate_json(row[0]) + if ( + record.state != ArtifactState.ready + or ( + record.expires_at is not None + and record.expires_at <= utc_now() + ) + ): + return None + return record + + def put_artifact(self, record: ArtifactRecord) -> ArtifactRecord: + with self._session() as connection: + connection.execute("BEGIN IMMEDIATE") + request_row = connection.execute( + """ + SELECT artifacts.payload + FROM artifact_requests + JOIN artifacts USING (artifact_id) + WHERE artifact_requests.request_key = ? + """, + (record.request_key,), + ).fetchone() + if request_row is not None: + existing = ArtifactRecord.model_validate_json(request_row[0]) + if ( + existing.state == ArtifactState.ready + and ( + existing.expires_at is None + or existing.expires_at > utc_now() + ) + ): + return existing + connection.execute( + "DELETE FROM artifact_requests WHERE request_key = ?", + (record.request_key,), + ) + row = connection.execute( + "SELECT payload FROM artifacts WHERE artifact_id = ?", + (record.artifact_id,), + ).fetchone() + if row is not None: + existing = ArtifactRecord.model_validate_json(row[0]) + if existing != record: + raise FileExistsError( + f"Artifact {record.artifact_id} already exists." + ) + return existing + connection.execute( + """ + INSERT INTO artifacts( + artifact_id, media_id, created_at, payload + ) VALUES (?, ?, ?, ?) + """, + ( + record.artifact_id, + record.media_id, + record.created_at.isoformat(), + record.model_dump_json(), + ), + ) + connection.execute( + """ + INSERT INTO artifact_requests(request_key, artifact_id) + VALUES (?, ?) + """, + (record.request_key, record.artifact_id), + ) + return record + + def invalidate_artifact_request( + self, + request_key: str, + artifact_id: str, + ) -> None: + with self._session() as connection: + connection.execute( + """ + DELETE FROM artifact_requests + WHERE request_key = ? AND artifact_id = ? + """, + (request_key, artifact_id), + ) diff --git a/src/vidxp/infrastructure/local_files.py b/src/vidxp/infrastructure/local_files.py new file mode 100644 index 0000000..bef009d --- /dev/null +++ b/src/vidxp/infrastructure/local_files.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from pathlib import Path, PurePosixPath + +from vidxp.core.storage_keys import validate_storage_key + + +def resolve_managed_file(root: Path, storage_key: str) -> Path: + """Resolve an internal storage key without permitting link escapes.""" + + validate_storage_key(storage_key) + resolved_root = root.resolve(strict=True) + relative = PurePosixPath(storage_key) + candidate = root.joinpath(*relative.parts) + resolved = candidate.resolve(strict=True) + if not resolved.is_relative_to(resolved_root) or not resolved.is_file(): + raise FileNotFoundError("The managed file is unavailable.") + + current = root + for part in relative.parts: + current = current / part + is_junction = getattr(current, "is_junction", lambda: False) + if current.is_symlink() or is_junction(): + raise PermissionError("Managed storage links are not permitted.") + return resolved + + +def prepare_managed_destination(root: Path, storage_key: str) -> Path: + """Create a confined parent tree and reject pre-existing links.""" + + validate_storage_key(storage_key) + root.mkdir(parents=True, exist_ok=True) + resolved_root = root.resolve(strict=True) + relative = PurePosixPath(storage_key) + current = root + for part in relative.parts[:-1]: + current = current / part + if current.exists(): + is_junction = getattr(current, "is_junction", lambda: False) + if current.is_symlink() or is_junction() or not current.is_dir(): + raise PermissionError( + "Managed storage parent links are not permitted." + ) + else: + current.mkdir() + if not current.resolve(strict=True).is_relative_to(resolved_root): + raise PermissionError("Managed storage escaped its configured root.") + + destination = current / relative.name + if destination.exists(): + is_junction = getattr(destination, "is_junction", lambda: False) + if destination.is_symlink() or is_junction() or not destination.is_file(): + raise PermissionError("Managed storage links are not permitted.") + return destination + + +def safe_media_suffix(path: Path) -> str: + suffix = path.suffix.lower() + if ( + len(suffix) <= 10 + and suffix.startswith(".") + and suffix[1:].isalnum() + ): + return suffix + return ".bin" diff --git a/src/vidxp/infrastructure/local_index.py b/src/vidxp/infrastructure/local_index.py index 6106e44..8541e7f 100644 --- a/src/vidxp/infrastructure/local_index.py +++ b/src/vidxp/infrastructure/local_index.py @@ -16,10 +16,9 @@ CancellationToken, IndexConfig, IndexSchemaError, - VideoSource, ) from vidxp.core.indexing_common import ProgressCallback -from vidxp.core.manifest import MANIFEST_FILE, ManifestStore, source_checksum +from vidxp.core.manifest import MANIFEST_FILE, ManifestStore from vidxp.core.runner import index_video from vidxp.core.storage import IndexStorage, SnapshotScopedIndexStore from vidxp.infrastructure.local_snapshots import LocalSnapshotRepository @@ -103,15 +102,13 @@ def create( progress: ProgressCallback | None, cancellation: CancellationToken | None, source_name: str | None, + source_checksum: str, ) -> dict[str, Any]: self._require_index_directory(config.index_directory) repository = self.repository - source = VideoSource( - path=path, - source_name=source_name or path.name, - ) - input_sha256 = source_checksum(source) - media_id = config.video_id or input_sha256 + media_id = config.video_id + if media_id is None: + raise ValueError("A catalog media_id is required for indexing.") generation_id = repository.new_generation_id() generation_directory = repository.generation_directory(generation_id) build_config = replace( @@ -153,11 +150,11 @@ def create( runtime=self.runtime, ) with IndexStorage(build_config) as storage: - summary = index_video( + index_video( str(path), progress_callback=progress, source_name=source_name, - checksum=input_sha256, + checksum=source_checksum, config=build_config, cancellation=cancellation, storage=storage, @@ -197,16 +194,13 @@ def create( pass raise - result = dict(summary) - result.update( - { - "media_id": media_id, - "generation_id": generation_id, - "snapshot_id": snapshot.snapshot_id, - "active_media_count": len(snapshot.generations), - } - ) - return result + return { + "media_id": media_id, + "generation_id": generation_id, + "snapshot_id": snapshot.snapshot_id, + "active_media_count": len(snapshot.generations), + "record_counts": record_counts, + } @staticmethod def _validate_generation_records( diff --git a/src/vidxp/infrastructure/local_media.py b/src/vidxp/infrastructure/local_media.py new file mode 100644 index 0000000..e2669b9 --- /dev/null +++ b/src/vidxp/infrastructure/local_media.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import hashlib +import json +import math +import mimetypes +import os +import subprocess +import tempfile +from contextlib import AbstractContextManager +from pathlib import Path +from typing import Any +from filelock import FileLock + +from vidxp.core.media import ( + InvalidMediaError, + MediaProbeUnavailableError, + MediaProbe, + MediaImportLimitError, + MediaStream, + MediaStoreIntegrityError, + StagedMedia, + StoredMedia, +) +from vidxp.infrastructure.local_files import ( + prepare_managed_destination, + safe_media_suffix, +) +from vidxp.infrastructure.local_objects import LocalObjectStore + + +class LocalMediaStore: + def __init__(self, root: Path, *, max_bytes: int) -> None: + self.root = root + self.objects = root / "objects" + self.staging = root / ".staging" + self.max_bytes = max_bytes + self._managed = LocalObjectStore(root) + + def stage_local(self, path: Path) -> StagedMedia: + source = path.resolve(strict=True) + if not source.is_file(): + raise FileNotFoundError("The local media source is not a file.") + initial = source.stat() + if initial.st_size > self.max_bytes: + raise MediaImportLimitError( + "The media exceeds the configured import limit." + ) + + prepare_managed_destination( + self.root, + ".staging/.media-placeholder", + ) + temporary: Path | None = None + digest = hashlib.sha256() + byte_size = 0 + try: + with source.open("rb") as reader: + opened_before = os.fstat(reader.fileno()) + with tempfile.NamedTemporaryFile( + mode="w+b", + dir=self.staging, + prefix=".media.", + suffix=".tmp", + delete=False, + ) as writer: + temporary = Path(writer.name) + while chunk := reader.read(1024 * 1024): + byte_size += len(chunk) + if byte_size > self.max_bytes: + raise MediaImportLimitError( + "The media exceeds the configured import limit." + ) + digest.update(chunk) + writer.write(chunk) + writer.flush() + os.fsync(writer.fileno()) + opened_after = os.fstat(reader.fileno()) + + current = source.stat() + if ( + opened_before.st_size != opened_after.st_size + or opened_before.st_mtime_ns != opened_after.st_mtime_ns + or opened_after.st_size != current.st_size + or opened_after.st_mtime_ns != current.st_mtime_ns + or ( + opened_after.st_ino + and current.st_ino + and opened_after.st_ino != current.st_ino + ) + ): + raise MediaStoreIntegrityError( + "The media changed while it was being imported." + ) + + checksum = digest.hexdigest() + if byte_size == 0: + raise InvalidMediaError("The media file is empty.") + suffix = safe_media_suffix(source) + storage_key = ( + f"objects/{checksum[:2]}/{checksum[2:4]}/" + f"{checksum}{suffix}" + ) + staged = StagedMedia( + sha256=checksum, + byte_size=byte_size, + storage_key=storage_key, + path=temporary, + ) + temporary = None + return staged + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + def publication_lock(self, sha256: str) -> AbstractContextManager[None]: + lock_path = prepare_managed_destination( + self.root, + f".locks/{sha256}.lock", + ) + return FileLock(lock_path) + + def publish(self, staged: StagedMedia) -> StoredMedia: + try: + path, checksum, byte_size = self._managed.publish( + staged.path, + staged.storage_key, + expected_sha256=staged.sha256, + replace_corrupt=True, + ) + except RuntimeError as exc: + raise MediaStoreIntegrityError( + "The staged media failed its integrity check." + ) from exc + return StoredMedia( + sha256=checksum, + byte_size=byte_size, + storage_key=staged.storage_key, + local_path=path, + ) + + def discard(self, staged: StagedMedia) -> None: + staged.path.unlink(missing_ok=True) + + def delete(self, storage_key: str) -> None: + self._managed.delete(storage_key) + + def verify( + self, + storage_key: str, + *, + sha256: str, + byte_size: int, + ) -> Path: + try: + return self._managed.verify( + storage_key, + sha256=sha256, + byte_size=byte_size, + ) + except RuntimeError as exc: + raise MediaStoreIntegrityError( + "Managed media failed its integrity check." + ) from exc + + def resolve(self, storage_key: str) -> Path: + self.objects.mkdir(parents=True, exist_ok=True) + return self._managed.resolve(storage_key) + + +class FFprobeMediaProbe: + _MIME_TYPES = { + "avi": "video/x-msvideo", + "matroska": "video/x-matroska", + "mov": "video/quicktime", + "mp4": "video/mp4", + "mpeg": "video/mpeg", + "webm": "video/webm", + } + + def __init__( + self, + executable: str = "ffprobe", + *, + timeout_seconds: float = 30.0, + ) -> None: + self.executable = executable + self.timeout_seconds = timeout_seconds + + def probe(self, path: Path) -> MediaProbe: + output = self._output(path) + try: + payload = json.loads(output) + if not isinstance(payload, dict): + raise TypeError("ffprobe root must be an object") + stream_items = payload.get("streams", ()) + if not isinstance(stream_items, list): + raise TypeError("ffprobe streams must be a list") + streams = tuple( + self._stream(item) + for item in stream_items + if isinstance(item, dict) + ) + if len(streams) != len(stream_items): + raise TypeError("ffprobe stream entries must be objects") + format_data = payload["format"] + if not isinstance(format_data, dict): + raise TypeError("ffprobe format must be an object") + formats = tuple( + part.strip() + for part in str(format_data["format_name"]).split(",") + if part.strip() + ) + duration = float(format_data["duration"]) + except ( + AttributeError, + KeyError, + OverflowError, + TypeError, + UnicodeDecodeError, + ValueError, + json.JSONDecodeError, + ) as exc: + raise InvalidMediaError("The media probe result is invalid.") from exc + if ( + not formats + or not math.isfinite(duration) + or duration <= 0 + or len(streams) > 128 + or not any(stream.kind == "video" for stream in streams) + ): + raise InvalidMediaError("The file is not a valid supported video.") + container = self._preferred_container(formats, path) + return MediaProbe( + detected_mime_type=self._MIME_TYPES.get( + container, + mimetypes.guess_type(path.name)[0] + or "application/octet-stream", + ), + container=container, + duration_seconds=duration, + streams=streams, + ) + + def _output(self, path: Path) -> bytes: + try: + with ( + tempfile.TemporaryFile() as stdout, + tempfile.TemporaryFile() as stderr, + ): + subprocess.run( + [ + self.executable, + "-v", + "error", + "-protocol_whitelist", + "file,pipe", + "-show_entries", + ( + "format=format_name,duration:" + "stream=index,codec_type,codec_name,width,height," + "channels,sample_rate" + ), + "-of", + "json", + str(path), + ], + check=True, + stdout=stdout, + stderr=stderr, + timeout=self.timeout_seconds, + ) + stdout.seek(0, os.SEEK_END) + if stdout.tell() > 1024 * 1024: + raise InvalidMediaError( + "The media probe result is unexpectedly large." + ) + stdout.seek(0) + return stdout.read() + except OSError as exc: + raise MediaProbeUnavailableError( + "The configured ffprobe executable is unavailable." + ) from exc + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + raise InvalidMediaError( + "The media could not be validated by ffprobe." + ) from exc + + @staticmethod + def _stream(item: dict[str, Any]) -> MediaStream: + def optional_int(name: str) -> int | None: + value = item.get(name) + return None if value in (None, "", 0, "0") else int(value) + + return MediaStream( + index=int(item["index"]), + kind=str(item["codec_type"]), + codec=str(item["codec_name"]), + width=optional_int("width"), + height=optional_int("height"), + channels=optional_int("channels"), + sample_rate=optional_int("sample_rate"), + ) + + @staticmethod + def _preferred_container( + formats: tuple[str, ...], + path: Path, + ) -> str: + if "matroska" in formats and "webm" in formats: + return "webm" if path.suffix.lower() == ".webm" else "matroska" + for candidate in ("matroska", "webm", "mp4", "mov", "avi", "mpeg"): + if candidate in formats: + return candidate + return formats[0] diff --git a/src/vidxp/infrastructure/local_objects.py b/src/vidxp/infrastructure/local_objects.py new file mode 100644 index 0000000..09a5576 --- /dev/null +++ b/src/vidxp/infrastructure/local_objects.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from vidxp.core.manifest import sha256_file +from vidxp.infrastructure.local_files import ( + prepare_managed_destination, + resolve_managed_file, +) + + +class LocalObjectStore: + """Shared confined-file lifecycle for local media and artifacts.""" + + def __init__(self, root: Path) -> None: + self.root = root + + def publish( + self, + source: Path, + storage_key: str, + *, + expected_sha256: str | None, + replace_corrupt: bool, + ) -> tuple[Path, str, int]: + if not source.is_file() or source.stat().st_size <= 0: + raise FileNotFoundError("The staged object was not created.") + with source.open("r+b") as handle: + os.fsync(handle.fileno()) + checksum = sha256_file(source) + if expected_sha256 is not None and checksum != expected_sha256: + raise RuntimeError("The staged object checksum changed.") + + destination = prepare_managed_destination(self.root, storage_key) + created = not destination.exists() + published = False + try: + if destination.exists(): + if sha256_file(destination) == checksum: + source.unlink() + elif replace_corrupt: + source.replace(destination) + published = True + else: + raise FileExistsError( + "The managed object destination already exists." + ) + else: + source.replace(destination) + published = True + resolved = destination.resolve(strict=True) + byte_size = resolved.stat().st_size + return resolved, checksum, byte_size + except BaseException: + if created and published: + destination.unlink(missing_ok=True) + raise + + def verify( + self, + storage_key: str, + *, + sha256: str, + byte_size: int, + ) -> Path: + path = self.resolve(storage_key) + if path.stat().st_size != byte_size: + raise RuntimeError("Managed object has an unexpected byte size.") + if sha256_file(path) != sha256: + raise RuntimeError("Managed object has an unexpected checksum.") + return path + + def delete(self, storage_key: str) -> None: + self.resolve(storage_key).unlink() + + def resolve(self, storage_key: str) -> Path: + return resolve_managed_file(self.root, storage_key) diff --git a/src/vidxp/infrastructure/local_snapshots.py b/src/vidxp/infrastructure/local_snapshots.py index b0f28a0..c940b8b 100644 --- a/src/vidxp/infrastructure/local_snapshots.py +++ b/src/vidxp/infrastructure/local_snapshots.py @@ -439,7 +439,7 @@ def status(self) -> dict[str, Any] | None: resolved = self._read_active() if resolved is None: return None - pointer, snapshot = resolved + _, snapshot = resolved media_ids = sorted(snapshot.generations) ready = bool(media_ids) return { @@ -455,10 +455,10 @@ def status(self) -> dict[str, Any] | None: "summary": { "index_schema_version": INDEX_SCHEMA_VERSION, "snapshot_id": snapshot.snapshot_id, - "snapshot_sha256": pointer.snapshot_sha256, "media_count": len(media_ids), "media_ids": media_ids, - "config_fingerprint": snapshot.config_fingerprint, - "configuration": dict(snapshot.configuration), + "modalities": tuple( + snapshot.configuration.get("enabled_modalities", ()) + ), }, } diff --git a/src/vidxp/media_service.py b/src/vidxp/media_service.py new file mode 100644 index 0000000..adfb984 --- /dev/null +++ b/src/vidxp/media_service.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +from pathlib import Path +import base64 +import hashlib +import json +from uuid import uuid4 + +from vidxp.application_models import ( + ImportMediaCommand, + ListMediaCommand, + MediaAsset, + MediaPage, +) +from vidxp.core.media import ( + MediaRecord, + MediaState, + StagedMedia, + MediaUnavailableError, + utc_now, +) +from vidxp.ports import ( + LocalFileResource, + MediaCatalogPort, + MediaProbePort, + MediaStorePort, +) +from vidxp.settings import ApplicationMode, VidXPSettings + + +class MediaImportNotAllowedError(PermissionError): + """Raised when a local path is outside the local import policy.""" + + +def media_asset(record: MediaRecord) -> MediaAsset: + return MediaAsset( + schema_version=record.schema_version, + media_id=record.media_id, + video_id=record.video_id, + original_filename=record.original_filename, + sha256=record.sha256, + byte_size=record.byte_size, + declared_mime_type=record.declared_mime_type, + detected_mime_type=record.detected_mime_type, + container=record.container, + duration_seconds=record.duration_seconds, + streams=record.streams, + state=record.state, + created_at=record.created_at, + ) + + +class MediaService: + def __init__( + self, + *, + settings: VidXPSettings, + catalog: MediaCatalogPort, + store: MediaStorePort, + probe: MediaProbePort, + ) -> None: + self.settings = settings + self.catalog = catalog + self.store = store + self.probe = probe + + def import_local(self, command: ImportMediaCommand) -> MediaAsset: + if self.settings.mode != ApplicationMode.local: + raise MediaImportNotAllowedError( + "Local path imports are available only in local mode." + ) + source = self._resolve_import_source(command.path) + staged = self.store.stage_local(source) + try: + with self.store.publication_lock(staged.sha256): + return self._publish_import(command, source, staged) + finally: + self.store.discard(staged) + + def _publish_import( + self, + command: ImportMediaCommand, + source: Path, + staged: StagedMedia, + ) -> MediaAsset: + if existing := self.catalog.get_media_by_checksum(staged.sha256): + self.store.publish( + staged.model_copy( + update={"storage_key": existing.storage_key} + ) + ) + return media_asset(existing) + probe = self.probe.probe(staged.path) + stored = self.store.publish(staged) + media_id = uuid4().hex + record = MediaRecord( + media_id=media_id, + video_id=media_id, + sha256=stored.sha256, + original_filename=command.original_filename or source.name, + byte_size=stored.byte_size, + declared_mime_type=command.declared_mime_type, + detected_mime_type=probe.detected_mime_type, + container=probe.container, + duration_seconds=probe.duration_seconds, + streams=probe.streams, + storage_key=stored.storage_key, + state=MediaState.ready, + created_at=utc_now(), + ) + try: + authoritative = self.catalog.put_media(record) + except BaseException: + try: + retained = self.catalog.get_media_by_checksum(stored.sha256) + except Exception: + retained = None + if retained is None: + try: + self.store.delete(stored.storage_key) + except OSError: + pass + raise + if authoritative.storage_key != stored.storage_key: + try: + self.store.delete(stored.storage_key) + except OSError: + pass + return media_asset(authoritative) + + def get(self, media_id: str) -> MediaAsset: + return media_asset(self.require_record(media_id)) + + def list(self, command: ListMediaCommand) -> MediaPage: + scope = hashlib.sha256( + str(self.settings.repository_root.resolve()).encode() + ).hexdigest() + offset = self._decode_cursor(command.cursor, scope) + total = self.catalog.count_media() + if offset > total: + raise ValueError("The media cursor is outside the result set.") + items = tuple( + media_asset(record) + for record in self.catalog.list_media( + limit=command.page_size, + offset=offset, + ) + ) + next_offset = offset + len(items) + cursor = ( + self._encode_cursor(next_offset, scope) + if next_offset < total + else None + ) + return MediaPage( + items=items, + total=total, + next_cursor=cursor, + ) + + @staticmethod + def _encode_cursor(offset: int, scope: str) -> str: + payload = json.dumps( + {"version": 1, "scope": scope, "offset": offset}, + separators=(",", ":"), + sort_keys=True, + ).encode() + return base64.urlsafe_b64encode(payload).decode() + + @staticmethod + def _decode_cursor(cursor: str | None, scope: str) -> int: + if cursor is None: + return 0 + try: + payload = json.loads( + base64.urlsafe_b64decode(cursor.encode()).decode() + ) + if ( + not isinstance(payload, dict) + or payload.get("version") != 1 + or payload.get("scope") != scope + ): + raise ValueError + offset = int(payload["offset"]) + except ( + KeyError, + TypeError, + UnicodeDecodeError, + ValueError, + json.JSONDecodeError, + ) as exc: + raise ValueError("The media cursor is invalid.") from exc + if offset < 0: + raise ValueError("The media cursor is invalid.") + return offset + + def require_record(self, media_id: str) -> MediaRecord: + record = self.catalog.get_media(media_id) + if record is None or record.state != MediaState.ready: + raise MediaUnavailableError("The media asset is unavailable.") + return record + + def content(self, media_id: str) -> LocalFileResource: + record = self.require_record(media_id) + try: + path = self.store.verify( + record.storage_key, + sha256=record.sha256, + byte_size=record.byte_size, + ) + except (FileNotFoundError, PermissionError) as exc: + raise MediaUnavailableError( + "The managed media content is unavailable." + ) from exc + return LocalFileResource( + path=path, + filename=record.original_filename, + mime_type=record.detected_mime_type, + byte_size=record.byte_size, + etag=record.sha256, + ) + + def _resolve_import_source(self, path: Path) -> Path: + try: + source = path.expanduser().resolve(strict=True) + except (FileNotFoundError, OSError) as exc: + raise MediaUnavailableError( + "The local media source is unavailable." + ) from exc + if not source.is_file(): + raise MediaUnavailableError( + "The local media source is not a file." + ) + roots = self.settings.trusted_local_import_roots + if not roots: + return source + for configured in roots: + try: + root = configured.expanduser().resolve(strict=True) + except (FileNotFoundError, OSError): + continue + if root.is_dir() and source.is_relative_to(root): + return source + raise MediaImportNotAllowedError( + "The local media source is outside the trusted import roots." + ) diff --git a/src/vidxp/ports.py b/src/vidxp/ports.py index b568dca..89a668a 100644 --- a/src/vidxp/ports.py +++ b/src/vidxp/ports.py @@ -12,12 +12,23 @@ ) from vidxp.application_models import RuntimeProfile +from vidxp.core.artifacts import ( + ArtifactRecord, + StagedArtifact, + StoredArtifact, +) from vidxp.core.contracts import ( CancellationToken, IndexConfig, StorageRecord, ) from vidxp.core.indexing_common import ProgressCallback +from vidxp.core.media import ( + MediaProbe, + MediaRecord, + StagedMedia, + StoredMedia, +) from vidxp.model_contracts import ArtifactSpec, ModelKey, ModelSpec @@ -25,6 +36,136 @@ class ResourceLimitError(RuntimeError): """Raised when configured host capacity cannot admit model work.""" +class LocalFileResource: + """Authorized local delivery handle; never serialize this object.""" + + __slots__ = ("path", "filename", "mime_type", "byte_size", "etag") + + def __init__( + self, + *, + path: Path, + filename: str, + mime_type: str, + byte_size: int, + etag: str, + ) -> None: + self.path = path + self.filename = filename + self.mime_type = mime_type + self.byte_size = byte_size + self.etag = etag + + +@runtime_checkable +class MediaCatalogPort(Protocol): + def get_media(self, media_id: str) -> MediaRecord | None: ... + + def get_media_by_checksum(self, sha256: str) -> MediaRecord | None: ... + + def put_media(self, record: MediaRecord) -> MediaRecord: ... + + def list_media( + self, + *, + limit: int, + offset: int = 0, + ) -> tuple[MediaRecord, ...]: ... + + def count_media(self) -> int: ... + + +@runtime_checkable +class MediaStorePort(Protocol): + def stage_local(self, path: Path) -> StagedMedia: ... + + def publication_lock(self, sha256: str) -> ContextManager[None]: ... + + def publish(self, staged: StagedMedia) -> StoredMedia: ... + + def discard(self, staged: StagedMedia) -> None: ... + + def delete(self, storage_key: str) -> None: ... + + def verify( + self, + storage_key: str, + *, + sha256: str, + byte_size: int, + ) -> Path: ... + + def resolve(self, storage_key: str) -> Path: ... + + +@runtime_checkable +class MediaProbePort(Protocol): + def probe(self, path: Path) -> MediaProbe: ... + + +@runtime_checkable +class ArtifactCatalogPort(Protocol): + def get_artifact(self, artifact_id: str) -> ArtifactRecord | None: ... + + def get_artifact_by_request( + self, + request_key: str, + ) -> ArtifactRecord | None: ... + + def invalidate_artifact_request( + self, + request_key: str, + artifact_id: str, + ) -> None: ... + + def put_artifact(self, record: ArtifactRecord) -> ArtifactRecord: ... + + +@runtime_checkable +class ArtifactStorePort(Protocol): + def stage(self, artifact_id: str, *, suffix: str) -> StagedArtifact: ... + + def publish(self, staged: StagedArtifact) -> StoredArtifact: ... + + def discard(self, staged: StagedArtifact) -> None: ... + + def delete(self, storage_key: str) -> None: ... + + def verify( + self, + storage_key: str, + *, + sha256: str, + byte_size: int, + ) -> Path: ... + + def resolve(self, storage_key: str) -> Path: ... + + +@runtime_checkable +class ActorRendererPort(Protocol): + def render( + self, + source: Path, + destination: Path, + cluster_id: str, + detections: list[dict[str, Any]], + ) -> None: ... + + +@runtime_checkable +class SnippetRendererPort(Protocol): + def render( + self, + source: Path, + destination: Path, + *, + start_seconds: float, + end_seconds: float, + compatible_mp4: bool, + ) -> None: ... + + @runtime_checkable class ResourceSchedulerPort(Protocol): def indexing(self): ... @@ -80,6 +221,24 @@ def query( filters: Mapping[str, Any] | None = None, ) -> list[dict[str, Any]]: ... + def records( + self, + modality: str, + *, + video_id: str | None = None, + filters: Mapping[str, Any] | None = None, + limit: int | None = None, + offset: int = 0, + ) -> list[dict[str, Any]]: ... + + def count_records( + self, + modality: str, + *, + video_id: str | None = None, + filters: Mapping[str, Any] | None = None, + ) -> int: ... + @runtime_checkable class IndexStore(IndexReader, Protocol): @@ -112,15 +271,6 @@ def upsert( cancellation: CancellationToken, ) -> int: ... - def records( - self, - modality: str, - *, - video_id: str | None = None, - filters: Mapping[str, Any] | None = None, - ) -> list[dict[str, Any]]: ... - - class IndexBackend(Protocol): """Infrastructure operations needed by the application layer.""" @@ -141,6 +291,7 @@ def create( progress: ProgressCallback | None, cancellation: CancellationToken | None, source_name: str | None, + source_checksum: str, ) -> dict[str, Any]: ... def indexing_in_progress(self, config: IndexConfig) -> bool: ... diff --git a/src/vidxp/repository_layout.py b/src/vidxp/repository_layout.py index bb063e7..d4a67dd 100644 --- a/src/vidxp/repository_layout.py +++ b/src/vidxp/repository_layout.py @@ -18,6 +18,14 @@ def descriptor(self) -> Path: def media(self) -> Path: return self.root / "media" + @property + def catalog(self) -> Path: + return self.root / "catalog.sqlite3" + + @property + def media_objects(self) -> Path: + return self.media / "objects" + @property def indexes(self) -> Path: return self.root / "indexes" @@ -50,6 +58,10 @@ def index_lease(self) -> Path: def artifacts(self) -> Path: return self.root / "artifacts" + @property + def artifact_objects(self) -> Path: + return self.artifacts / "objects" + @property def local_workflows(self) -> Path: return self.root / "local-workflows" diff --git a/src/vidxp/settings.py b/src/vidxp/settings.py index d6c3cca..1a29cbf 100644 --- a/src/vidxp/settings.py +++ b/src/vidxp/settings.py @@ -42,6 +42,18 @@ class VidXPSettings(BaseSettings): le=256, ) minimum_available_memory_mb: int = Field(default=1024, ge=0) + max_local_import_bytes: int = Field( + default=50 * 1024 * 1024 * 1024, + gt=0, + ) + max_snippet_duration_seconds: float = Field( + default=300, + gt=0, + le=3600, + ) + trusted_local_import_roots: tuple[Path, ...] = () + ffprobe_executable: str = Field(default="ffprobe", min_length=1) + ffmpeg_executable: str = Field(default="ffmpeg", min_length=1) external_capabilities: bool = False capability_allowlist: tuple[str, ...] = () @@ -75,6 +87,16 @@ def _clean_allowlist(cls, values: tuple[str, ...]) -> tuple[str, ...]: ) return cleaned + @field_validator("trusted_local_import_roots") + @classmethod + def _clean_import_roots( + cls, + values: tuple[Path, ...], + ) -> tuple[Path, ...]: + return tuple( + dict.fromkeys(path.expanduser() for path in values) + ) + @model_validator(mode="after") def _require_explicit_server_backend(self) -> "VidXPSettings": if ( diff --git a/tests/test_actor_results.py b/tests/test_actor_results.py index 5d0430b..2b6ec60 100644 --- a/tests/test_actor_results.py +++ b/tests/test_actor_results.py @@ -1,20 +1,24 @@ import unittest -from tempfile import TemporaryDirectory -from pathlib import Path -from unittest.mock import Mock, patch +from unittest.mock import Mock from vidxp.capabilities.actor.results import ( ActorClusterNotFoundError, actor_clusters, actor_detections, - render_actor_result, ) +from vidxp.capabilities.actor.schemas import ActorDetectionsInput from vidxp.core.contracts import IndexConfig +from vidxp.capabilities.contracts import CapabilityRequestError + + +MEDIA_ID = "123456781234423481234567890abcde" +OTHER_MEDIA_ID = "223456781234423481234567890abcde" +GENERATION_ID = "323456781234423481234567890abcde" class ActorResultTests(unittest.TestCase): def setUp(self): - self.config = IndexConfig.local(video_id="video-1") + self.config = IndexConfig.local(video_id=MEDIA_ID) def test_actor_detection_metadata_is_converted_once(self): storage = Mock() @@ -31,107 +35,108 @@ def test_actor_detection_metadata_is_converted_once(self): "dataset": "local", "split": "local", "run_id": "default", - "video_id": "video-1", + "video_id": MEDIA_ID, + "generation_id": GENERATION_ID, "modality": "actor", "source_id": "actor:d2", } ] - detections = actor_detections( + page = actor_detections( self.config, "3", storage=storage, + page_size=50, + cursor=None, ) - self.assertEqual(detections[0].bbox, (1, 4, 5, 0)) + self.assertEqual(page.detections[0].bbox, (1, 4, 5, 0)) storage.records.assert_called_once_with( "actor", filters={"cluster_id": "3"}, + limit=1000, + offset=0, ) - storage.close.assert_not_called() def test_actor_clusters_summarize_detection_ranges(self): storage = Mock() storage.records.return_value = [ { - "cluster_id": "video-1:1", - "video_id": "video-1", + "cluster_id": "cluster-1", + "video_id": MEDIA_ID, + "generation_id": GENERATION_ID, "timestamp": 4.5, }, { - "cluster_id": "video-1:1", - "video_id": "video-1", + "cluster_id": "cluster-1", + "video_id": MEDIA_ID, + "generation_id": GENERATION_ID, "timestamp": 1.5, }, { - "cluster_id": "video-2:1", - "video_id": "video-2", + "cluster_id": "cluster-2", + "video_id": OTHER_MEDIA_ID, + "generation_id": GENERATION_ID, "timestamp": 9.0, }, ] - clusters = actor_clusters(self.config, storage=storage) + page = actor_clusters( + self.config, + storage=storage, + page_size=50, + cursor=None, + ) + clusters = page.clusters self.assertEqual( [cluster.cluster_id for cluster in clusters], - ["video-1:1", "video-2:1"], + ["cluster-1", "cluster-2"], ) self.assertEqual(clusters[0].detection_count, 2) self.assertEqual(clusters[0].first_timestamp, 1.5) self.assertEqual(clusters[0].last_timestamp, 4.5) - self.assertEqual(clusters[1].video_id, "video-2") - storage.records.assert_called_once_with("actor") + self.assertEqual(clusters[1].media_id, OTHER_MEDIA_ID) + storage.records.assert_called_once_with( + "actor", + limit=1000, + offset=0, + ) def test_actor_clusters_reject_cross_media_identity_collision(self): storage = Mock() storage.records.return_value = [ { "cluster_id": "legacy-cluster", - "video_id": "video-1", + "video_id": MEDIA_ID, + "generation_id": GENERATION_ID, "timestamp": 1.0, }, { "cluster_id": "legacy-cluster", - "video_id": "video-2", + "video_id": OTHER_MEDIA_ID, + "generation_id": GENERATION_ID, "timestamp": 2.0, }, ] with self.assertRaisesRegex(RuntimeError, "multiple index identities"): - actor_clusters(self.config, storage=storage) - - def test_render_actor_result_rejects_an_empty_cluster(self): - storage = Mock() - storage.records.return_value = [] - - with self.assertRaises(ActorClusterNotFoundError): - render_actor_result( - self.config, - "missing", - "input.mp4", - "output.mp4", - storage=storage, - ) - - def test_actor_detections_rejects_an_empty_cluster(self): - storage = Mock() - storage.records.return_value = [] - - with self.assertRaises(ActorClusterNotFoundError): - actor_detections( + actor_clusters( self.config, - "missing", storage=storage, + page_size=50, + cursor=None, ) - def test_render_actor_result_returns_output_details(self): + def test_actor_detection_cursor_round_trips_through_public_input(self): + cluster_id = "cluster-" + ("a" * 64) storage = Mock() storage.records.return_value = [ { - "detection_id": "d2", - "cluster_id": "3", - "frame_index": 2, - "timestamp": 0.2, + "detection_id": f"detection-{index}", + "cluster_id": cluster_id, + "frame_index": index, + "timestamp": index / 10, "bbox_top": 1, "bbox_right": 4, "bbox_bottom": 5, @@ -139,32 +144,97 @@ def test_render_actor_result_returns_output_details(self): "dataset": "local", "split": "local", "run_id": "default", - "video_id": "video-1", - "generation_id": "generation-1", + "video_id": MEDIA_ID, + "generation_id": GENERATION_ID, "modality": "actor", - "source_id": "actor:d2", + "source_id": f"actor:detection-{index}", } + for index in range(3) ] - with TemporaryDirectory() as directory: - output = Path(directory) / "actor.mp4" - with patch( - "vidxp.capabilities.actor.results.render_actor_video" - ) as renderer: - result = render_actor_result( - self.config, - "3", - "input.mp4", - output, - storage=storage, - ) - - renderer.assert_called_once() - self.assertEqual(result.output_path, output) - self.assertEqual(result.detection_count, 1) + + first = actor_detections( + self.config, + cluster_id, + storage=storage, + page_size=2, + cursor=None, + ) + + self.assertIsNotNone(first.next_cursor) + self.assertGreater(len(first.next_cursor or ""), 128) + request = ActorDetectionsInput( + cluster_id=cluster_id, + page_size=2, + cursor=first.next_cursor, + ) + second = actor_detections( + self.config, + request.cluster_id, + storage=storage, + page_size=request.page_size, + cursor=request.cursor, + ) + self.assertEqual( - renderer.call_args.args[3][0].generation_id, - "generation-1", + [detection.detection_id for detection in second.detections], + ["detection-2"], + ) + self.assertIsNone(second.next_cursor) + + def test_actor_cluster_pages_use_opaque_cursors(self): + storage = Mock() + storage.records.return_value = [ + { + "cluster_id": f"cluster-{index}", + "video_id": MEDIA_ID, + "generation_id": GENERATION_ID, + "timestamp": float(index), + } + for index in range(3) + ] + first = actor_clusters( + self.config, + storage=storage, + page_size=2, + cursor=None, ) + second = actor_clusters( + self.config, + storage=storage, + page_size=2, + cursor=first.next_cursor, + ) + + self.assertEqual(first.total, 3) + self.assertEqual(len(first.clusters), 2) + self.assertIsNotNone(first.next_cursor) + self.assertEqual( + [cluster.cluster_id for cluster in second.clusters], + ["cluster-2"], + ) + self.assertIsNone(second.next_cursor) + + with self.assertRaises(CapabilityRequestError): + actor_detections( + self.config, + "cluster-2", + storage=storage, + page_size=2, + cursor=first.next_cursor, + ) + + def test_actor_detections_rejects_an_empty_cluster(self): + storage = Mock() + storage.records.return_value = [] + + with self.assertRaises(ActorClusterNotFoundError): + actor_detections( + self.config, + "missing", + storage=storage, + page_size=50, + cursor=None, + ) if __name__ == "__main__": diff --git a/tests/test_application.py b/tests/test_application.py index e08f13f..e9960d7 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -19,6 +19,7 @@ RemoveIndexCommand, SearchCommand, ) +from vidxp.core.media import MediaUnavailableError from vidxp.core.contracts import IndexConfig, IndexSchemaError from vidxp.infrastructure.local_index import LocalIndexBackend from vidxp.capabilities.contracts import ( @@ -40,6 +41,11 @@ from vidxp.ports import IndexStore +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" +SNAPSHOT_ID = "323456781234423481234567890abcde" + + class ApplicationTests(unittest.TestCase): def application( self, @@ -55,6 +61,8 @@ def application( ) runtime = ModelRuntime(settings) active_backend = backend or Mock() + media_service = Mock() + artifact_service = Mock() return ( VidXPApplication( settings=settings, @@ -62,6 +70,8 @@ def application( registry=registry or create_capability_registry(), runtime=runtime, index_backend=active_backend, + media_service=media_service, + artifact_service=artifact_service, ), active_backend, ) @@ -145,22 +155,43 @@ def test_missing_index_has_shared_status_model(self): self.assertEqual(status.state, "missing") self.assertEqual(status.schema_version, 1) - self.assertEqual(status.index_directory, Path("missing/indexes")) def test_create_index_builds_one_central_config(self): with TemporaryDirectory() as directory: application, backend = self.application(directory) - backend.create.return_value = {"scene_frames": 1} + application.media_service.require_record.return_value = Mock( + original_filename="video.mp4", + sha256="1" * 64, + ) + application.media_service.content.return_value = Mock( + path=Path("managed.mp4") + ) + backend.create.return_value = { + "media_id": MEDIA_ID, + "generation_id": GENERATION_ID, + "snapshot_id": SNAPSHOT_ID, + "active_media_count": 1, + "record_counts": {"scene": 1}, + } result = application.create_index( CreateIndexCommand( - path=Path("video.mp4"), + media_id=MEDIA_ID, modalities=("scene",), frame_stride=5, ) ) - self.assertEqual(result, IndexResult(summary={"scene_frames": 1})) + self.assertEqual( + result, + IndexResult( + media_id=MEDIA_ID, + generation_id=GENERATION_ID, + snapshot_id=SNAPSHOT_ID, + active_media_count=1, + record_counts={"scene": 1}, + ), + ) config = backend.create.call_args.kwargs["config"] self.assertEqual(config.enabled_modalities, ("scene",)) self.assertEqual(config.frame_stride, 5) @@ -293,11 +324,14 @@ def test_validation_error_does_not_expose_input(self): def test_missing_media_error_does_not_expose_path(self): application, _ = self.application("unused") secret_path = Path("private/customer/video.mp4") + application.media_service.require_record.side_effect = ( + MediaUnavailableError("secret") + ) with self.assertRaises(ApplicationError) as raised: application.create_index( CreateIndexCommand( - path=secret_path, + media_id=MEDIA_ID, modalities=("scene",), ) ) @@ -313,12 +347,17 @@ def test_downstream_missing_file_is_not_misclassified_as_media(self): path = Path(directory) / "video.mp4" path.write_bytes(b"video") application, backend = self.application(directory) + application.media_service.require_record.return_value = Mock( + original_filename="video.mp4", + sha256="1" * 64, + ) + application.media_service.content.return_value = Mock(path=path) backend.create.side_effect = FileNotFoundError("ffmpeg") with self.assertRaises(DependencyUnavailableError) as raised: application.create_index( CreateIndexCommand( - path=path, + media_id=MEDIA_ID, modalities=("scene",), ) ) @@ -584,7 +623,7 @@ def test_remove_delegates_to_index_backend(self): self.assertTrue( application.remove_from_index( - RemoveIndexCommand(media_id="episode-1") + RemoveIndexCommand(media_id=MEDIA_ID) ) ) @@ -593,7 +632,7 @@ def test_remove_delegates_to_index_backend(self): Path(config.storage_directory), Path("repository") / "indexes", ) - self.assertEqual(media_id, "episode-1") + self.assertEqual(media_id, MEDIA_ID) def test_generation_storage_validation_checks_identity_and_count(self): config = IndexConfig.local( @@ -641,6 +680,7 @@ def test_local_backend_injects_storage_for_generation_build(self): storage = MagicMock() storage.__enter__.return_value = storage config = IndexConfig.local( + video_id=MEDIA_ID, enabled_modalities=("scene",), collection_names={"scene": "scene"}, storage_directory=settings.layout.indexes, @@ -658,10 +698,6 @@ def test_local_backend_injects_storage_for_generation_build(self): "vidxp.infrastructure.local_index.index_video", return_value={"scene_frames": 1}, ) as index_video, - patch( - "vidxp.infrastructure.local_index.source_checksum", - return_value="1" * 64, - ), patch( "vidxp.infrastructure.local_index.LocalSnapshotRepository." "generation_reference", @@ -688,10 +724,11 @@ def test_local_backend_injects_storage_for_generation_build(self): progress=None, cancellation=None, source_name=None, + source_checksum="1" * 64, ) - self.assertEqual(result["scene_frames"], 1) - self.assertEqual(result["media_id"], "1" * 64) + self.assertEqual(result["record_counts"], {"scene": 1}) + self.assertEqual(result["media_id"], MEDIA_ID) self.assertEqual(result["snapshot_id"], "a" * 32) self.assertIs(index_video.call_args.kwargs["storage"], storage) self.assertIs( diff --git a/tests/test_benchmarks.py b/tests/test_benchmarks.py index 055e3cb..640fa18 100644 --- a/tests/test_benchmarks.py +++ b/tests/test_benchmarks.py @@ -26,10 +26,16 @@ from vidxp.capabilities.schemas import SearchHit +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" + + def scene_hit(chunk, score): return SearchHit( rank=chunk + 1, - video_id="video", + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, start=chunk * 5.0, end=chunk * 5.0 + 1.0, score=score, @@ -43,7 +49,9 @@ def scene_hit(chunk, score): def timed_hit(start, end, score, rank=1): return SearchHit( rank=rank, - video_id="video", + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, start=start, end=end, score=score, diff --git a/tests/test_cli.py b/tests/test_cli.py index 36266d1..ba23fa3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ import json import os import unittest +from datetime import datetime, timezone from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import Mock, patch @@ -14,15 +15,22 @@ DependencyCheckResult, IndexResult, IndexStatus, + MediaAsset, PrepareModelsResult, RemoveIndexCommand, SearchCommand, ) +from vidxp.core.media import MediaState, MediaStream from vidxp.capabilities.registry import create_capability_registry from vidxp.capabilities.schemas import SearchResult from vidxp.repositories import RepositoryConfig, RepositoryRegistry +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" +SNAPSHOT_ID = "323456781234423481234567890abcde" + + class CliTests(unittest.TestCase): def setUp(self): self.runner = CliRunner() @@ -55,7 +63,15 @@ def invoke(self, arguments): def test_grouped_commands_are_exposed(self): result = self.invoke(["--help"]) self.assertEqual(result.exit_code, 0, result.output) - for command in ("index", "search", "actors", "doctor", "prepare"): + for command in ( + "media", + "index", + "search", + "actors", + "artifacts", + "doctor", + "prepare", + ): self.assertIn(command, result.output) def test_search_constructs_shared_command(self): @@ -80,61 +96,76 @@ def test_search_constructs_shared_command(self): self.assertEqual(json.loads(result.output)["query"], "yellow taxi") def test_index_constructs_shared_command(self): - with TemporaryDirectory() as directory: - video = Path(directory) / "video.mp4" - video.write_bytes(b"video") - self.service.create_index.return_value = IndexResult( - summary={"scene_frames": 1} - ) + self.service.create_index.return_value = IndexResult( + media_id=MEDIA_ID, + generation_id=GENERATION_ID, + snapshot_id=SNAPSHOT_ID, + active_media_count=1, + record_counts={"scene": 1}, + ) - result = self.invoke( - [ - "--format", - "json", - "index", - "create", - str(video), - "--modality", - "scene", - "--frame-stride", - "5", - ] - ) + result = self.invoke( + [ + "--format", + "json", + "index", + "create", + MEDIA_ID, + "--modality", + "scene", + "--frame-stride", + "5", + ] + ) self.assertEqual(result.exit_code, 0, result.output) command = self.service.create_index.call_args.args[0] self.assertIsInstance(command, CreateIndexCommand) self.assertEqual(command.modalities, ("scene",)) self.assertEqual(command.frame_stride, 5) - self.assertIsNone(command.media_id) + self.assertEqual(command.media_id, MEDIA_ID) - def test_media_id_and_remove_use_shared_commands(self): + def test_remove_uses_shared_media_id_command(self): + self.service.remove_from_index.return_value = True + removed = self.invoke(["index", "remove", MEDIA_ID, "--json"]) + self.assertEqual(removed.exit_code, 0, removed.output) + self.service.remove_from_index.assert_called_once_with( + RemoveIndexCommand(media_id=MEDIA_ID) + ) + + def test_media_import_uses_the_local_import_command(self): + self.service.import_media.return_value = MediaAsset( + schema_version=1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + original_filename="video.mp4", + sha256="1" * 64, + byte_size=5, + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=1, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + state=MediaState.ready, + created_at=datetime.now(timezone.utc), + ) with TemporaryDirectory() as directory: video = Path(directory) / "video.mp4" video.write_bytes(b"video") - self.service.create_index.return_value = IndexResult(summary={}) - created = self.invoke( - [ - "--format", - "json", - "index", - "create", - str(video), - "--media-id", - "episode-1", - ] - ) + result = self.invoke(["media", "import", str(video), "--json"]) - self.assertEqual(created.exit_code, 0, created.output) + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(json.loads(result.output)["media_id"], MEDIA_ID) self.assertEqual( - self.service.create_index.call_args.args[0].media_id, - "episode-1", - ) - self.service.remove_from_index.return_value = True - removed = self.invoke(["index", "remove", "episode-1", "--json"]) - self.assertEqual(removed.exit_code, 0, removed.output) - self.service.remove_from_index.assert_called_once_with( - RemoveIndexCommand(media_id="episode-1") + self.service.import_media.call_args.args[0].path, + video.resolve(), ) def test_status_serializes_shared_model(self): @@ -143,8 +174,6 @@ def test_status_serializes_shared_model(self): state="missing", stage="status", message="No index.", - repository_root=Path("repo"), - index_directory=Path("repo/indexes"), ) result = self.invoke(["index", "status", "--json"]) @@ -161,7 +190,11 @@ def test_doctor_and_prepare_use_shared_models(self): self.service.prepare_models.return_value = PrepareModelsResult( prepared=("scene-model",), modalities=("scene",), - runtime={"torch_device": "cpu"}, + runtime={ + "requested": "cpu", + "torch_device": "cpu", + "transcription_device": "cpu", + }, ) checked = self.invoke( diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 92e61af..fb22c7d 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -12,6 +12,10 @@ from vidxp.capabilities.schemas import SearchHit, SearchResult +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" + + class ContractTests(unittest.TestCase): def test_stable_source_ids_escape_delimiters_without_collisions(self): first = stable_source_id("run:a", "video", "scene", "1") @@ -28,7 +32,7 @@ def test_stable_source_ids_escape_delimiters_without_collisions(self): "video", "scene", "1", - generation_id="generation-1", + generation_id=GENERATION_ID, ) self.assertNotEqual(first, scoped) self.assertEqual(scoped.count(":"), 4) @@ -136,7 +140,9 @@ def test_invalid_config_is_rejected(self): def test_result_contract_serializes_complete_ranked_hits(self): hit = SearchHit( rank=1, - video_id="video-1", + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, start=1.0, end=2.0, score=-0.2, @@ -152,7 +158,7 @@ def test_result_contract_serializes_complete_ranked_hits(self): hits=(hit,), ) - self.assertEqual(result.to_prediction()["q1"][0]["video_id"], "video-1") + self.assertEqual(result.to_prediction()["q1"][0]["video_id"], MEDIA_ID) self.assertEqual(result.to_dict()["hits"][0]["raw_distance"], 0.2) def test_cancellation_is_cooperative(self): diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 00726b6..c6a48c2 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -10,6 +10,10 @@ from vidxp.settings import VidXPSettings +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" + + class UploadedVideo: name = "video.mp4" @@ -63,7 +67,7 @@ def test_service_is_composed_lazily_and_cached(self): create.assert_called_once() self.assertEqual(settings, expected) - def test_uploaded_video_identity_controls_search_readiness(self): + def test_media_identity_controls_search_readiness(self): with TemporaryDirectory() as directory: service = self.service(Path(directory)) with patch.object( @@ -71,17 +75,16 @@ def test_uploaded_video_identity_controls_search_readiness(self): "_configured_service", return_value=service, ): - uploaded = UploadedVideo(b"video") matching = { "state": "ready", - "video": {"sha256": frontend._video_hash(uploaded)}, + "summary": {"media_ids": [MEDIA_ID]}, } stale = { "state": "ready", - "video": {"sha256": "different"}, + "summary": {"media_ids": []}, } - self.assertTrue(frontend._is_search_ready(matching, uploaded)) - self.assertFalse(frontend._is_search_ready(stale, uploaded)) + self.assertTrue(frontend._is_search_ready(matching, MEDIA_ID)) + self.assertFalse(frontend._is_search_ready(stale, MEDIA_ID)) def test_available_modalities_use_application_dependency_commands(self): with TemporaryDirectory() as directory: @@ -113,8 +116,6 @@ def test_search_uses_shared_command_and_returns_timestamp(self): state="ready", stage="complete", message="ready", - repository_root=root, - index_directory=root / "indexes", ) service.search.return_value = SearchResult( query_id="scene:1", @@ -123,7 +124,9 @@ def test_search_uses_shared_command_and_returns_timestamp(self): hits=( SearchHit( rank=1, - video_id="video-1", + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, start=12.5, end=13.0, score=-0.1, diff --git a/tests/test_index_worker.py b/tests/test_index_worker.py index 9dd3f8e..56d7ee2 100644 --- a/tests/test_index_worker.py +++ b/tests/test_index_worker.py @@ -7,6 +7,9 @@ from vidxp.settings import VidXPSettings +MEDIA_ID = "123456781234423481234567890abcde" + + class FakeProcess: def __init__(self, **options): self.options = options @@ -72,8 +75,7 @@ def test_worker_starts_indexing_in_a_separate_process(self): with patch.object(index_worker, "get_context", return_value=context): index_worker.start_indexing( - "video.mp4", - "source.mp4", + MEDIA_ID, self.service, modalities=("scene",), ) @@ -84,8 +86,7 @@ def test_worker_starts_indexing_in_a_separate_process(self): self.assertEqual( context.process.options["args"], ( - "video.mp4", - "source.mp4", + MEDIA_ID, context.event, self.service.settings.model_dump(mode="python"), ("scene",), @@ -101,8 +102,7 @@ def test_worker_process_reconstructs_the_selected_service(self): return_value=service, ) as create: index_worker._run_indexing( - "video.mp4", - "source.mp4", + MEDIA_ID, FakeEvent(), self.service.settings.model_dump(mode="python"), ("scene",), @@ -112,8 +112,8 @@ def test_worker_process_reconstructs_the_selected_service(self): self.assertEqual(settings, self.service.settings) service.create_index.assert_called_once() self.assertEqual( - service.create_index.call_args.args[0].source_name, - "source.mp4", + service.create_index.call_args.args[0].media_id, + MEDIA_ID, ) self.assertEqual( service.create_index.call_args.args[0].modalities, @@ -125,15 +125,13 @@ def test_worker_rejects_a_second_indexing_run(self): with patch.object(index_worker, "get_context", return_value=context): index_worker.start_indexing( - "video.mp4", - "source.mp4", + MEDIA_ID, self.service, modalities=("scene",), ) with self.assertRaises(IndexingInProgressError): index_worker.start_indexing( - "video.mp4", - "source.mp4", + MEDIA_ID, self.service, modalities=("scene",), ) @@ -147,8 +145,7 @@ def test_cancellation_requests_are_cooperative(self): context = FakeContext() with patch.object(index_worker, "get_context", return_value=context): index_worker.start_indexing( - "video.mp4", - "source.mp4", + MEDIA_ID, self.service, modalities=("scene",), ) diff --git a/tests/test_local_snapshots.py b/tests/test_local_snapshots.py index b4af06d..9d8e16b 100644 --- a/tests/test_local_snapshots.py +++ b/tests/test_local_snapshots.py @@ -276,6 +276,7 @@ def test_build_failure_or_cancellation_preserves_active_snapshot(self): self.repository.layout, ) build = IndexConfig.local( + video_id="b" * 64, enabled_modalities=("scene",), collection_names={"scene": "scene"}, storage_directory=self.repository.indexes, @@ -302,10 +303,6 @@ def fail_index(*_args, **kwargs): "vidxp.infrastructure.local_index.IndexStorage", return_value=storage, ), - patch( - "vidxp.infrastructure.local_index.source_checksum", - return_value="b" * 64, - ), patch( "vidxp.infrastructure.local_index.index_video", side_effect=fail_index, @@ -318,6 +315,7 @@ def fail_index(*_args, **kwargs): progress=None, cancellation=None, source_name=None, + source_checksum="b" * 64, ) self.assertEqual( @@ -450,6 +448,7 @@ def test_cancellation_after_validation_does_not_publish(self): self.repository.layout, ) build = IndexConfig.local( + video_id="b" * 64, enabled_modalities=("scene",), collection_names={"scene": "scene"}, storage_directory=self.repository.indexes, @@ -478,10 +477,6 @@ def complete_index(*_args, **kwargs): "vidxp.infrastructure.local_index.IndexStorage", return_value=storage, ), - patch( - "vidxp.infrastructure.local_index.source_checksum", - return_value="b" * 64, - ), patch( "vidxp.infrastructure.local_index.index_video", side_effect=complete_index, @@ -494,6 +489,7 @@ def complete_index(*_args, **kwargs): progress=None, cancellation=cancellation, source_name=None, + source_checksum="b" * 64, ) self.assertEqual( diff --git a/tests/test_media_catalog.py b/tests/test_media_catalog.py new file mode 100644 index 0000000..3d2cf69 --- /dev/null +++ b/tests/test_media_catalog.py @@ -0,0 +1,349 @@ +import json +import os +import subprocess +import unittest +from datetime import datetime, timezone +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from vidxp.core.artifacts import ArtifactKind, ArtifactRecord +from vidxp.core.media import ( + MediaImportLimitError, + MediaRecord, + MediaState, + MediaStream, +) +from vidxp.infrastructure.local_catalog import LocalCatalog +from vidxp.infrastructure.local_artifacts import LocalArtifactStore +from vidxp.infrastructure.local_media import ( + FFprobeMediaProbe, + InvalidMediaError, + LocalMediaStore, +) +from vidxp.infrastructure.local_files import prepare_managed_destination + + +MEDIA_ID = "123456781234423481234567890abcde" +OTHER_MEDIA_ID = "223456781234423481234567890abcde" +ARTIFACT_ID = "323456781234423481234567890abcde" + + +def media_record( + media_id: str = MEDIA_ID, + *, + checksum: str = "1" * 64, +) -> MediaRecord: + return MediaRecord( + media_id=media_id, + video_id=media_id, + sha256=checksum, + original_filename="video.mp4", + byte_size=5, + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=2, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + storage_key=f"objects/{checksum[:2]}/{checksum}.mp4", + state=MediaState.ready, + created_at=datetime.now(timezone.utc), + ) + + +class LocalCatalogTests(unittest.TestCase): + def test_catalog_persists_and_deduplicates_media_by_checksum(self): + with TemporaryDirectory() as directory: + database = Path(directory) / "catalog.sqlite3" + first = LocalCatalog(database) + record = media_record() + self.assertEqual(first.put_media(record), record) + + reopened = LocalCatalog(database) + self.assertEqual(reopened.get_media(MEDIA_ID), record) + duplicate = media_record(OTHER_MEDIA_ID) + self.assertEqual(reopened.put_media(duplicate), record) + self.assertEqual(reopened.list_media(limit=10), (record,)) + + def test_artifact_requires_cataloged_media_and_survives_reopen(self): + with TemporaryDirectory() as directory: + database = Path(directory) / "catalog.sqlite3" + catalog = LocalCatalog(database) + catalog.put_media(media_record()) + artifact = ArtifactRecord( + artifact_id=ARTIFACT_ID, + media_id=MEDIA_ID, + request_key="3" * 64, + kind=ArtifactKind.actor_overlay, + profile="default", + mime_type="video/mp4", + byte_size=10, + sha256="2" * 64, + storage_key=f"objects/32/{ARTIFACT_ID}.mp4", + created_at=datetime.now(timezone.utc), + ) + + catalog.put_artifact(artifact) + + self.assertEqual( + LocalCatalog(database).get_artifact(ARTIFACT_ID), + artifact, + ) + self.assertEqual( + LocalCatalog(database).get_artifact_by_request("3" * 64), + artifact, + ) + + +class LocalMediaStoreTests(unittest.TestCase): + def test_import_is_staged_hashed_once_and_atomically_published(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.mp4" + source.write_bytes(b"video") + store = LocalMediaStore(root / "media", max_bytes=100) + + staged = store.stage_local(source) + self.assertEqual(staged.byte_size, 5) + self.assertEqual( + staged.sha256, + "0cab1c9617404faf2b24e221e189ca5945813e14" + "d3f766345b09ca13bbe28ffc", + ) + self.assertTrue(staged.path.is_file()) + + with store.publication_lock(staged.sha256): + stored = store.publish(staged) + + self.assertFalse(staged.path.exists()) + self.assertEqual(store.resolve(stored.storage_key), stored.local_path) + self.assertEqual(stored.local_path.read_bytes(), b"video") + + def test_size_limit_and_unsafe_storage_keys_fail_closed(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.mp4" + source.write_bytes(b"too-large") + store = LocalMediaStore(root / "media", max_bytes=3) + + with self.assertRaisesRegex(MediaImportLimitError, "import limit"): + store.stage_local(source) + with self.assertRaises(ValueError): + store.resolve("../outside.mp4") + + def test_managed_storage_rejects_symlink_escapes(self): + with TemporaryDirectory() as directory: + root = Path(directory) + media_root = root / "media" + (media_root / "objects").mkdir(parents=True) + outside = root / "outside" + outside.mkdir() + (outside / "secret.mp4").write_bytes(b"secret") + link = media_root / "objects" / "escape" + try: + link.symlink_to(outside, target_is_directory=True) + except OSError as exc: + self.skipTest(f"Symlink creation is unavailable: {exc}") + + store = LocalMediaStore(media_root, max_bytes=100) + with self.assertRaises((FileNotFoundError, PermissionError)): + store.resolve("objects/escape/secret.mp4") + + def test_destination_rejects_junction_parent(self): + with TemporaryDirectory() as directory: + root = Path(directory) + junction = root / "objects" / "junction" + junction.mkdir(parents=True) + original = Path.is_junction + + with patch.object( + Path, + "is_junction", + lambda item: item == junction or original(item), + ): + with self.assertRaises(PermissionError): + prepare_managed_destination( + root, + "objects/junction/video.mp4", + ) + + def test_publication_rejects_symlinked_parent(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.mp4" + source.write_bytes(b"video") + store = LocalMediaStore(root / "media", max_bytes=100) + staged = store.stage_local(source) + first_parent = store.root / "objects" / staged.sha256[:2] + outside = root / "outside" + outside.mkdir() + first_parent.parent.mkdir(parents=True) + try: + first_parent.symlink_to(outside, target_is_directory=True) + except OSError as exc: + store.discard(staged) + self.skipTest(f"Symlink creation is unavailable: {exc}") + + with self.assertRaises(PermissionError): + store.publish(staged) + self.assertEqual(tuple(outside.iterdir()), ()) + store.discard(staged) + + def test_reimport_repairs_same_size_corruption(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.mp4" + source.write_bytes(b"video") + store = LocalMediaStore(root / "media", max_bytes=100) + first = store.publish(store.stage_local(source)) + first.local_path.write_bytes(b"bideo") + + repaired = store.publish(store.stage_local(source)) + + self.assertEqual(repaired.local_path.read_bytes(), b"video") + self.assertEqual( + store.verify( + repaired.storage_key, + sha256=repaired.sha256, + byte_size=repaired.byte_size, + ), + repaired.local_path, + ) + + @unittest.skipUnless(os.name == "nt", "Windows junction behavior") + def test_publication_rejects_windows_junction_parent(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.mp4" + source.write_bytes(b"video") + store = LocalMediaStore(root / "media", max_bytes=100) + staged = store.stage_local(source) + parent = store.objects / staged.sha256[:2] + parent.parent.mkdir(parents=True) + outside = root / "outside" + outside.mkdir() + result = subprocess.run( + [ + "cmd", + "/c", + "mklink", + "/J", + str(parent), + str(outside), + ], + check=False, + capture_output=True, + text=True, + ) + if result.returncode: + store.discard(staged) + self.skipTest("Junction creation is unavailable.") + + with self.assertRaises(PermissionError): + store.publish(staged) + self.assertEqual(tuple(outside.iterdir()), ()) + store.discard(staged) + + +class FFprobeTests(unittest.TestCase): + def test_probe_normalizes_stream_and_container_metadata(self): + payload = { + "format": {"format_name": "mov,mp4", "duration": "2.5"}, + "streams": [ + { + "index": 0, + "codec_type": "video", + "codec_name": "h264", + "width": 1920, + "height": 1080, + }, + { + "index": 1, + "codec_type": "audio", + "codec_name": "aac", + "channels": 2, + "sample_rate": "48000", + }, + ], + } + with patch.object( + FFprobeMediaProbe, + "_output", + return_value=json.dumps(payload).encode(), + ): + result = FFprobeMediaProbe().probe(Path("video.mp4")) + + self.assertEqual(result.container, "mp4") + self.assertEqual(result.detected_mime_type, "video/mp4") + self.assertEqual(result.streams[0].codec, "h264") + self.assertEqual(result.streams[1].sample_rate, 48000) + + def test_probe_rejects_malformed_or_non_video_results(self): + with patch.object( + FFprobeMediaProbe, + "_output", + return_value=b'{"format":{"format_name":"mp4","duration":"nan"}}', + ): + with self.assertRaises(InvalidMediaError): + FFprobeMediaProbe().probe(Path("bad.mp4")) + + def test_probe_distinguishes_matroska_from_webm_by_extension(self): + payload = { + "format": { + "format_name": "matroska,webm", + "duration": "2.5", + }, + "streams": [ + { + "index": 0, + "codec_type": "video", + "codec_name": "h264", + "width": 1920, + "height": 1080, + } + ], + } + with patch.object( + FFprobeMediaProbe, + "_output", + return_value=json.dumps(payload).encode(), + ): + matroska = FFprobeMediaProbe().probe(Path("video.mkv")) + webm = FFprobeMediaProbe().probe(Path("video.webm")) + + self.assertEqual(matroska.detected_mime_type, "video/x-matroska") + self.assertEqual(webm.detected_mime_type, "video/webm") + + +class LocalArtifactStoreTests(unittest.TestCase): + def test_publication_rejects_symlinked_parent(self): + with TemporaryDirectory() as directory: + root = Path(directory) + store = LocalArtifactStore(root / "artifacts") + staged = store.stage(ARTIFACT_ID, suffix=".mp4") + staged.path.write_bytes(b"video") + outside = root / "outside" + outside.mkdir() + parent = store.objects / ARTIFACT_ID[:2] + parent.parent.mkdir(parents=True) + try: + parent.symlink_to(outside, target_is_directory=True) + except OSError as exc: + store.discard(staged) + self.skipTest(f"Symlink creation is unavailable: {exc}") + + with self.assertRaises(PermissionError): + store.publish(staged) + self.assertEqual(tuple(outside.iterdir()), ()) + store.discard(staged) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_media_services.py b/tests/test_media_services.py new file mode 100644 index 0000000..95f1671 --- /dev/null +++ b/tests/test_media_services.py @@ -0,0 +1,497 @@ +import unittest +from contextlib import nullcontext +from datetime import datetime, timezone +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import Mock, patch + +from vidxp.application_models import ( + ActorOverlayProfile, + CreateSnippetCommand, + ImportMediaCommand, + ListMediaCommand, + SnippetProfile, +) +from vidxp.artifact_service import ( + ArtifactService, + ArtifactUnavailableError, + InvalidArtifactError, +) +from vidxp.core.artifacts import ArtifactState +from vidxp.core.media import ( + MediaProbe, + MediaRecord, + MediaState, + MediaStream, + StagedMedia, + StoredMedia, +) +from vidxp.infrastructure.local_artifacts import LocalArtifactStore +from vidxp.infrastructure.local_media import InvalidMediaError +from vidxp.media_service import MediaService +from vidxp.ports import LocalFileResource +from vidxp.settings import VidXPSettings + + +MEDIA_ID = "123456781234423481234567890abcde" +ARTIFACT_ID = "323456781234423481234567890abcde" + + +def record() -> MediaRecord: + return MediaRecord( + media_id=MEDIA_ID, + video_id=MEDIA_ID, + sha256="1" * 64, + original_filename="video.mp4", + byte_size=5, + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=2, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + storage_key="objects/11/video.mp4", + state=MediaState.ready, + created_at=datetime.now(timezone.utc), + ) + + +class MediaServiceTests(unittest.TestCase): + def service(self, root: Path): + catalog = Mock() + store = Mock() + store.publication_lock.return_value = nullcontext() + probe = Mock() + service = MediaService( + settings=VidXPSettings( + repository_root=root, + runtime_backend="cpu", + ), + catalog=catalog, + store=store, + probe=probe, + ) + return service, catalog, store, probe + + def test_import_probes_staging_before_publishing_and_cataloging(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "video.mp4" + source.write_bytes(b"video") + service, catalog, store, probe = self.service(root) + staged = StagedMedia( + sha256="1" * 64, + byte_size=5, + storage_key="objects/11/video.mp4", + path=root / "staged.tmp", + ) + stored = StoredMedia( + sha256=staged.sha256, + byte_size=5, + storage_key=staged.storage_key, + local_path=root / "managed.mp4", + ) + store.stage_local.return_value = staged + store.publish.return_value = stored + probe.probe.return_value = MediaProbe( + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=2, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + ) + catalog.get_media_by_checksum.return_value = None + catalog.put_media.side_effect = lambda item: item + with patch("vidxp.media_service.uuid4") as identifier: + identifier.return_value.hex = MEDIA_ID + result = service.import_local(ImportMediaCommand(path=source)) + + self.assertEqual(result.media_id, MEDIA_ID) + store.publication_lock.assert_called_once_with(staged.sha256) + probe.probe.assert_called_once_with(staged.path) + store.publish.assert_called_once_with(staged) + catalog.put_media.assert_called_once() + store.discard.assert_called_once_with(staged) + + def test_invalid_probe_never_publishes_or_catalogs_media(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "video.mp4" + source.write_bytes(b"video") + service, catalog, store, probe = self.service(root) + staged = StagedMedia( + sha256="1" * 64, + byte_size=5, + storage_key="objects/11/video.mp4", + path=root / "staged.tmp", + ) + store.stage_local.return_value = staged + catalog.get_media_by_checksum.return_value = None + probe.probe.side_effect = InvalidMediaError("invalid") + + with self.assertRaises(InvalidMediaError): + service.import_local(ImportMediaCommand(path=source)) + + store.publish.assert_not_called() + catalog.put_media.assert_not_called() + store.discard.assert_called_once_with(staged) + + def test_existing_checksum_is_reused_without_reprobe(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "video.mp4" + source.write_bytes(b"video") + service, catalog, store, probe = self.service(root) + staged = StagedMedia( + sha256="1" * 64, + byte_size=5, + storage_key="objects/11/video.mp4", + path=root / "staged.tmp", + ) + store.stage_local.return_value = staged + catalog.get_media_by_checksum.return_value = record() + + result = service.import_local(ImportMediaCommand(path=source)) + + self.assertEqual(result.media_id, MEDIA_ID) + probe.probe.assert_not_called() + store.publish.assert_called_once_with(staged) + store.discard.assert_called_once_with(staged) + + def test_catalog_failure_rolls_back_new_managed_content(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "video.mp4" + source.write_bytes(b"video") + service, catalog, store, probe = self.service(root) + staged = StagedMedia( + sha256="1" * 64, + byte_size=5, + storage_key="objects/11/video.mp4", + path=root / "staged.tmp", + ) + stored = StoredMedia( + sha256=staged.sha256, + byte_size=5, + storage_key=staged.storage_key, + local_path=root / "managed.mp4", + ) + store.stage_local.return_value = staged + store.publish.return_value = stored + probe.probe.return_value = MediaProbe( + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=2, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + ) + catalog.get_media_by_checksum.return_value = None + catalog.put_media.side_effect = RuntimeError("catalog failed") + + with self.assertRaisesRegex(RuntimeError, "catalog failed"): + service.import_local(ImportMediaCommand(path=source)) + + store.delete.assert_called_once_with(stored.storage_key) + + def test_media_pages_are_bounded_and_cursor_scoped(self): + with TemporaryDirectory() as directory: + service, catalog, _store, _probe = self.service(Path(directory)) + catalog.count_media.return_value = 3 + catalog.list_media.side_effect = [ + (record(), record().model_copy( + update={ + "media_id": "223456781234423481234567890abcde", + "video_id": "223456781234423481234567890abcde", + "sha256": "2" * 64, + } + )), + (record().model_copy( + update={ + "media_id": "323456781234423481234567890abcde", + "video_id": "323456781234423481234567890abcde", + "sha256": "3" * 64, + } + ),), + ] + + first = service.list(ListMediaCommand(page_size=2)) + second = service.list( + ListMediaCommand(page_size=2, cursor=first.next_cursor) + ) + + self.assertEqual(first.total, 3) + self.assertEqual(len(first.items), 2) + self.assertIsNotNone(first.next_cursor) + self.assertEqual(len(second.items), 1) + self.assertIsNone(second.next_cursor) + self.assertEqual( + catalog.list_media.call_args_list[1].kwargs["offset"], + 2, + ) + + +class ArtifactServiceTests(unittest.TestCase): + def test_actor_overlay_uses_catalog_media_and_store_owned_destination(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.mp4" + source.write_bytes(b"source") + media = Mock() + media.content.return_value = LocalFileResource( + path=source, + filename="source.mp4", + mime_type="video/mp4", + byte_size=6, + etag="1" * 64, + ) + catalog = Mock() + catalog.get_artifact_by_request.return_value = None + catalog.put_artifact.side_effect = lambda item: item + renderer = Mock() + renderer.render.side_effect = ( + lambda _source, destination, _cluster, _detections: + destination.write_bytes(b"rendered") + ) + probe = Mock() + probe.probe.return_value = MediaProbe( + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=1, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + ) + store = LocalArtifactStore(root / "artifacts") + service = ArtifactService( + catalog=catalog, + store=store, + media=media, + probe=probe, + actor_renderer=renderer, + snippet_renderer=Mock(), + max_snippet_duration_seconds=300, + ) + with patch("vidxp.artifact_service.uuid4") as identifier: + identifier.return_value.hex = ARTIFACT_ID + result = service.create_actor_overlay( + media_id=MEDIA_ID, + generation_id="223456781234423481234567890abcde", + cluster_id="cluster", + detections=[{"frame_index": 1}], + profile=ActorOverlayProfile.default, + ) + + self.assertEqual(result.artifact_id, ARTIFACT_ID) + self.assertEqual(result.media_id, MEDIA_ID) + self.assertEqual(result.byte_size, len(b"rendered")) + renderer.render.assert_called_once() + self.assertNotIn("path", result.model_dump(mode="json")) + catalog.put_artifact.assert_called_once() + + def test_actor_renderer_failure_leaves_no_staged_or_ready_artifact(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.mp4" + source.write_bytes(b"source") + media = Mock() + media.content.return_value = LocalFileResource( + path=source, + filename="source.mp4", + mime_type="video/mp4", + byte_size=6, + etag="1" * 64, + ) + catalog = Mock() + catalog.get_artifact_by_request.return_value = None + renderer = Mock() + renderer.render.side_effect = RuntimeError("render failed") + store = LocalArtifactStore(root / "artifacts") + service = ArtifactService( + catalog=catalog, + store=store, + media=media, + probe=Mock(), + actor_renderer=renderer, + snippet_renderer=Mock(), + max_snippet_duration_seconds=300, + ) + with ( + patch("vidxp.artifact_service.uuid4") as identifier, + self.assertRaisesRegex(RuntimeError, "render failed"), + ): + identifier.return_value.hex = ARTIFACT_ID + service.create_actor_overlay( + media_id=MEDIA_ID, + generation_id="223456781234423481234567890abcde", + cluster_id="cluster", + detections=[], + profile=ActorOverlayProfile.default, + ) + + self.assertFalse(any(store.staging.glob("*"))) + catalog.put_artifact.assert_not_called() + + def test_invalid_rendered_media_is_not_published_or_cataloged(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.mp4" + source.write_bytes(b"source") + media = Mock() + media.content.return_value = LocalFileResource( + path=source, + filename="source.mp4", + mime_type="video/mp4", + byte_size=6, + etag="1" * 64, + ) + catalog = Mock() + catalog.get_artifact_by_request.return_value = None + renderer = Mock() + renderer.render.side_effect = ( + lambda _source, destination, _cluster, _detections: + destination.write_bytes(b"not-video") + ) + probe = Mock() + probe.probe.side_effect = InvalidMediaError("invalid") + store = LocalArtifactStore(root / "artifacts") + service = ArtifactService( + catalog=catalog, + store=store, + media=media, + probe=probe, + actor_renderer=renderer, + snippet_renderer=Mock(), + max_snippet_duration_seconds=300, + ) + + with self.assertRaises(InvalidArtifactError): + service.create_actor_overlay( + media_id=MEDIA_ID, + generation_id="223456781234423481234567890abcde", + cluster_id="cluster", + detections=[], + profile=ActorOverlayProfile.default, + ) + + self.assertFalse(any(store.staging.glob("*"))) + catalog.put_artifact.assert_not_called() + + def test_snippet_is_bounded_and_reuses_ready_request(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.mp4" + source.write_bytes(b"source") + media = Mock() + media.require_record.return_value = record() + media.content.return_value = LocalFileResource( + path=source, + filename="source.mp4", + mime_type="video/mp4", + byte_size=6, + etag="1" * 64, + ) + catalog = Mock() + catalog.get_artifact_by_request.return_value = None + catalog.put_artifact.side_effect = lambda item: item + renderer = Mock() + renderer.render.side_effect = ( + lambda _source, destination, **_kwargs: + destination.write_bytes(b"rendered") + ) + probe = Mock() + probe.probe.return_value = MediaProbe( + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=1, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + ) + store = LocalArtifactStore(root / "artifacts") + service = ArtifactService( + catalog=catalog, + store=store, + media=media, + probe=probe, + actor_renderer=Mock(), + snippet_renderer=renderer, + max_snippet_duration_seconds=1, + ) + command = CreateSnippetCommand( + media_id=MEDIA_ID, + start_seconds=0, + end_seconds=1, + profile=SnippetProfile.compatible_mp4, + ) + + created = service.create_snippet(command) + catalog.get_artifact_by_request.return_value = ( + catalog.put_artifact.call_args.args[0] + ) + reused = service.create_snippet(command) + cached_record = catalog.put_artifact.call_args.args[0] + store.delete(cached_record.storage_key) + regenerated = service.create_snippet(command) + + self.assertEqual(reused.artifact_id, created.artifact_id) + self.assertNotEqual(regenerated.artifact_id, created.artifact_id) + self.assertEqual(renderer.render.call_count, 2) + catalog.invalidate_artifact_request.assert_called_once_with( + cached_record.request_key, + cached_record.artifact_id, + ) + + def test_expired_artifact_is_unavailable(self): + catalog = Mock() + expired = Mock() + catalog.get_artifact.return_value = expired + expired.state = ArtifactState.ready + expired.expires_at = datetime(2000, 1, 1, tzinfo=timezone.utc) + service = ArtifactService( + catalog=catalog, + store=Mock(), + media=Mock(), + probe=Mock(), + actor_renderer=Mock(), + snippet_renderer=Mock(), + max_snippet_duration_seconds=1, + ) + + with self.assertRaises(ArtifactUnavailableError): + service.require_record(ARTIFACT_ID) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_public_path_contracts.py b/tests/test_public_path_contracts.py new file mode 100644 index 0000000..8c052e8 --- /dev/null +++ b/tests/test_public_path_contracts.py @@ -0,0 +1,150 @@ +import json +import unittest +from dataclasses import is_dataclass +from pathlib import Path + +from pydantic import BaseModel, ValidationError + +from vidxp.application_models import ( + Artifact, + CreateActorOverlayCommand, + CreateIndexCommand, + CreateSnippetCommand, + ImportMediaCommand, + IndexResult, + IndexStatus, + MediaAsset, + MediaPage, + PrepareModelsResult, +) +from vidxp.capabilities.registry import create_capability_registry +from vidxp.capabilities.contracts import OperationDefinition +from vidxp.capabilities.schemas import SearchHit +from vidxp.ports import LocalFileResource + + +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" + + +def schema_fields(schema): + fields = set() + if isinstance(schema, dict): + fields.update(schema.get("properties", ())) + for value in schema.values(): + fields.update(schema_fields(value)) + elif isinstance(schema, list): + for value in schema: + fields.update(schema_fields(value)) + return fields + + +class PublicPathContractTests(unittest.TestCase): + def test_shared_contracts_do_not_expose_paths_or_storage_keys(self): + models = [ + Artifact, + CreateActorOverlayCommand, + CreateIndexCommand, + CreateSnippetCommand, + IndexResult, + IndexStatus, + MediaAsset, + MediaPage, + PrepareModelsResult, + SearchHit, + ] + registry = create_capability_registry() + for definition in registry.definitions.values(): + for operation in definition.operations.values(): + models.extend((operation.input_model, operation.output_model)) + + forbidden = { + "path", + "input_path", + "output_path", + "storage_key", + "repository_root", + "index_directory", + } + for model in models: + with self.subTest(model=model.__name__): + schema = model.model_json_schema() + self.assertTrue(forbidden.isdisjoint(schema_fields(schema))) + self.assertNotIn('"format": "path"', json.dumps(schema)) + + def test_only_local_import_accepts_a_path(self): + command = ImportMediaCommand(path=Path("video.mp4")) + self.assertEqual(command.path, Path("video.mp4")) + with self.assertRaises(ValidationError): + CreateIndexCommand.model_validate( + { + "path": "video.mp4", + "media_id": MEDIA_ID, + "modalities": ["scene"], + } + ) + + def test_media_and_artifact_ids_are_not_content_hashes_or_names(self): + with self.assertRaises(ValidationError): + CreateIndexCommand(media_id="episode-1", modalities=("scene",)) + with self.assertRaises(ValidationError): + CreateIndexCommand(media_id="1" * 64, modalities=("scene",)) + + def test_search_metadata_rejects_internal_locations(self): + with self.assertRaises(ValidationError): + SearchHit( + rank=1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, + start=1, + end=2, + score=0, + raw_distance=0, + modality="scene", + source_id="source", + metadata={"source_path": "C:/secret/video.mp4"}, + ) + + with self.assertRaises(ValidationError): + SearchHit( + rank=1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, + start=1, + end=2, + score=0, + raw_distance=0, + modality="scene", + source_id="source", + metadata={"source": {"storage_key": "secret"}}, + ) + + def test_external_public_operation_cannot_expose_paths(self): + class UnsafeInput(BaseModel): + path: Path + + class SafeOutput(BaseModel): + ok: bool + + with self.assertRaises(ValidationError): + OperationDefinition( + input_model=UnsafeInput, + output_model=SafeOutput, + ) + + def test_local_file_handle_is_not_dataclass_serializable(self): + handle = LocalFileResource( + path=Path("internal"), + filename="video.mp4", + mime_type="video/mp4", + byte_size=1, + etag="1" * 64, + ) + self.assertFalse(is_dataclass(handle)) + self.assertFalse(hasattr(handle, "__dict__")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_search.py b/tests/test_search.py index 4a8fe4f..be7c58a 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -19,6 +19,10 @@ from vidxp.settings import VidXPSettings +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" + + class FakeStorage: def __init__(self, rows): self.rows = rows @@ -29,7 +33,7 @@ def query(self, modality, embedding, **options): return list(self.rows) -def dialogue_row(source_id, distance, video_id="video-1"): +def dialogue_row(source_id, distance, video_id=MEDIA_ID): return { "source_id": source_id, "raw_distance": distance, @@ -38,6 +42,7 @@ def dialogue_row(source_id, distance, video_id="video-1"): "split": "test", "run_id": "run-1", "video_id": video_id, + "generation_id": GENERATION_ID, "source_id": source_id, "start": 1.0, "end": 2.0, @@ -80,7 +85,7 @@ def test_top_k_filter_order_distance_and_score_are_preserved(self): config=self.config, runtime=self.runtime, top_k=3, - video_id="video-1", + video_id=MEDIA_ID, query_id="query-7", storage=storage, ) @@ -96,8 +101,12 @@ def test_top_k_filter_order_distance_and_score_are_preserved(self): self.assertEqual([hit.rank for hit in result.hits], [1, 2, 3]) self.assertEqual(result.hits[0].raw_distance, 0.1) self.assertEqual(result.hits[0].score, -0.1) + self.assertEqual( + result.hits[0].metadata, + {"text": "fresh bread", "phrase_id": 3}, + ) self.assertEqual(storage.calls[0][2]["top_k"], 3) - self.assertEqual(storage.calls[0][2]["video_id"], "video-1") + self.assertEqual(storage.calls[0][2]["video_id"], MEDIA_ID) def test_score_is_strictly_monotonic_and_not_a_probability(self): self.assertGreater(distance_to_score(0.1), distance_to_score(0.2)) From 686c9b970b9f03101708c82e39148f1f3e5721a0 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 04:41:37 +0500 Subject: [PATCH 06/46] feat: add durable background jobs --- README.md | 11 + changes/durable-jobs.feature.md | 7 + pyproject.toml | 2 + src/vidxp/application.py | 45 ++- src/vidxp/application_models.py | 139 +++++++++- src/vidxp/artifact_service.py | 88 +++++- src/vidxp/cli.py | 3 + src/vidxp/cli_commands/actors.py | 19 +- src/vidxp/cli_commands/artifacts.py | 19 +- src/vidxp/cli_commands/index.py | 31 ++- src/vidxp/cli_commands/jobs.py | 96 +++++++ src/vidxp/cli_commands/runtime.py | 40 ++- src/vidxp/cli_support.py | 2 + src/vidxp/composition.py | 26 +- src/vidxp/core/identifiers.py | 23 +- src/vidxp/core/runner.py | 31 +-- src/vidxp/core/video.py | 29 ++ src/vidxp/execution.py | 42 +++ src/vidxp/frontend.py | 101 +++++-- src/vidxp/index_state.py | 84 ------ src/vidxp/index_worker.py | 73 ----- src/vidxp/infrastructure/dbos_jobs.py | 291 ++++++++++++++++++++ src/vidxp/infrastructure/dbos_workflows.py | 220 +++++++++++++++ src/vidxp/infrastructure/local_artifacts.py | 89 +++++- src/vidxp/infrastructure/local_index.py | 85 +++++- src/vidxp/infrastructure/local_worker.py | 129 +++++++++ src/vidxp/job_service.py | 208 ++++++++++++++ src/vidxp/ports.py | 48 +++- src/vidxp/repository_layout.py | 4 + src/vidxp/settings.py | 6 + src/vidxp/workflow_contracts.py | 24 ++ src/vidxp/workflow_runtime.py | 52 ++++ src/vidxp/workflow_worker.py | 137 +++++++++ tests/test_benchmark_cli.py | 1 + tests/test_cli.py | 44 ++- tests/test_dbos_jobs.py | 211 ++++++++++++++ tests/test_frontend.py | 1 + tests/test_index_state.py | 81 ------ tests/test_index_worker.py | 158 ----------- tests/test_job_contracts.py | 136 +++++++++ tests/test_local_snapshots.py | 132 +++++++++ tests/test_local_worker.py | 159 +++++++++++ tests/test_media_services.py | 167 ++++++++++- tests/test_runner.py | 10 - tests/test_video.py | 1 + uv.lock | 210 ++++++++++++++ 46 files changed, 3002 insertions(+), 513 deletions(-) create mode 100644 changes/durable-jobs.feature.md create mode 100644 src/vidxp/cli_commands/jobs.py create mode 100644 src/vidxp/execution.py delete mode 100644 src/vidxp/index_worker.py create mode 100644 src/vidxp/infrastructure/dbos_jobs.py create mode 100644 src/vidxp/infrastructure/dbos_workflows.py create mode 100644 src/vidxp/infrastructure/local_worker.py create mode 100644 src/vidxp/job_service.py create mode 100644 src/vidxp/workflow_contracts.py create mode 100644 src/vidxp/workflow_runtime.py create mode 100644 src/vidxp/workflow_worker.py create mode 100644 tests/test_dbos_jobs.py delete mode 100644 tests/test_index_state.py delete mode 100644 tests/test_index_worker.py create mode 100644 tests/test_job_contracts.py create mode 100644 tests/test_local_worker.py diff --git a/README.md b/README.md index 718b25a..b438253 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,17 @@ vidxp index create --modality scene --frame-stride 5 ``` Repeat `--modality` to combine `dialogue`, `scene`, and `actor`. +Indexing, artifact rendering, and model preparation run as durable background +jobs. Commands wait by default; add `--detach` to return after queueing, then +inspect or control the job separately: + +```bash +vidxp jobs list +vidxp jobs show +vidxp jobs cancel +vidxp jobs retry +``` + Run `vidxp --help` or any command followed by `--help` for the complete command reference. diff --git a/changes/durable-jobs.feature.md b/changes/durable-jobs.feature.md new file mode 100644 index 0000000..3dd7161 --- /dev/null +++ b/changes/durable-jobs.feature.md @@ -0,0 +1,7 @@ +Add durable background jobs: + +- run indexing, snippets, actor overlays, and model preparation through DBOS workflows +- use the same typed job, progress, cancellation, and result contracts locally and on servers +- persist local jobs in SQLite and support PostgreSQL-backed worker deployments +- keep CPU and GPU work on explicit queues with concurrency controlled by DBOS +- supervise the detached local worker without creating a second job-state store diff --git a/pyproject.toml b/pyproject.toml index bbc95e2..353be1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ classifiers = [ ] dynamic = ["optional-dependencies"] dependencies = [ + "dbos>=2.28,<3", "filelock>=3.32,<4", "packaging>=26.2,<27", "platformdirs>=4.11,<5", @@ -46,6 +47,7 @@ dependencies = [ [project.scripts] vidxp = "vidxp.cli:main" +vidxp-worker = "vidxp.workflow_worker:main" [project.urls] Repository = "https://github.com/grayhatdevelopers/vidxp" diff --git a/src/vidxp/application.py b/src/vidxp/application.py index 1a8b154..1bcb711 100644 --- a/src/vidxp/application.py +++ b/src/vidxp/application.py @@ -44,7 +44,6 @@ from vidxp.capabilities.registry import CapabilityRegistry from vidxp.capabilities.schemas import SearchResult from vidxp.core.contracts import ( - CancellationToken, IndexCancelledError, IndexConfig, IndexSchemaError, @@ -54,7 +53,7 @@ ArtifactRenderError, ArtifactRendererUnavailableError, ) -from vidxp.core.indexing_common import ProgressCallback +from vidxp.execution import ExecutionContext, execution_context from vidxp.index_state import ( INDEX_STATUS_SCHEMA, IndexingInProgressError, @@ -335,9 +334,9 @@ def create_index( self, command: CreateIndexCommand, *, - progress_callback: ProgressCallback | None = None, - cancellation: CancellationToken | None = None, + execution: ExecutionContext | None = None, ) -> IndexResult: + active_execution = execution_context(execution) selected = self.registry.validate_names(command.modalities) non_indexable = [ name @@ -368,8 +367,9 @@ def create_index( result = self.index_backend.create( content.path, config=config, - progress=progress_callback, - cancellation=cancellation, + progress=active_execution.report, + cancellation=active_execution.cancellation, + operation_id=active_execution.operation_id, source_name=media.original_filename, source_checksum=media.sha256, ) @@ -399,8 +399,9 @@ def prepare_models( self, command: PrepareModelsCommand, *, - progress_callback: ProgressCallback | None = None, + execution: ExecutionContext | None = None, ) -> PrepareModelsResult: + active_execution = execution_context(execution) selected = self.registry.validate_names(command.modalities) options = self.registry.validate_options( selected, @@ -418,6 +419,7 @@ def prepare_models( with self.runtime.scheduler.inference(): with self._capability_dependencies(selected): for name in selected: + active_execution.checkpoint() executor = self.registry.executor(name) if executor.prepare is not None: prepared.extend( @@ -427,9 +429,10 @@ def prepare_models( settings=self.registry.get(name) .config_model.model_validate(options[name]), ), - progress_callback, + active_execution.report, ) ) + active_execution.checkpoint() return PrepareModelsResult( prepared=tuple(prepared), modalities=selected, @@ -537,10 +540,20 @@ def actor_detections( def render_actor( self, command: CreateActorOverlayCommand, + *, + execution: ExecutionContext | None = None, ) -> Artifact: + active_execution = execution_context(execution) + active_execution.report( + { + "stage": "resolving_detections", + "message": "Resolving actor detections.", + } + ) detections = [] cursor = None while True: + active_execution.checkpoint() page = self.actor_detections( command.cluster_id, page_size=100, @@ -569,11 +582,23 @@ def render_actor( for detection in detections ], profile=command.profile, + job_id=active_execution.job_id, + execution=active_execution, ) @application_boundary - def create_snippet(self, command: CreateSnippetCommand) -> Artifact: - return self.artifact_service.create_snippet(command) + def create_snippet( + self, + command: CreateSnippetCommand, + *, + execution: ExecutionContext | None = None, + ) -> Artifact: + active_execution = execution_context(execution) + return self.artifact_service.create_snippet( + command, + job_id=active_execution.job_id, + execution=active_execution, + ) @application_boundary def clear_index(self) -> bool: diff --git a/src/vidxp/application_models.py b/src/vidxp/application_models.py index a218c0a..41bec5e 100644 --- a/src/vidxp/application_models.py +++ b/src/vidxp/application_models.py @@ -2,7 +2,7 @@ from enum import StrEnum from pathlib import Path -from typing import Any, Generic, Literal, Mapping, TypeVar +from typing import Annotated, Any, Generic, Literal, Mapping, TypeVar from pydantic import ( AwareDatetime, @@ -12,6 +12,7 @@ JsonValue, NonNegativeInt, field_validator, + model_validator, ) from vidxp.core.identifiers import ( @@ -76,6 +77,27 @@ class ErrorCategory(StrEnum): internal = "internal" +class JobKind(StrEnum): + index = "index" + snippet = "snippet" + actor_overlay = "actor_overlay" + prepare_models = "prepare_models" + + +class JobState(StrEnum): + queued = "queued" + running = "running" + succeeded = "succeeded" + failed = "failed" + cancelled = "cancelled" + recovery_exhausted = "recovery_exhausted" + + +class JobQueue(StrEnum): + cpu = "cpu" + gpu = "gpu" + + class ErrorDetail(ApplicationModel): code: str = Field(min_length=1, pattern=r"^[a-z][a-z0-9_]*$") category: ErrorCategory @@ -335,3 +357,118 @@ class PrepareModelsResult(ApplicationModel): prepared: tuple[str, ...] modalities: tuple[str, ...] runtime: RuntimeProfile + + +JOB_SCHEMA_VERSION = 1 + + +class IndexJobRequest(ApplicationModel): + kind: Literal[JobKind.index] = JobKind.index + command: CreateIndexCommand + + +class SnippetJobRequest(ApplicationModel): + kind: Literal[JobKind.snippet] = JobKind.snippet + command: CreateSnippetCommand + + +class ActorOverlayJobRequest(ApplicationModel): + kind: Literal[JobKind.actor_overlay] = JobKind.actor_overlay + command: CreateActorOverlayCommand + + +class PrepareModelsJobRequest(ApplicationModel): + kind: Literal[JobKind.prepare_models] = JobKind.prepare_models + command: PrepareModelsCommand + + +JobRequest = Annotated[ + IndexJobRequest + | SnippetJobRequest + | ActorOverlayJobRequest + | PrepareModelsJobRequest, + Field(discriminator="kind"), +] + + +class IndexJobResult(ApplicationModel): + kind: Literal[JobKind.index] = JobKind.index + result: IndexResult + + +class ArtifactJobResult(ApplicationModel): + kind: Literal[JobKind.snippet, JobKind.actor_overlay] + result: Artifact + + +class PrepareModelsJobResult(ApplicationModel): + kind: Literal[JobKind.prepare_models] = JobKind.prepare_models + result: PrepareModelsResult + + +JobResult = Annotated[ + IndexJobResult | ArtifactJobResult | PrepareModelsJobResult, + Field(discriminator="kind"), +] + + +class JobProgress(ApplicationModel): + schema_version: Literal[JOB_SCHEMA_VERSION] = JOB_SCHEMA_VERSION + stage: str = Field(min_length=1, max_length=128) + message: str = Field(min_length=1, max_length=512) + current: int | None = Field(default=None, ge=0) + total: int | None = Field(default=None, gt=0) + updated_at: AwareDatetime + + @model_validator(mode="after") + def _validate_position(self) -> "JobProgress": + if ( + self.current is not None + and self.total is not None + and self.current > self.total + ): + raise ValueError("current must not exceed total") + return self + + +class Job(ApplicationModel): + schema_version: Literal[JOB_SCHEMA_VERSION] = JOB_SCHEMA_VERSION + job_id: JobId + kind: JobKind + state: JobState + queue: JobQueue + progress: JobProgress | None = None + result: JobResult | None = None + error: ErrorDetail | None = None + recovery_attempts: int = Field(default=0, ge=0) + created_at: AwareDatetime | None = None + updated_at: AwareDatetime | None = None + + @model_validator(mode="after") + def _validate_terminal_payload(self) -> "Job": + if self.state == JobState.succeeded: + if self.result is None or self.error is not None: + raise ValueError( + "succeeded jobs require a result and no error" + ) + if self.result.kind != self.kind: + raise ValueError("job result kind must match job kind") + elif self.result is not None: + raise ValueError("only succeeded jobs may contain a result") + if self.state in {JobState.queued, JobState.running}: + if self.error is not None: + raise ValueError("active jobs may not contain an error") + elif self.state in {JobState.failed, JobState.recovery_exhausted}: + if self.error is None: + raise ValueError("failed jobs require a typed error") + return self + + +class ListJobsCommand(ApplicationModel): + page_size: int = Field(default=50, gt=0, le=100) + cursor: str | None = Field(default=None, min_length=1, max_length=512) + + +class JobPage(ApplicationModel): + items: tuple[Job, ...] = () + next_cursor: str | None = None diff --git a/src/vidxp/artifact_service.py b/src/vidxp/artifact_service.py index 7ff4881..65f5aa1 100644 --- a/src/vidxp/artifact_service.py +++ b/src/vidxp/artifact_service.py @@ -19,6 +19,7 @@ ArtifactState, ) from vidxp.core.media import InvalidMediaError, utc_now +from vidxp.execution import ExecutionContext, execution_context from vidxp.media_service import MediaService from vidxp.ports import ( ActorRendererPort, @@ -100,7 +101,11 @@ def create_actor_overlay( cluster_id: str, detections: list[dict], profile: ActorOverlayProfile, + job_id: str | None = None, + execution: ExecutionContext | None = None, ) -> Artifact: + active_execution = execution_context(execution) + active_execution.checkpoint() source = self.media.content(media_id) request_key = _request_key( { @@ -120,15 +125,27 @@ def create_actor_overlay( request_key=request_key, suffix=".mp4", expected_mime_type="video/mp4", + job_id=job_id, + execution=active_execution, render=lambda destination: self.actor_renderer.render( source.path, destination, cluster_id, detections, + cancellation=active_execution.cancellation, + progress=active_execution.progress, ), ) - def create_snippet(self, command: CreateSnippetCommand) -> Artifact: + def create_snippet( + self, + command: CreateSnippetCommand, + *, + job_id: str | None = None, + execution: ExecutionContext | None = None, + ) -> Artifact: + active_execution = execution_context(execution) + active_execution.checkpoint() media_record = self.media.require_record(command.media_id) duration = command.end_seconds - command.start_seconds if command.end_seconds > media_record.duration_seconds: @@ -161,12 +178,16 @@ def create_snippet(self, command: CreateSnippetCommand) -> Artifact: expected_mime_type=( "video/mp4" if compatible else "video/x-matroska" ), + job_id=job_id, + execution=active_execution, render=lambda destination: self.snippet_renderer.render( source.path, destination, start_seconds=command.start_seconds, end_seconds=command.end_seconds, compatible_mp4=compatible, + cancellation=active_execution.cancellation, + progress=active_execution.progress, ), ) @@ -180,8 +201,11 @@ def _create( request_key: str, suffix: str, expected_mime_type: str, + job_id: str | None, + execution: ExecutionContext, render: Callable[[Path], None], ) -> Artifact: + execution.checkpoint() cached = self.catalog.get_artifact_by_request(request_key) if cached is not None: try: @@ -193,15 +217,46 @@ def _create( cached.artifact_id, ) else: + execution.report( + { + "stage": "complete", + "message": "Reused the existing ready artifact.", + "current": 1, + "total": 1, + } + ) return artifact_result(cached) - artifact_id = uuid4().hex + artifact_id = execution.operation_id or uuid4().hex staged = self.store.stage(artifact_id, suffix=suffix) - stored = None + stored = self.store.recover(artifact_id, suffix=suffix) try: - render(staged.path) + if stored is None: + execution.report( + { + "stage": "rendering", + "message": "Rendering the requested video artifact.", + } + ) + render(staged.path) + execution.checkpoint() + artifact_path = staged.path + else: + execution.report( + { + "stage": "recovering", + "message": "Recovering the published video artifact.", + } + ) + artifact_path = stored.local_path + execution.report( + { + "stage": "validating", + "message": "Validating the rendered video artifact.", + } + ) try: - probed = self.probe.probe(staged.path) + probed = self.probe.probe(artifact_path) except InvalidMediaError as exc: raise InvalidArtifactError( "The rendered artifact is not valid video." @@ -210,7 +265,16 @@ def _create( raise InvalidArtifactError( "The rendered artifact does not match its output profile." ) - stored = self.store.publish(staged) + execution.checkpoint() + if stored is None: + execution.report( + { + "stage": "publishing", + "message": "Publishing the validated video artifact.", + } + ) + stored = self.store.publish(staged) + execution.checkpoint() record = ArtifactRecord( artifact_id=artifact_id, media_id=media_id, @@ -222,19 +286,29 @@ def _create( byte_size=stored.byte_size, sha256=stored.sha256, storage_key=stored.storage_key, + job_id=job_id, state=ArtifactState.ready, created_at=utc_now(), ) authoritative = self.catalog.put_artifact(record) if authoritative.artifact_id != artifact_id: self._delete_quietly(stored.storage_key) - return artifact_result(authoritative) + stored = None except BaseException: if stored is not None: self._delete_quietly(stored.storage_key) raise finally: self.store.discard(staged) + execution.report( + { + "stage": "complete", + "message": "The video artifact is ready.", + "current": 1, + "total": 1, + } + ) + return artifact_result(authoritative) def _delete_quietly(self, storage_key: str) -> None: try: diff --git a/src/vidxp/cli.py b/src/vidxp/cli.py index bfe62b0..b4988ac 100644 --- a/src/vidxp/cli.py +++ b/src/vidxp/cli.py @@ -12,6 +12,7 @@ from vidxp.application_models import ApplicationError from vidxp.cli_commands.actors import app as actor_app from vidxp.cli_commands.index import app as index_app +from vidxp.cli_commands.jobs import app as jobs_app from vidxp.cli_commands.media import app as media_app from vidxp.cli_commands.artifacts import app as artifacts_app from vidxp.cli_commands.repositories import app as repositories_app @@ -27,6 +28,7 @@ help="Index and search video with installable capabilities.", ) app.add_typer(index_app, name="index") +app.add_typer(jobs_app, name="jobs") app.add_typer(media_app, name="media") app.add_typer(artifacts_app, name="artifacts") app.command("search")(search) @@ -120,6 +122,7 @@ def app_options( ) ctx.obj = CLIState( service=local.application, + jobs=local.jobs, registry=local.repositories, repository=local.repository, output_format=output_format, diff --git a/src/vidxp/cli_commands/actors.py b/src/vidxp/cli_commands/actors.py index 2569859..102a881 100644 --- a/src/vidxp/cli_commands/actors.py +++ b/src/vidxp/cli_commands/actors.py @@ -167,6 +167,13 @@ def actors_render( bool, typer.Option("--json", help="Emit machine-readable JSON."), ] = False, + detach: Annotated[ + bool, + typer.Option( + "--detach", + help="Return after the durable job is queued.", + ), + ] = False, ) -> None: """Render one actor cluster as a result video.""" @@ -191,18 +198,24 @@ def actors_render( cursor = page.next_cursor if selected is None: raise typer.BadParameter("Actor cluster was not found.") - result = state.service.render_actor( + job = state.jobs.submit_actor_overlay( CreateActorOverlayCommand( cluster_id=cluster_id, media_id=selected.media_id, generation_id=selected.generation_id, ) ) - payload = result.model_dump(mode="json") + if not detach: + job = state.jobs.wait(job.job_id) + payload = job.model_dump(mode="json") if effective_output_format(state, json_output) == OutputFormat.json: emit_json(payload) else: typer.secho( - f"Actor overlay artifact created: {result.artifact_id}", + ( + f"Actor overlay job queued: {job.job_id}" + if detach + else f"Actor overlay job completed: {job.job_id}" + ), fg=typer.colors.GREEN, ) diff --git a/src/vidxp/cli_commands/artifacts.py b/src/vidxp/cli_commands/artifacts.py index 65b600e..8e36fe9 100644 --- a/src/vidxp/cli_commands/artifacts.py +++ b/src/vidxp/cli_commands/artifacts.py @@ -39,11 +39,18 @@ def create_snippet( bool, typer.Option("--json", help="Emit machine-readable JSON."), ] = False, + detach: Annotated[ + bool, + typer.Option( + "--detach", + help="Return after the durable job is queued.", + ), + ] = False, ) -> None: """Create a managed video snippet artifact.""" state = state_from_context(ctx) - artifact = state.service.create_snippet( + job = state.jobs.submit_snippet( CreateSnippetCommand( media_id=media_id, start_seconds=start_seconds, @@ -51,12 +58,18 @@ def create_snippet( profile=profile, ) ) - payload = artifact.model_dump(mode="json") + if not detach: + job = state.jobs.wait(job.job_id) + payload = job.model_dump(mode="json") if effective_output_format(state, json_output) == OutputFormat.json: emit_json(payload) else: typer.secho( - f"Snippet artifact created: {artifact.artifact_id}", + ( + f"Snippet job queued: {job.job_id}" + if detach + else f"Snippet artifact job completed: {job.job_id}" + ), fg=typer.colors.GREEN, ) diff --git a/src/vidxp/cli_commands/index.py b/src/vidxp/cli_commands/index.py index 3e457c8..743ebf1 100644 --- a/src/vidxp/cli_commands/index.py +++ b/src/vidxp/cli_commands/index.py @@ -28,26 +28,41 @@ def create_index( modalities: Iterable[str], frame_stride: int, capability_options: dict[str, dict], + detach: bool = False, ) -> dict: show_progress = ( not state.quiet and state.output_format == OutputFormat.rich ) with IndexProgress(show_progress) as progress: - result = state.service.create_index( + job = state.jobs.submit_index( CreateIndexCommand( media_id=media_id, modalities=tuple(modalities), frame_stride=frame_stride, capability_options=capability_options, ), - progress_callback=progress.update, ) - summary = result.model_dump(mode="json") + if not detach: + job = state.jobs.wait( + job.job_id, + progress=lambda current: ( + progress.update( + current.progress.model_dump(mode="python") + ) + if current.progress is not None + else None + ), + ) + summary = job.model_dump(mode="json") if state.output_format == OutputFormat.json: emit_json(summary) else: typer.secho( - "Video indexing completed successfully.", + ( + f"Indexing job queued: {job.job_id}" + if detach + else f"Video indexing completed: {job.job_id}" + ), fg=typer.colors.GREEN, bold=True, ) @@ -87,6 +102,13 @@ def index_create( ), ), ] = None, + detach: Annotated[ + bool, + typer.Option( + "--detach", + help="Return after the durable job is queued.", + ), + ] = False, ) -> None: """Add media or replace its immutable generation in the active index.""" @@ -100,6 +122,7 @@ def index_create( ), frame_stride=frame_stride, capability_options=parse_capability_options(capability_options), + detach=detach, ) diff --git a/src/vidxp/cli_commands/jobs.py b/src/vidxp/cli_commands/jobs.py new file mode 100644 index 0000000..6d3b87a --- /dev/null +++ b/src/vidxp/cli_commands/jobs.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from typing import Annotated + +import typer +from rich.console import Console +from rich.table import Table + +from vidxp.application_models import ListJobsCommand +from vidxp.cli_support import ( + OutputFormat, + effective_output_format, + emit_json, + state_from_context, +) + + +app = typer.Typer(no_args_is_help=True, help="Manage durable background jobs.") + + +@app.command("list") +def list_jobs( + ctx: typer.Context, + page_size: Annotated[ + int, + typer.Option("--page-size", min=1, max=100), + ] = 50, + cursor: Annotated[ + str | None, + typer.Option("--cursor", help="Cursor returned by the previous page."), + ] = None, + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """List durable VidXP jobs.""" + + state = state_from_context(ctx) + page = state.jobs.list( + ListJobsCommand(page_size=page_size, cursor=cursor) + ) + payload = page.model_dump(mode="json") + if effective_output_format(state, json_output) == OutputFormat.json: + emit_json(payload) + return + table = Table(title="VidXP jobs") + table.add_column("Job") + table.add_column("Kind") + table.add_column("State") + table.add_column("Queue") + table.add_column("Progress") + for job in page.items: + table.add_row( + job.job_id, + job.kind.value, + job.state.value, + job.queue.value, + job.progress.message if job.progress is not None else "—", + ) + Console().print(table) + if page.next_cursor is not None: + typer.echo(f"Next cursor: {page.next_cursor}") + + +@app.command("show") +def show_job( + ctx: typer.Context, + job_id: Annotated[str, typer.Argument(help="Durable job identifier.")], +) -> None: + """Show one job and its typed result when available.""" + + state = state_from_context(ctx) + emit_json(state.jobs.get(job_id).model_dump(mode="json")) + + +@app.command("cancel") +def cancel_job( + ctx: typer.Context, + job_id: Annotated[str, typer.Argument(help="Durable job identifier.")], +) -> None: + """Request cancellation of a queued or running job.""" + + state = state_from_context(ctx) + emit_json(state.jobs.cancel(job_id).model_dump(mode="json")) + + +@app.command("retry") +def retry_job( + ctx: typer.Context, + job_id: Annotated[str, typer.Argument(help="Durable job identifier.")], +) -> None: + """Retry a failed or cancelled job through DBOS recovery.""" + + state = state_from_context(ctx) + emit_json(state.jobs.retry(job_id).model_dump(mode="json")) diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py index 4d8e448..0e568bc 100644 --- a/src/vidxp/cli_commands/runtime.py +++ b/src/vidxp/cli_commands/runtime.py @@ -100,6 +100,13 @@ def prepare( bool, typer.Option("--json", help="Emit machine-readable JSON."), ] = False, + detach: Annotated[ + bool, + typer.Option( + "--detach", + help="Return after the durable job is queued.", + ), + ] = False, ) -> None: """Download and cache selected runtime models before indexing.""" @@ -109,24 +116,35 @@ def prepare( if modalities is None else parse_modalities(modalities, state.service.registry) ) - result = state.service.prepare_models( + job = state.jobs.submit_prepare_models( PrepareModelsCommand( modalities=selected, capability_options=parse_capability_options(capability_options), - ), - progress_callback=( - None - if state.quiet - or effective_output_format(state, json_output) - == OutputFormat.json - else lambda event: typer.echo(event["message"]) - ), + ) ) + if not detach: + show_progress = ( + not state.quiet + and effective_output_format(state, json_output) + != OutputFormat.json + ) + job = state.jobs.wait( + job.job_id, + progress=lambda current: ( + typer.echo(current.progress.message) + if show_progress and current.progress is not None + else None + ), + ) if effective_output_format(state, json_output) == OutputFormat.json: - emit_json(result.model_dump(mode="json")) + emit_json(job.model_dump(mode="json")) else: typer.secho( - "Selected VidXP runtime models are prepared.", + ( + f"Model preparation job queued: {job.job_id}" + if detach + else f"Selected runtime models are prepared: {job.job_id}" + ), fg=typer.colors.GREEN, bold=True, ) diff --git a/src/vidxp/cli_support.py b/src/vidxp/cli_support.py index 2b7b489..bfc9efb 100644 --- a/src/vidxp/cli_support.py +++ b/src/vidxp/cli_support.py @@ -19,6 +19,7 @@ from vidxp.application import VidXPApplication from vidxp.capabilities.registry import CapabilityRegistry from vidxp.capabilities.schemas import SearchResult +from vidxp.job_service import JobService from vidxp.repositories import RepositoryConfig, RepositoryRegistry @@ -30,6 +31,7 @@ class OutputFormat(str, Enum): @dataclass class CLIState: service: VidXPApplication + jobs: JobService registry: RepositoryRegistry repository: RepositoryConfig output_format: OutputFormat = OutputFormat.rich diff --git a/src/vidxp/composition.py b/src/vidxp/composition.py index b25d3c5..9c25729 100644 --- a/src/vidxp/composition.py +++ b/src/vidxp/composition.py @@ -19,7 +19,10 @@ ) from vidxp.infrastructure.local_catalog import LocalCatalog from vidxp.infrastructure.local_media import FFprobeMediaProbe, LocalMediaStore +from vidxp.infrastructure.dbos_jobs import DBOSJobBackend +from vidxp.infrastructure.local_worker import LocalWorkerSupervisor from vidxp.artifact_service import ArtifactService +from vidxp.job_service import JobService from vidxp.media_service import MediaService from vidxp.runtime import ModelRuntime, RuntimeBackendUnavailableError from vidxp.repositories import ( @@ -28,12 +31,17 @@ RepositoryRegistry, resolve_repository, ) -from vidxp.settings import VidXPSettings +from vidxp.settings import ApplicationMode, VidXPSettings +from vidxp.workflow_runtime import ( + workflow_application_version, + workflow_database_url, +) @dataclass(frozen=True) class LocalApplicationContext: application: VidXPApplication + jobs: JobService repositories: RepositoryRegistry repository: RepositoryConfig @@ -101,6 +109,21 @@ def create_application( ) +def create_job_service(settings: VidXPSettings) -> JobService: + settings.layout.ensure_local_directories() + before_access = None + if settings.mode != ApplicationMode.server: + before_access = LocalWorkerSupervisor(settings).ensure_running + return JobService( + settings=settings, + backend=DBOSJobBackend( + system_database_url=workflow_database_url(settings), + application_version=workflow_application_version(), + before_access=before_access, + ), + ) + + def create_local_application( *, registry_path: str | Path | None = None, @@ -124,6 +147,7 @@ def create_local_application( ) from exc return LocalApplicationContext( application=create_application(settings), + jobs=create_job_service(settings), repositories=repositories, repository=repository, ) diff --git a/src/vidxp/core/identifiers.py b/src/vidxp/core/identifiers.py index 8708b6d..d488282 100644 --- a/src/vidxp/core/identifiers.py +++ b/src/vidxp/core/identifiers.py @@ -20,11 +20,32 @@ def _require_uuid4_hex(value: str) -> str: return value +def _require_workflow_uuid(value: str) -> str: + identifier = UUID(value) + if ( + identifier.version not in {4, 7} + or value not in {identifier.hex, str(identifier)} + ): + raise ValueError("identifier must be a lowercase UUID4 or UUID7 string") + return value + + Uuid4Hex: TypeAlias = Annotated[ str, StringConstraints(pattern=r"^[0-9a-f]{32}$"), AfterValidator(_require_uuid4_hex), ] +WorkflowUuid: TypeAlias = Annotated[ + str, + StringConstraints( + pattern=( + r"^(?:[0-9a-f]{12}[47][0-9a-f]{19}|" + r"[0-9a-f]{8}-[0-9a-f]{4}-[47][0-9a-f]{3}-" + r"[89ab][0-9a-f]{3}-[0-9a-f]{12})$" + ) + ), + AfterValidator(_require_workflow_uuid), +] Sha256: TypeAlias = Annotated[ str, StringConstraints(pattern=r"^[0-9a-f]{64}$"), @@ -44,5 +65,5 @@ def _require_uuid4_hex(value: str) -> str: VideoId: TypeAlias = MediaId IndexGenerationId: TypeAlias = Uuid4Hex IndexSnapshotId: TypeAlias = Uuid4Hex -JobId: TypeAlias = Identifier +JobId: TypeAlias = WorkflowUuid ArtifactId: TypeAlias = Uuid4Hex diff --git a/src/vidxp/core/runner.py b/src/vidxp/core/runner.py index bd19c9b..81937f3 100644 --- a/src/vidxp/core/runner.py +++ b/src/vidxp/core/runner.py @@ -20,10 +20,7 @@ source_checksum, source_checksums, ) -from vidxp.index_state import ( - IndexingInProgressError, - write_index_status, -) +from vidxp.index_state import IndexingInProgressError from vidxp.ports import IndexStore, ModelRuntimePort @@ -446,16 +443,6 @@ def run_index( ) -def _video_status(path: Path, source_name: str, checksum: str) -> dict[str, Any]: - stat = path.stat() - return { - "path": str(path.resolve()), - "source_name": source_name, - "size": stat.st_size, - "sha256": checksum, - } - - def index_video( path: str, progress_callback: ProgressCallback | None = None, @@ -486,11 +473,6 @@ def index_video( ) active_config = config video_id = active_config.video_id or checksum - video = _video_status( - input_path, - source.source_name or input_path.name, - checksum, - ) latest_event: dict[str, Any] = { "state": "indexing", "stage": "initializing", @@ -498,17 +480,6 @@ def index_video( def report(event: dict[str, Any]) -> None: latest_event.update(event) - write_index_status( - state=event["state"], - stage=event["stage"], - message=event["message"], - video=video, - current=event.get("current"), - total=event.get("total"), - summary=event.get("summary"), - error=event.get("error"), - index_directory=active_config.run_directory, - ) if progress_callback is not None: progress_callback(event) diff --git a/src/vidxp/core/video.py b/src/vidxp/core/video.py index 074ee6a..171f2d5 100644 --- a/src/vidxp/core/video.py +++ b/src/vidxp/core/video.py @@ -5,6 +5,7 @@ from typing import Iterator from vidxp.core.contracts import CancellationToken +from vidxp.core.indexing_common import ProgressCallback @dataclass(frozen=True) @@ -110,9 +111,13 @@ def render_actor_video( output_path: str | Path, cluster_id: str, detections: list[dict], + *, + cancellation: CancellationToken | None = None, + progress: ProgressCallback | None = None, ) -> None: import cv2 + active_cancellation = cancellation or CancellationToken() source_path = Path(input_path) destination = Path(output_path) if not source_path.is_file(): @@ -129,6 +134,7 @@ def render_actor_video( fps = float(source.get(cv2.CAP_PROP_FPS)) width = int(source.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(source.get(cv2.CAP_PROP_FRAME_HEIGHT)) + frame_count = int(source.get(cv2.CAP_PROP_FRAME_COUNT)) if fps <= 0 or width <= 0 or height <= 0: source.release() raise RuntimeError( @@ -160,6 +166,7 @@ def render_actor_video( try: frame_index = 0 while True: + active_cancellation.raise_if_cancelled() retrieved, frame = source.read() if not retrieved: break @@ -187,10 +194,32 @@ def render_actor_video( writer.write(frame) frame_index += 1 frames_written += 1 + if progress is not None and frames_written % 30 == 0: + progress( + { + "state": "rendering", + "stage": "rendering", + "message": "Rendering the actor overlay.", + "current": frames_written, + "total": frame_count if frame_count > 0 else None, + } + ) finally: source.release() writer.release() + active_cancellation.raise_if_cancelled() + if progress is not None and frames_written: + progress( + { + "state": "rendering", + "stage": "rendering", + "message": "Rendered the actor overlay.", + "current": frames_written, + "total": frame_count if frame_count > 0 else frames_written, + } + ) + if ( frames_written == 0 or not destination.is_file() diff --git a/src/vidxp/execution.py b/src/vidxp/execution.py new file mode 100644 index 0000000..d1e94fc --- /dev/null +++ b/src/vidxp/execution.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import hashlib +from dataclasses import dataclass, field +from typing import Any +from uuid import UUID + +from vidxp.core.contracts import CancellationToken +from vidxp.core.indexing_common import ProgressCallback + + +@dataclass(frozen=True) +class ExecutionContext: + """Runtime-only progress and cancellation controls for worker operations.""" + + job_id: str | None = None + progress: ProgressCallback | None = None + cancellation: CancellationToken = field(default_factory=CancellationToken) + + def report(self, event: dict[str, Any]) -> None: + self.cancellation.raise_if_cancelled() + if self.progress is not None: + self.progress(event) + + def checkpoint(self) -> None: + self.cancellation.raise_if_cancelled() + + @property + def operation_id(self) -> str | None: + """Return a filesystem-safe UUID identity without changing the job ID.""" + + if self.job_id is None: + return None + identifier = UUID(self.job_id) + if identifier.version == 4: + return identifier.hex + digest = hashlib.sha256(self.job_id.encode()).digest()[:16] + return UUID(bytes=digest, version=4).hex + + +def execution_context(value: ExecutionContext | None) -> ExecutionContext: + return value if value is not None else ExecutionContext() diff --git a/src/vidxp/frontend.py b/src/vidxp/frontend.py index cbf7541..d15d1d6 100644 --- a/src/vidxp/frontend.py +++ b/src/vidxp/frontend.py @@ -13,17 +13,15 @@ from vidxp.application_models import ( ApplicationError, CreateActorOverlayCommand, + CreateIndexCommand, DependencyCheckCommand, ImportMediaCommand, + JobState, SearchCommand, ) -from vidxp.composition import create_application +from vidxp.composition import create_application, create_job_service from vidxp.index_state import IndexNotReadyError -from vidxp.index_worker import ( - cancel_indexing, - indexing_in_progress, - start_indexing, -) +from vidxp.job_service import JobService from vidxp.settings import VidXPSettings @@ -37,6 +35,11 @@ def _configured_service( return create_application(settings or _settings_from_arguments()) +@lru_cache(maxsize=1) +def _configured_jobs(settings: VidXPSettings | None = None) -> JobService: + return create_job_service(settings or _settings_from_arguments()) + + def _settings_from_arguments( arguments: Sequence[str] | None = None, ) -> VidXPSettings: @@ -52,6 +55,7 @@ def _settings_from_arguments( INDEX_REQUESTED_KEY = "_vidxp_index_requested" INDEX_ERROR_KEY = "_vidxp_index_error" +INDEX_JOB_ID_KEY = "_vidxp_index_job_id" SEARCH_RESULT_KEY = "_vidxp_search_result" CANCEL_REQUESTED_KEY = "_vidxp_cancel_requested" MEDIA_ID_KEY = "_vidxp_media_id" @@ -100,6 +104,16 @@ def _render_progress(event): ) +def _get_job(jobs: JobService, job_id: str | None): + if job_id is None: + return None + try: + return jobs.get(job_id) + except ApplicationError: + LOGGER.warning("Ignoring unavailable background job %s.", job_id) + return None + + def _render_index_status(status, active, media_id, request_error=None): if request_error: st.error(request_error) @@ -142,7 +156,9 @@ def _request_indexing(): def _request_cancellation(): - if cancel_indexing(): + job_id = st.session_state.get(INDEX_JOB_ID_KEY) + if job_id is not None: + _configured_jobs().cancel(job_id) st.session_state[CANCEL_REQUESTED_KEY] = True st.session_state.pop(INDEX_ERROR_KEY, None) else: @@ -193,11 +209,13 @@ def _run_indexing(uploaded_video, status, modalities): media_id = media_ids[0] if len(media_ids) == 1 else None if media_id is None: raise ValueError("Select or import media before indexing.") - start_indexing( - media_id, - service, - modalities=modalities, + job = _configured_jobs().submit_index( + CreateIndexCommand( + media_id=media_id, + modalities=modalities, + ) ) + st.session_state[INDEX_JOB_ID_KEY] = job.job_id except ApplicationError as exc: st.session_state[INDEX_ERROR_KEY] = str(exc) except Exception: @@ -232,13 +250,17 @@ def _run_search(search_type, query): ) if cluster is None: return {"error": "No matching actor cluster was found."} - artifact = service.render_actor( + job = _configured_jobs().submit_actor_overlay( CreateActorOverlayCommand( cluster_id=query, media_id=cluster.media_id, generation_id=cluster.generation_id, ) ) + artifact = _configured_jobs().wait(job.job_id).result + if artifact is None: + return {"error": "The actor artifact result is unavailable."} + artifact = artifact.result return { "type": search_type, "query": query, @@ -381,7 +403,16 @@ def run(): st.caption("Index and search video by dialogue, scene, and actor.") st.caption(f"Index repository: {service.layout.root}") - active = indexing_in_progress(service) + jobs = _configured_jobs() + job_id = st.session_state.get(INDEX_JOB_ID_KEY) + current_job = _get_job(jobs, job_id) + if job_id is not None and current_job is None: + st.session_state.pop(INDEX_JOB_ID_KEY, None) + job_id = None + active = ( + current_job is not None + and current_job.state in {JobState.queued, JobState.running} + ) if not active: st.session_state.pop(CANCEL_REQUESTED_KEY, None) requested = st.session_state.get(INDEX_REQUESTED_KEY, False) @@ -457,15 +488,49 @@ def run(): @st.fragment(run_every="1s") def poll_index_status(): - latest_active = indexing_in_progress(service) + latest_job = _get_job(jobs, job_id) + if latest_job is None: + st.session_state.pop(INDEX_JOB_ID_KEY, None) + st.error("The background indexing job is unavailable.") + st.rerun() + return + latest_active = latest_job.state in { + JobState.queued, + JobState.running, + } + if not latest_active: + if latest_job.state == JobState.succeeded: + st.session_state.pop(INDEX_ERROR_KEY, None) + elif latest_job.error is not None: + st.session_state[INDEX_ERROR_KEY] = ( + latest_job.error.message + ) + elif latest_job.state == JobState.cancelled: + st.session_state[INDEX_ERROR_KEY] = ( + "Indexing was cancelled." + ) + else: + st.session_state[INDEX_ERROR_KEY] = ( + "Indexing did not complete successfully." + ) + st.session_state.pop(INDEX_JOB_ID_KEY, None) + st.rerun() + return + latest_status = ( + latest_job.progress.model_dump(mode="json") + if latest_job.progress is not None + else { + "state": "indexing", + "stage": "queued", + "message": "Indexing is queued.", + } + ) _render_index_status( - service.index_status().model_dump(mode="json"), - latest_active, + latest_status, + True, selected_media_id, st.session_state.get(INDEX_ERROR_KEY), ) - if not latest_active: - st.rerun() poll_index_status() else: diff --git a/src/vidxp/index_state.py b/src/vidxp/index_state.py index ac1c942..27dfa45 100644 --- a/src/vidxp/index_state.py +++ b/src/vidxp/index_state.py @@ -1,12 +1,3 @@ -import hashlib -import json -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - - -INDEX_DIRECTORY = Path("chroma_data") -INDEX_STATUS_FILE = "index_status.json" INDEX_STATUS_SCHEMA = 1 @@ -16,78 +7,3 @@ class IndexNotReadyError(RuntimeError): class IndexingInProgressError(RuntimeError): """Raised when another indexing run is already active.""" - - -def fingerprint_file(path: str | Path) -> dict[str, Any]: - file_path = Path(path) - digest = hashlib.sha256() - - with file_path.open("rb") as source: - for chunk in iter(lambda: source.read(1024 * 1024), b""): - digest.update(chunk) - - stat = file_path.stat() - return { - "path": str(file_path.resolve()), - "size": stat.st_size, - "sha256": digest.hexdigest(), - } - - -def read_index_status(index_directory: str | Path = INDEX_DIRECTORY) -> dict[str, Any] | None: - status_path = Path(index_directory) / INDEX_STATUS_FILE - if not status_path.is_file(): - return None - - try: - return json.loads(status_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return { - "schema_version": INDEX_STATUS_SCHEMA, - "state": "failed", - "stage": "status", - "message": "The saved index status is unreadable. Re-index the video.", - } - - -def write_index_status( - *, - state: str, - stage: str, - message: str, - video: dict[str, Any] | None = None, - current: int | None = None, - total: int | None = None, - summary: dict[str, Any] | None = None, - error: str | None = None, - index_directory: str | Path = INDEX_DIRECTORY, -) -> dict[str, Any]: - index_path = Path(index_directory) - index_path.mkdir(parents=True, exist_ok=True) - status_path = index_path / INDEX_STATUS_FILE - temporary_path = status_path.with_suffix(".tmp") - - payload: dict[str, Any] = { - "schema_version": INDEX_STATUS_SCHEMA, - "state": state, - "stage": stage, - "message": message, - "updated_at": datetime.now(timezone.utc).isoformat(), - } - if video is not None: - payload["video"] = video - if current is not None: - payload["current"] = current - if total is not None: - payload["total"] = total - if summary is not None: - payload["summary"] = summary - if error is not None: - payload["error"] = error - - temporary_path.write_text( - json.dumps(payload, indent=2, sort_keys=True), - encoding="utf-8", - ) - temporary_path.replace(status_path) - return payload diff --git a/src/vidxp/index_worker.py b/src/vidxp/index_worker.py deleted file mode 100644 index 8d412ff..0000000 --- a/src/vidxp/index_worker.py +++ /dev/null @@ -1,73 +0,0 @@ -from multiprocessing import get_context -from multiprocessing.process import BaseProcess -from threading import Lock -from typing import Any, Mapping - -from vidxp.application import VidXPApplication -from vidxp.application_models import CreateIndexCommand -from vidxp.composition import create_application -from vidxp.core.contracts import CancellationToken -from vidxp.index_state import IndexingInProgressError -from vidxp.settings import VidXPSettings - -_process: BaseProcess | None = None -_cancel_event = None -_start_lock = Lock() - - -def _run_indexing( - media_id: str, - cancel_event, - settings_payload: Mapping[str, Any], - modalities: tuple[str, ...], -) -> None: - create_application( - VidXPSettings.model_validate(settings_payload) - ).create_index( - CreateIndexCommand( - media_id=media_id, - modalities=modalities, - ), - cancellation=CancellationToken(cancel_event), - ) - - -def indexing_in_progress(service: VidXPApplication) -> bool: - return ( - _process is not None and _process.is_alive() - ) or service.indexing_in_progress() - - -def start_indexing( - media_id: str, - service: VidXPApplication, - *, - modalities: tuple[str, ...], -) -> None: - global _cancel_event, _process - - with _start_lock: - if indexing_in_progress(service): - raise IndexingInProgressError("Another video is already being indexed.") - - context = get_context("spawn") - _cancel_event = context.Event() - _process = context.Process( - target=_run_indexing, - args=( - media_id, - _cancel_event, - service.settings.model_dump(mode="python"), - modalities, - ), - name="vidxp-indexer", - daemon=True, - ) - _process.start() - - -def cancel_indexing() -> bool: - if _process is None or not _process.is_alive() or _cancel_event is None: - return False - _cancel_event.set() - return True diff --git a/src/vidxp/infrastructure/dbos_jobs.py b/src/vidxp/infrastructure/dbos_jobs.py new file mode 100644 index 0000000..4b6cdab --- /dev/null +++ b/src/vidxp/infrastructure/dbos_jobs.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +import base64 +import binascii +import json +from collections.abc import Callable +from datetime import datetime, timezone +from typing import Any +from uuid import uuid4 + +from dbos import DBOSClient, EnqueueOptions +from pydantic import TypeAdapter, ValidationError + +from vidxp.application_models import ( + Artifact, + ArtifactJobResult, + ErrorDetail, + ErrorCategory, + IndexJobResult, + IndexResult, + Job, + JobKind, + JobPage, + JobProgress, + JobQueue, + JobRequest, + JobState, + ListJobsCommand, + PrepareModelsJobResult, + PrepareModelsResult, +) +from vidxp.core.identifiers import JobId +from vidxp.ports import InvalidJobBackendRequestError +from vidxp.workflow_contracts import ( + ERROR_EVENT, + PROGRESS_EVENT, + QUEUE_KINDS, + QUEUE_NAMES, + WORKFLOW_CLASS_NAME, + WORKFLOW_INSTANCE_NAME, + WORKFLOW_KINDS, + WORKFLOW_NAMES, +) + + +_JOB_ID_ADAPTER = TypeAdapter(JobId) +_LIST_SCOPE = "vidxp:jobs" +_STATUS_STATES = { + "ENQUEUED": JobState.queued, + "DELAYED": JobState.queued, + "PENDING": JobState.running, + "SUCCESS": JobState.succeeded, + "ERROR": JobState.failed, + "CANCELLED": JobState.cancelled, + "MAX_RECOVERY_ATTEMPTS_EXCEEDED": JobState.recovery_exhausted, +} + + +def _encode_cursor(offset: int, *, has_more: bool) -> str | None: + if not has_more: + return None + payload = json.dumps( + {"version": 1, "scope": _LIST_SCOPE, "offset": offset}, + sort_keys=True, + separators=(",", ":"), + ).encode() + return base64.urlsafe_b64encode(payload).decode() + + +def _decode_cursor(cursor: str | None) -> int: + if cursor is None: + return 0 + try: + payload = json.loads( + base64.urlsafe_b64decode(cursor.encode()).decode() + ) + offset = int(payload["offset"]) + except ( + KeyError, + TypeError, + UnicodeDecodeError, + ValueError, + json.JSONDecodeError, + binascii.Error, + ) as exc: + raise InvalidJobBackendRequestError( + "The job cursor is invalid." + ) from exc + if ( + not isinstance(payload, dict) + or payload.get("version") != 1 + or payload.get("scope") != _LIST_SCOPE + or offset < 0 + ): + raise InvalidJobBackendRequestError("The job cursor is invalid.") + return offset + + +def _job_id(value: str) -> str: + try: + return _JOB_ID_ADAPTER.validate_python(value) + except ValidationError as exc: + raise InvalidJobBackendRequestError( + "The job identifier is invalid." + ) from exc + + +def _timestamp(value: int | None) -> datetime | None: + if value is None: + return None + return datetime.fromtimestamp(value / 1000, timezone.utc) + + +class DBOSJobBackend: + """Thin projection of DBOS workflow state into VidXP job contracts.""" + + def __init__( + self, + *, + system_database_url: str, + application_version: str, + before_access: Callable[[], None] | None = None, + ) -> None: + self.client = DBOSClient(system_database_url=system_database_url) + self.application_version = application_version + self.before_access = before_access + + def close(self) -> None: + self.client.destroy() + + def submit( + self, + request: JobRequest, + *, + queue: JobQueue, + job_id: str | None = None, + ) -> Job: + self._prepare_access() + identifier = _job_id(job_id or uuid4().hex) + options: EnqueueOptions = { + "workflow_name": WORKFLOW_NAMES[request.kind], + "queue_name": QUEUE_NAMES[queue], + "workflow_id": identifier, + "app_version": self.application_version, + "attributes": {"vidxp_queue": queue.value}, + "class_name": WORKFLOW_CLASS_NAME, + "instance_name": WORKFLOW_INSTANCE_NAME, + } + self.client.enqueue(options, request.model_dump(mode="json")) + job = self.get(identifier) + if job is None: + raise RuntimeError("DBOS did not persist the submitted workflow.") + return job + + def get(self, job_id: str) -> Job | None: + self._prepare_access() + identifier = _job_id(job_id) + statuses = self.client.list_workflows( + workflow_ids=[identifier], + limit=1, + load_input=False, + load_output=True, + ) + if not statuses: + return None + status = statuses[0] + if status.name not in WORKFLOW_KINDS: + return None + return self._job(status) + + def list(self, command: ListJobsCommand) -> JobPage: + self._prepare_access() + offset = _decode_cursor(command.cursor) + statuses = self.client.list_workflows( + name=list(WORKFLOW_KINDS), + limit=command.page_size + 1, + offset=offset, + sort_desc=True, + load_input=False, + load_output=True, + ) + has_more = len(statuses) > command.page_size + selected = statuses[: command.page_size] + return JobPage( + items=tuple(self._job(status) for status in selected), + next_cursor=_encode_cursor( + offset + len(selected), + has_more=has_more, + ), + ) + + def cancel(self, job_id: str) -> Job | None: + self._prepare_access() + current = self.get(job_id) + if current is None: + return None + self.client.cancel_workflow(current.job_id) + return self.get(current.job_id) + + def retry(self, job_id: str) -> Job | None: + self._prepare_access() + current = self.get(job_id) + if current is None: + return None + handle = self.client.fork_workflow( + current.job_id, + 1, + application_version=self.application_version, + queue_name=QUEUE_NAMES[current.queue], + ) + retried_id = handle.get_workflow_id() + return self.get(retried_id) + + def _prepare_access(self) -> None: + if self.before_access is not None: + self.before_access() + + def _job(self, status: Any) -> Job: + kind = WORKFLOW_KINDS[status.name] + recorded_queue = (status.attributes or {}).get("vidxp_queue") + try: + queue = ( + JobQueue(recorded_queue) + if recorded_queue is not None + else QUEUE_KINDS.get(status.queue_name) + ) + except ValueError: + queue = None + if queue is None: + raise RuntimeError("A VidXP workflow has an unexpected queue.") + state = _STATUS_STATES[status.status] + progress = self._event(status.workflow_id, PROGRESS_EVENT, JobProgress) + error = ( + self._event(status.workflow_id, ERROR_EVENT, ErrorDetail) + if state + in { + JobState.failed, + JobState.cancelled, + JobState.recovery_exhausted, + } + else None + ) + result = None + if state == JobState.succeeded: + if kind == JobKind.index: + result = IndexJobResult( + result=IndexResult.model_validate(status.output) + ) + elif kind in {JobKind.snippet, JobKind.actor_overlay}: + result = ArtifactJobResult( + kind=kind, + result=Artifact.model_validate(status.output), + ) + else: + result = PrepareModelsJobResult( + result=PrepareModelsResult.model_validate(status.output) + ) + if error is None and state == JobState.failed: + error = ErrorDetail( + code="job_execution_failed", + category=ErrorCategory.internal, + message="The background job failed unexpectedly.", + ) + elif error is None and state == JobState.recovery_exhausted: + error = ErrorDetail( + code="job_recovery_exhausted", + category=ErrorCategory.unavailable, + message="The background job exhausted its recovery attempts.", + retryable=True, + ) + return Job( + job_id=status.workflow_id, + kind=kind, + state=state, + queue=queue, + progress=progress, + result=result, + error=error, + recovery_attempts=status.recovery_attempts or 0, + created_at=_timestamp(status.created_at), + updated_at=_timestamp(status.updated_at), + ) + + def _event(self, job_id: str, key: str, model: type[Any]) -> Any | None: + payload = self.client.get_event(job_id, key, timeout_seconds=0) + if payload is None: + return None + try: + return model.model_validate(payload) + except ValidationError: + return None diff --git a/src/vidxp/infrastructure/dbos_workflows.py b/src/vidxp/infrastructure/dbos_workflows.py new file mode 100644 index 0000000..e4df155 --- /dev/null +++ b/src/vidxp/infrastructure/dbos_workflows.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime, timezone +from time import monotonic +from typing import Any + +from dbos import DBOS, DBOSConfiguredInstance +from pydantic import TypeAdapter + +from vidxp.application import VidXPApplication +from vidxp.application_models import ( + ActorOverlayJobRequest, + ApplicationError, + ErrorCategory, + ErrorDetail, + IndexJobRequest, + JobKind, + JobProgress, + SnippetJobRequest, + PrepareModelsJobRequest, +) +from vidxp.core.contracts import CancellationToken +from vidxp.core.contracts import IndexCancelledError +from vidxp.execution import ExecutionContext +from vidxp.workflow_contracts import ( + ERROR_EVENT, + PROGRESS_EVENT, + WORKFLOW_CLASS_NAME, + WORKFLOW_INSTANCE_NAME, + WORKFLOW_NAMES, +) + + +_ARTIFACT_REQUEST = TypeAdapter( + ActorOverlayJobRequest | SnippetJobRequest +) + + +class _DBOSCancellationEvent: + def __init__(self, poll_interval_seconds: float = 0.2) -> None: + self._poll_interval_seconds = poll_interval_seconds + self._checked_at = 0.0 + self._cancelled = False + + def is_set(self) -> bool: + if self._cancelled: + return True + now = monotonic() + if now - self._checked_at < self._poll_interval_seconds: + return False + self._checked_at = now + status = DBOS.get_workflow_status(DBOS.workflow_id) + self._cancelled = status is not None and status.status == "CANCELLED" + return self._cancelled + + def set(self) -> None: + if DBOS.workflow_id is not None: + DBOS.cancel_workflow(DBOS.workflow_id) + self._cancelled = True + + +def _publish_progress(event: dict[str, Any]) -> None: + stage = str(event.get("stage") or "working")[:128] + message = str(event.get("message") or stage.replace("_", " "))[:512] + current = event.get("current") + total = event.get("total") + progress = JobProgress( + stage=stage, + message=message, + current=current if isinstance(current, int) else None, + total=total if isinstance(total, int) and total > 0 else None, + updated_at=datetime.now(timezone.utc), + ) + DBOS.set_event(PROGRESS_EVENT, progress.model_dump(mode="json")) + + +def _execution() -> ExecutionContext: + cancellation = CancellationToken(_DBOSCancellationEvent()) + + def publish(event: dict[str, Any]) -> None: + cancellation.raise_if_cancelled() + _publish_progress(event) + + return ExecutionContext( + job_id=DBOS.workflow_id, + progress=publish, + cancellation=cancellation, + ) + + +def _publish_error(exc: BaseException) -> None: + if isinstance(exc, ApplicationError): + error = exc.detail + else: + error = ErrorDetail( + code="job_execution_failed", + category=ErrorCategory.internal, + message="The background job failed unexpectedly.", + ) + DBOS.set_event(ERROR_EVENT, error.model_dump(mode="json")) + + +def _step_boundary( + operation: Callable[[], dict[str, Any]], +) -> dict[str, Any]: + try: + return operation() + except IndexCancelledError: + raise + except BaseException as exc: + _publish_error(exc) + raise RuntimeError("VidXP job execution failed.") from None + + +@DBOS.dbos_class(class_name=WORKFLOW_CLASS_NAME) +class VidXPWorkerWorkflows(DBOSConfiguredInstance): + """Official DBOS configured instance for one composed worker application.""" + + def __init__(self, application: VidXPApplication) -> None: + self.application = application + super().__init__(WORKFLOW_INSTANCE_NAME) + + @DBOS.step(name="vidxp.run_index.v1") + def run_index_step(self, payload: dict[str, Any]) -> dict[str, Any]: + def execute() -> dict[str, Any]: + request = IndexJobRequest.model_validate(payload) + execution = _execution() + _publish_progress( + { + "stage": "starting", + "message": "Starting the indexing job.", + } + ) + result = self.application.create_index( + request.command, + execution=execution, + ) + _publish_progress( + { + "stage": "complete", + "message": "The index generation is ready.", + "current": 1, + "total": 1, + } + ) + return result.model_dump(mode="json") + + return _step_boundary(execute) + + @DBOS.step(name="vidxp.run_artifact.v1") + def run_artifact_step(self, payload: dict[str, Any]) -> dict[str, Any]: + def execute() -> dict[str, Any]: + request = _ARTIFACT_REQUEST.validate_python(payload) + execution = _execution() + if isinstance(request, SnippetJobRequest): + result = self.application.create_snippet( + request.command, + execution=execution, + ) + else: + result = self.application.render_actor( + request.command, + execution=execution, + ) + return result.model_dump(mode="json") + + return _step_boundary(execute) + + @DBOS.step(name="vidxp.prepare_models_step.v1") + def prepare_models_step( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + def execute() -> dict[str, Any]: + request = PrepareModelsJobRequest.model_validate(payload) + execution = _execution() + _publish_progress( + { + "stage": "preparing_models", + "message": "Preparing model artifacts.", + } + ) + result = self.application.prepare_models( + request.command, + execution=execution, + ) + _publish_progress( + { + "stage": "complete", + "message": "Model artifacts are ready.", + "current": 1, + "total": 1, + } + ) + return result.model_dump(mode="json") + + return _step_boundary(execute) + + @DBOS.workflow(name=WORKFLOW_NAMES[JobKind.index]) + def index_workflow(self, payload: dict[str, Any]) -> dict[str, Any]: + return self.run_index_step(payload) + + @DBOS.workflow(name=WORKFLOW_NAMES[JobKind.snippet]) + def snippet_workflow(self, payload: dict[str, Any]) -> dict[str, Any]: + return self.run_artifact_step(payload) + + @DBOS.workflow(name=WORKFLOW_NAMES[JobKind.actor_overlay]) + def actor_overlay_workflow( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + return self.run_artifact_step(payload) + + @DBOS.workflow(name=WORKFLOW_NAMES[JobKind.prepare_models]) + def prepare_models_workflow( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + return self.prepare_models_step(payload) diff --git a/src/vidxp/infrastructure/local_artifacts.py b/src/vidxp/infrastructure/local_artifacts.py index 52361c3..872d893 100644 --- a/src/vidxp/infrastructure/local_artifacts.py +++ b/src/vidxp/infrastructure/local_artifacts.py @@ -2,6 +2,7 @@ import subprocess from pathlib import Path +from time import monotonic, sleep from pydantic import TypeAdapter @@ -11,8 +12,11 @@ StagedArtifact, StoredArtifact, ) +from vidxp.core.contracts import CancellationToken from vidxp.core.identifiers import ArtifactId +from vidxp.core.indexing_common import ProgressCallback from vidxp.core.video import render_actor_video +from vidxp.core.manifest import sha256_file from vidxp.infrastructure.local_files import ( prepare_managed_destination, ) @@ -60,6 +64,28 @@ def publish(self, staged: StagedArtifact) -> StoredArtifact: local_path=path, ) + def recover( + self, + artifact_id: str, + *, + suffix: str, + ) -> StoredArtifact | None: + validated_id = TypeAdapter(ArtifactId).validate_python(artifact_id) + storage_key = ( + f"objects/{validated_id[:2]}/" + f"{validated_id}{suffix.lower()}" + ) + try: + path = self._managed.resolve(storage_key) + except FileNotFoundError: + return None + return StoredArtifact( + sha256=sha256_file(path), + byte_size=path.stat().st_size, + storage_key=storage_key, + local_path=path, + ) + def discard(self, staged: StagedArtifact) -> None: staged.path.unlink(missing_ok=True) @@ -91,8 +117,18 @@ def render( destination: Path, cluster_id: str, detections: list[dict], + *, + cancellation: CancellationToken, + progress: ProgressCallback | None, ) -> None: - render_actor_video(source, destination, cluster_id, detections) + render_actor_video( + source, + destination, + cluster_id, + detections, + cancellation=cancellation, + progress=progress, + ) class FFmpegSnippetRenderer: @@ -113,6 +149,8 @@ def render( start_seconds: float, end_seconds: float, compatible_mp4: bool, + cancellation: CancellationToken, + progress: ProgressCallback | None, ) -> None: duration = end_seconds - start_seconds encoding = ( @@ -121,7 +159,7 @@ def render( else ["-c", "copy"] ) try: - subprocess.run( + process = subprocess.Popen( [ self.executable, "-nostdin", @@ -138,17 +176,50 @@ def render( *encoding, str(destination), ], - check=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - timeout=self.timeout_seconds, + stderr=subprocess.DEVNULL, ) except FileNotFoundError as exc: raise ArtifactRendererUnavailableError( "The configured ffmpeg executable is unavailable." ) from exc - except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: - raise ArtifactRenderError( - "The requested snippet could not be rendered." - ) from exc + + started_at = monotonic() + last_progress_at = started_at + try: + while process.poll() is None: + cancellation.raise_if_cancelled() + now = monotonic() + if now - started_at > self.timeout_seconds: + raise ArtifactRenderError( + "The requested snippet render timed out." + ) + if progress is not None and now - last_progress_at >= 1: + progress( + { + "state": "rendering", + "stage": "rendering", + "message": "Rendering the requested video snippet.", + } + ) + last_progress_at = now + sleep(0.1) + + if process.returncode != 0: + raise ArtifactRenderError( + "The requested snippet could not be rendered." + ) + except BaseException: + if process.poll() is None: + self._stop(process) + raise + + @staticmethod + def _stop(process: subprocess.Popen) -> None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) diff --git a/src/vidxp/infrastructure/local_index.py b/src/vidxp/infrastructure/local_index.py index 8541e7f..e8d0679 100644 --- a/src/vidxp/infrastructure/local_index.py +++ b/src/vidxp/infrastructure/local_index.py @@ -103,13 +103,14 @@ def create( cancellation: CancellationToken | None, source_name: str | None, source_checksum: str, + operation_id: str | None = None, ) -> dict[str, Any]: self._require_index_directory(config.index_directory) repository = self.repository media_id = config.video_id if media_id is None: raise ValueError("A catalog media_id is required for indexing.") - generation_id = repository.new_generation_id() + generation_id = operation_id or repository.new_generation_id() generation_directory = repository.generation_directory(generation_id) build_config = replace( config, @@ -138,6 +139,88 @@ def create( active, active_config, ) + if operation_id is not None and active is not None: + committed = active.generations.get(media_id) + if ( + committed is not None + and committed.generation_id == operation_id + ): + if ( + committed.input_sha256 != source_checksum + or committed.config_fingerprint + != build_config.fingerprint() + ): + raise IndexSchemaError( + "The indexing operation ID is already bound to " + "different input or configuration." + ) + return { + "media_id": media_id, + "generation_id": committed.generation_id, + "snapshot_id": active.snapshot_id, + "active_media_count": len(active.generations), + "record_counts": dict(committed.record_counts), + } + completed_manifest = generation_directory / MANIFEST_FILE + if operation_id is not None and completed_manifest.is_file(): + completed = None + try: + completed = repository.generation_reference( + generation_id=operation_id, + media_id=media_id, + ) + except IndexSchemaError: + try: + raw_manifest = json.loads( + completed_manifest.read_text(encoding="utf-8") + ) + except (OSError, json.JSONDecodeError): + pass + else: + if raw_manifest.get("state") == "complete": + manifest_store = ManifestStore( + build_config, + registry=self.registry, + runtime=self.runtime, + ) + with IndexStorage( + build_config, + create=False, + ) as storage: + recovered_counts = ( + self._validate_generation_records( + storage, + build_config, + ) + ) + manifest_store.record_storage_counts( + recovered_counts + ) + completed = repository.generation_reference( + generation_id=operation_id, + media_id=media_id, + ) + if completed is not None: + if ( + completed.input_sha256 != source_checksum + or completed.config_fingerprint + != build_config.fingerprint() + ): + raise IndexSchemaError( + "The indexing operation ID is already bound to " + "different input or configuration." + ) + snapshot = repository.publish_generation( + completed, + build_config, + ) + return { + "media_id": media_id, + "generation_id": completed.generation_id, + "snapshot_id": snapshot.snapshot_id, + "active_media_count": len(snapshot.generations), + "record_counts": dict(completed.record_counts), + } self._cleanup_abandoned_generations( repository, build_config, diff --git a/src/vidxp/infrastructure/local_worker.py b/src/vidxp/infrastructure/local_worker.py new file mode 100644 index 0000000..e147d40 --- /dev/null +++ b/src/vidxp/infrastructure/local_worker.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from time import monotonic, sleep + +from filelock import FileLock, Timeout + +from vidxp.settings import VidXPSettings +from vidxp.workflow_runtime import ( + LOCAL_WORKER_SETTINGS_ENV, + local_executor_id, + workflow_database_url, + workflow_application_version, +) + + +class LocalWorkerSupervisor: + """Start one detached repository-scoped worker without owning job state.""" + + def __init__(self, settings: VidXPSettings) -> None: + self.settings = settings + self.layout = settings.layout + + def ensure_running(self) -> None: + self.layout.ensure_local_directories() + version = workflow_application_version() + worker_lock_path = ( + self.layout.local_workflows / f"worker-{version}.lock" + ) + ready_path = ( + self.layout.local_workflows / f"worker-{version}.ready" + ) + start_lock = FileLock( + self.layout.local_workflows / f"worker-{version}-start.lock" + ) + with start_lock: + worker_lock = FileLock(worker_lock_path) + if self._wait_for_existing_worker(worker_lock, ready_path): + return + ready_path.unlink(missing_ok=True) + + command = [ + sys.executable, + "-m", + "vidxp.workflow_worker", + "--database-url", + workflow_database_url(self.settings), + "--executor-id", + local_executor_id(self.settings), + "--role", + "all", + "--lock-file", + str(worker_lock_path.resolve()), + "--ready-file", + str(ready_path.resolve()), + ] + environment = os.environ.copy() + environment[LOCAL_WORKER_SETTINGS_ENV] = ( + self.settings.model_dump_json() + ) + options: dict = { + "stdin": subprocess.DEVNULL, + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + "close_fds": True, + "env": environment, + } + if sys.platform == "win32": + options["creationflags"] = ( + subprocess.CREATE_NEW_PROCESS_GROUP + | subprocess.DETACHED_PROCESS + | subprocess.CREATE_NO_WINDOW + ) + else: + options["start_new_session"] = True + process = subprocess.Popen(command, **options) + self._wait_for_worker(process, worker_lock, ready_path) + + @staticmethod + def _wait_for_existing_worker( + worker_lock: FileLock, + ready_path: Path, + *, + timeout_seconds: float = 5, + ) -> bool: + deadline = monotonic() + timeout_seconds + while monotonic() < deadline: + try: + worker_lock.acquire(timeout=0) + except Timeout: + if ready_path.is_file(): + return True + sleep(0.05) + continue + else: + worker_lock.release() + return False + raise RuntimeError( + "The existing local background worker did not become ready." + ) + + @staticmethod + def _wait_for_worker( + process: subprocess.Popen, + worker_lock: FileLock, + ready_path: Path, + *, + timeout_seconds: float = 5, + ) -> None: + deadline = monotonic() + timeout_seconds + while monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError( + "The local background worker exited during startup." + ) + try: + worker_lock.acquire(timeout=0) + except Timeout: + if ready_path.is_file(): + return + else: + worker_lock.release() + sleep(0.05) + raise RuntimeError( + "The local background worker did not become ready in time." + ) diff --git a/src/vidxp/job_service.py b/src/vidxp/job_service.py new file mode 100644 index 0000000..27e2ca3 --- /dev/null +++ b/src/vidxp/job_service.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from functools import wraps +from time import sleep +from typing import Any, Callable + +from vidxp.application_models import ( + ActorOverlayJobRequest, + ApplicationError, + CreateActorOverlayCommand, + CreateIndexCommand, + CreateSnippetCommand, + ErrorCategory, + IndexJobRequest, + Job, + JobPage, + JobQueue, + JobResult, + JobState, + InvalidRequestError, + ListJobsCommand, + PrepareModelsCommand, + PrepareModelsJobRequest, + ResourceNotFoundError, + SnippetJobRequest, +) +from vidxp.ports import InvalidJobBackendRequestError, JobBackend +from vidxp.settings import VidXPSettings + + +def job_boundary(handler: Callable) -> Callable: + """Translate job-backend failures once for every adapter.""" + + @wraps(handler) + def wrapped(*args: Any, **kwargs: Any) -> Any: + try: + return handler(*args, **kwargs) + except ApplicationError: + raise + except InvalidJobBackendRequestError as exc: + raise InvalidRequestError() from exc + except Exception as exc: + raise ApplicationError( + "job_backend_unavailable", + ErrorCategory.unavailable, + "The durable job backend is unavailable.", + retryable=True, + ) from exc + + return wrapped + + +class JobService: + """Transport-neutral durable job commands backed by one workflow engine.""" + + def __init__( + self, + *, + settings: VidXPSettings, + backend: JobBackend, + ) -> None: + self.settings = settings + self.backend = backend + + @job_boundary + def submit_index( + self, + command: CreateIndexCommand, + ) -> Job: + return self.backend.submit( + IndexJobRequest(command=command), + queue=self._model_queue(), + ) + + @job_boundary + def submit_snippet( + self, + command: CreateSnippetCommand, + ) -> Job: + return self.backend.submit( + SnippetJobRequest(command=command), + queue=JobQueue.cpu, + ) + + @job_boundary + def submit_actor_overlay( + self, + command: CreateActorOverlayCommand, + ) -> Job: + return self.backend.submit( + ActorOverlayJobRequest(command=command), + queue=JobQueue.cpu, + ) + + @job_boundary + def submit_prepare_models( + self, + command: PrepareModelsCommand, + ) -> Job: + return self.backend.submit( + PrepareModelsJobRequest(command=command), + queue=self._model_queue(), + ) + + @job_boundary + def get(self, job_id: str) -> Job: + job = self.backend.get(job_id) + if job is None: + raise ResourceNotFoundError("job") + return job + + @job_boundary + def list(self, command: ListJobsCommand) -> JobPage: + return self.backend.list(command) + + @job_boundary + def cancel(self, job_id: str) -> Job: + current = self.get(job_id) + if current.state in { + JobState.succeeded, + JobState.failed, + JobState.cancelled, + JobState.recovery_exhausted, + }: + return current + cancelled = self.backend.cancel(job_id) + if cancelled is None: + raise ResourceNotFoundError("job") + return cancelled + + @job_boundary + def retry(self, job_id: str) -> Job: + current = self.get(job_id) + if current.state not in { + JobState.failed, + JobState.cancelled, + JobState.recovery_exhausted, + }: + raise ApplicationError( + "job_not_retryable", + ErrorCategory.conflict, + "Only failed or cancelled jobs can be retried.", + ) + retried = self.backend.retry(job_id) + if retried is None: + raise ResourceNotFoundError("job") + return retried + + @job_boundary + def result(self, job_id: str) -> JobResult: + job = self.get(job_id) + if job.state == JobState.cancelled: + raise ApplicationError( + "job_cancelled", + ErrorCategory.cancelled, + "The job was cancelled.", + ) + if job.state != JobState.succeeded: + if job.error is not None: + raise ApplicationError( + job.error.code, + job.error.category, + job.error.message, + details=job.error.details, + retryable=job.error.retryable, + ) + raise ApplicationError( + "job_not_complete", + ErrorCategory.conflict, + "The job has not completed successfully.", + retryable=job.state in {JobState.queued, JobState.running}, + ) + if job.result is None: + raise ApplicationError( + "job_result_unavailable", + ErrorCategory.internal, + "The completed job result is unavailable.", + ) + return job.result + + @job_boundary + def wait( + self, + job_id: str, + *, + progress: Callable[[Job], None] | None = None, + ) -> Job: + last_progress = None + while True: + job = self.get(job_id) + if progress is not None and job.progress != last_progress: + progress(job) + last_progress = job.progress + if job.state not in {JobState.queued, JobState.running}: + if job.state != JobState.succeeded: + self.result(job.job_id) + return job + sleep(self.settings.workflow_poll_interval_seconds) + + def _model_queue(self) -> JobQueue: + return ( + JobQueue.gpu + if self.settings.runtime_backend.startswith("cuda") + else JobQueue.cpu + ) + + def close(self) -> None: + self.backend.close() diff --git a/src/vidxp/ports.py b/src/vidxp/ports.py index 89a668a..9b18137 100644 --- a/src/vidxp/ports.py +++ b/src/vidxp/ports.py @@ -11,7 +11,14 @@ runtime_checkable, ) -from vidxp.application_models import RuntimeProfile +from vidxp.application_models import ( + Job, + JobPage, + JobQueue, + JobRequest, + ListJobsCommand, + RuntimeProfile, +) from vidxp.core.artifacts import ( ArtifactRecord, StagedArtifact, @@ -36,6 +43,10 @@ class ResourceLimitError(RuntimeError): """Raised when configured host capacity cannot admit model work.""" +class InvalidJobBackendRequestError(ValueError): + """Raised when a durable job identifier or cursor is malformed.""" + + class LocalFileResource: """Authorized local delivery handle; never serialize this object.""" @@ -125,6 +136,13 @@ def put_artifact(self, record: ArtifactRecord) -> ArtifactRecord: ... class ArtifactStorePort(Protocol): def stage(self, artifact_id: str, *, suffix: str) -> StagedArtifact: ... + def recover( + self, + artifact_id: str, + *, + suffix: str, + ) -> StoredArtifact | None: ... + def publish(self, staged: StagedArtifact) -> StoredArtifact: ... def discard(self, staged: StagedArtifact) -> None: ... @@ -150,6 +168,9 @@ def render( destination: Path, cluster_id: str, detections: list[dict[str, Any]], + *, + cancellation: CancellationToken, + progress: ProgressCallback | None, ) -> None: ... @@ -163,6 +184,8 @@ def render( start_seconds: float, end_seconds: float, compatible_mp4: bool, + cancellation: CancellationToken, + progress: ProgressCallback | None, ) -> None: ... @@ -292,6 +315,7 @@ def create( cancellation: CancellationToken | None, source_name: str | None, source_checksum: str, + operation_id: str | None = None, ) -> dict[str, Any]: ... def indexing_in_progress(self, config: IndexConfig) -> bool: ... @@ -301,3 +325,25 @@ def open_store(self, config: IndexConfig) -> ContextManager[IndexReader]: ... def remove(self, config: IndexConfig, media_id: str) -> bool: ... def clear(self, config: IndexConfig) -> bool: ... + + +class JobBackend(Protocol): + """Durable lifecycle operations owned by the workflow engine.""" + + def submit( + self, + request: JobRequest, + *, + queue: JobQueue, + job_id: str | None = None, + ) -> Job: ... + + def get(self, job_id: str) -> Job | None: ... + + def list(self, command: ListJobsCommand) -> JobPage: ... + + def cancel(self, job_id: str) -> Job | None: ... + + def retry(self, job_id: str) -> Job | None: ... + + def close(self) -> None: ... diff --git a/src/vidxp/repository_layout.py b/src/vidxp/repository_layout.py index d4a67dd..5014c87 100644 --- a/src/vidxp/repository_layout.py +++ b/src/vidxp/repository_layout.py @@ -66,6 +66,10 @@ def artifact_objects(self) -> Path: def local_workflows(self) -> Path: return self.root / "local-workflows" + @property + def workflow_database(self) -> Path: + return self.local_workflows / "jobs.sqlite3" + def ensure_local_directories(self) -> None: for path in ( self.root, diff --git a/src/vidxp/settings.py b/src/vidxp/settings.py index 1a29cbf..9ca3a73 100644 --- a/src/vidxp/settings.py +++ b/src/vidxp/settings.py @@ -36,6 +36,12 @@ class VidXPSettings(BaseSettings): max_loaded_models: int = Field(default=3, gt=0, le=16) max_concurrent_indexing: int = Field(default=1, gt=0, le=16) max_concurrent_inference: int = Field(default=2, gt=0, le=64) + workflow_database_url: str | None = Field(default=None, min_length=1) + workflow_poll_interval_seconds: float = Field( + default=0.25, + gt=0, + le=10, + ) cpu_thread_budget: int = Field( default_factory=lambda: min(256, max(1, os.cpu_count() or 1)), gt=0, diff --git a/src/vidxp/workflow_contracts.py b/src/vidxp/workflow_contracts.py new file mode 100644 index 0000000..e8d60d0 --- /dev/null +++ b/src/vidxp/workflow_contracts.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from vidxp.application_models import JobKind, JobQueue + + +APPLICATION_NAME = "vidxp" +WORKFLOW_CLASS_NAME = "VidXPWorkerWorkflows" +WORKFLOW_INSTANCE_NAME = "default" +PROGRESS_EVENT = "vidxp.progress" +ERROR_EVENT = "vidxp.error" + +WORKFLOW_NAMES: dict[JobKind, str] = { + JobKind.index: "vidxp.index.v1", + JobKind.snippet: "vidxp.snippet.v1", + JobKind.actor_overlay: "vidxp.actor_overlay.v1", + JobKind.prepare_models: "vidxp.prepare_models.v1", +} +WORKFLOW_KINDS = {name: kind for kind, name in WORKFLOW_NAMES.items()} + +QUEUE_NAMES: dict[JobQueue, str] = { + JobQueue.cpu: "vidxp-cpu", + JobQueue.gpu: "vidxp-gpu", +} +QUEUE_KINDS = {name: queue for queue, name in QUEUE_NAMES.items()} diff --git a/src/vidxp/workflow_runtime.py b/src/vidxp/workflow_runtime.py new file mode 100644 index 0000000..793e749 --- /dev/null +++ b/src/vidxp/workflow_runtime.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from pathlib import Path +from urllib.parse import urlsplit + +from vidxp import __version__ +from vidxp.settings import ApplicationMode, VidXPSettings + + +LOCAL_WORKER_SETTINGS_ENV = "VIDXP_LOCAL_WORKER_SETTINGS_JSON" + + +def workflow_database_url(settings: VidXPSettings) -> str: + if settings.workflow_database_url is not None: + database_url = settings.workflow_database_url + if ( + settings.mode == ApplicationMode.server + and not urlsplit(database_url).scheme.startswith("postgresql") + ): + raise ValueError( + "Server mode requires a PostgreSQL workflow database URL." + ) + return database_url + if settings.mode == ApplicationMode.server: + raise ValueError( + "Server mode requires VIDXP_WORKFLOW_DATABASE_URL." + ) + database = settings.layout.workflow_database.resolve() + return f"sqlite:///{database.as_posix()}" + + +def workflow_application_version() -> str: + return __version__ + + +def local_executor_id(settings: VidXPSettings) -> str: + if settings.mode == ApplicationMode.server: + raise ValueError("A local executor ID cannot be used in server mode.") + return f"{workflow_application_version()}-local-0" + + +def server_executor_id(*, role: str, ordinal: int) -> str: + if role not in {"cpu", "gpu"} or ordinal < 0: + raise ValueError("The worker role or ordinal is invalid.") + return f"{workflow_application_version()}-{role}-{ordinal}" + + +def sqlite_database_path(database_url: str) -> Path | None: + prefix = "sqlite:///" + if not database_url.startswith(prefix): + return None + return Path(database_url.removeprefix(prefix)) diff --git a/src/vidxp/workflow_worker.py b/src/vidxp/workflow_worker.py new file mode 100644 index 0000000..9b84a22 --- /dev/null +++ b/src/vidxp/workflow_worker.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import argparse +import os +from threading import Event +from pathlib import Path +from typing import Callable, Sequence + +from dbos import DBOS, DBOSConfig +from filelock import FileLock + +from vidxp.application_models import JobQueue +from vidxp.composition import create_application +from vidxp.infrastructure.dbos_workflows import VidXPWorkerWorkflows +from vidxp.settings import VidXPSettings +from vidxp.workflow_contracts import APPLICATION_NAME, QUEUE_NAMES +from vidxp.workflow_runtime import ( + LOCAL_WORKER_SETTINGS_ENV, + server_executor_id, + workflow_application_version, + workflow_database_url, +) + + +def _arguments(values: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run VidXP DBOS workflows.") + parser.add_argument("--database-url") + parser.add_argument("--executor-id") + parser.add_argument("--role", choices=("all", "cpu", "gpu"), default="cpu") + parser.add_argument("--ordinal", type=int, default=0) + parser.add_argument("--lock-file", type=Path) + parser.add_argument("--ready-file", type=Path) + return parser.parse_args(values) + + +def _resolved_database_url( + settings: VidXPSettings, + override: str | None, +) -> str: + if override is None: + return workflow_database_url(settings) + return workflow_database_url( + settings.model_copy(update={"workflow_database_url": override}) + ) + + +def run_worker( + *, + settings: VidXPSettings, + database_url: str, + executor_id: str, + role: str, + ready: Callable[[], None] | None = None, +) -> None: + config: DBOSConfig = { + "name": APPLICATION_NAME, + "system_database_url": database_url, + "application_version": workflow_application_version(), + "executor_id": executor_id, + "max_executor_threads": 1 if role == "all" else None, + "use_listen_notify": not database_url.startswith("sqlite:///"), + } + DBOS(config=config) + + VidXPWorkerWorkflows(create_application(settings)) + roles = ("cpu", "gpu") if role == "all" else (role,) + queue_names = [ + QUEUE_NAMES[queue_role] + for queue_role in (JobQueue(value) for value in roles) + ] + DBOS.listen_queues(queue_names) + DBOS.launch() + for queue_name in queue_names: + DBOS.register_queue( + queue_name, + worker_concurrency=1, + ) + if ready is not None: + ready() + Event().wait() + + +def main(arguments: Sequence[str] | None = None) -> None: + options = _arguments(arguments) + local_settings = os.environ.pop(LOCAL_WORKER_SETTINGS_ENV, None) + settings = ( + VidXPSettings.model_validate_json(local_settings) + if local_settings is not None + else VidXPSettings() + ) + database_url = _resolved_database_url(settings, options.database_url) + role = options.role + executor_id = options.executor_id + if executor_id is None: + if role == "all": + raise ValueError("The all-queue worker requires an executor ID.") + executor_id = server_executor_id( + role=role, + ordinal=options.ordinal, + ) + + if options.lock_file is None: + run_worker( + settings=settings, + database_url=database_url, + executor_id=executor_id, + role=role, + ) + return + + lock = FileLock(options.lock_file) + ready_file = options.ready_file + + def mark_ready() -> None: + if ready_file is None: + return + ready_file.parent.mkdir(parents=True, exist_ok=True) + temporary = ready_file.with_suffix(".tmp") + temporary.write_text(str(os.getpid()), encoding="utf-8") + temporary.replace(ready_file) + + with lock: + try: + run_worker( + settings=settings, + database_url=database_url, + executor_id=executor_id, + role=role, + ready=mark_ready, + ) + finally: + if ready_file is not None: + ready_file.unlink(missing_ok=True) + + +if __name__ == "__main__": + main() diff --git a/tests/test_benchmark_cli.py b/tests/test_benchmark_cli.py index f413fb0..01a0646 100644 --- a/tests/test_benchmark_cli.py +++ b/tests/test_benchmark_cli.py @@ -36,6 +36,7 @@ def test_benchmark_uses_global_device_and_json_output(self): "create_local_application", return_value=LocalApplicationContext( application=service, + jobs=Mock(), repositories=RepositoryRegistry(config), repository=RepositoryConfig( "default", diff --git a/tests/test_cli.py b/tests/test_cli.py index ba23fa3..551f255 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,10 +13,16 @@ from vidxp.application_models import ( CreateIndexCommand, DependencyCheckResult, + IndexJobResult, IndexResult, IndexStatus, + Job, + JobKind, + JobQueue, + JobState, MediaAsset, PrepareModelsResult, + PrepareModelsJobResult, RemoveIndexCommand, SearchCommand, ) @@ -29,6 +35,7 @@ MEDIA_ID = "123456781234423481234567890abcde" GENERATION_ID = "223456781234423481234567890abcde" SNAPSHOT_ID = "323456781234423481234567890abcde" +JOB_ID = "423456781234423481234567890abcde" class CliTests(unittest.TestCase): @@ -39,6 +46,7 @@ def setUp(self): self.service.index_directory = Path("repo/indexes") self.service.layout.root = Path("repo") self.service.runtime.backends.requested = "cpu" + self.jobs = Mock() self.registry = Mock(spec=RepositoryRegistry) self.registry.path = Path("repositories.json") self.repository = RepositoryConfig( @@ -54,6 +62,7 @@ def invoke(self, arguments): "create_local_application", return_value=LocalApplicationContext( application=self.service, + jobs=self.jobs, repositories=self.registry, repository=self.repository, ), @@ -66,6 +75,7 @@ def test_grouped_commands_are_exposed(self): for command in ( "media", "index", + "jobs", "search", "actors", "artifacts", @@ -96,13 +106,26 @@ def test_search_constructs_shared_command(self): self.assertEqual(json.loads(result.output)["query"], "yellow taxi") def test_index_constructs_shared_command(self): - self.service.create_index.return_value = IndexResult( + result_value = IndexResult( media_id=MEDIA_ID, generation_id=GENERATION_ID, snapshot_id=SNAPSHOT_ID, active_media_count=1, record_counts={"scene": 1}, ) + self.jobs.submit_index.return_value = Job( + job_id=JOB_ID, + kind=JobKind.index, + state=JobState.queued, + queue=JobQueue.cpu, + ) + self.jobs.wait.return_value = Job( + job_id=JOB_ID, + kind=JobKind.index, + state=JobState.succeeded, + queue=JobQueue.cpu, + result=IndexJobResult(result=result_value), + ) result = self.invoke( [ @@ -119,7 +142,7 @@ def test_index_constructs_shared_command(self): ) self.assertEqual(result.exit_code, 0, result.output) - command = self.service.create_index.call_args.args[0] + command = self.jobs.submit_index.call_args.args[0] self.assertIsInstance(command, CreateIndexCommand) self.assertEqual(command.modalities, ("scene",)) self.assertEqual(command.frame_stride, 5) @@ -187,7 +210,7 @@ def test_doctor_and_prepare_use_shared_models(self): modalities=("scene",), checks=(), ) - self.service.prepare_models.return_value = PrepareModelsResult( + prepared_value = PrepareModelsResult( prepared=("scene-model",), modalities=("scene",), runtime={ @@ -196,6 +219,19 @@ def test_doctor_and_prepare_use_shared_models(self): "transcription_device": "cpu", }, ) + self.jobs.submit_prepare_models.return_value = Job( + job_id=JOB_ID, + kind=JobKind.prepare_models, + state=JobState.queued, + queue=JobQueue.cpu, + ) + self.jobs.wait.return_value = Job( + job_id=JOB_ID, + kind=JobKind.prepare_models, + state=JobState.succeeded, + queue=JobQueue.cpu, + result=PrepareModelsJobResult(result=prepared_value), + ) checked = self.invoke( ["doctor", "--modalities", "scene", "--json"] @@ -211,7 +247,7 @@ def test_doctor_and_prepare_use_shared_models(self): ("scene",), ) self.assertEqual( - self.service.prepare_models.call_args.args[0].modalities, + self.jobs.submit_prepare_models.call_args.args[0].modalities, ("scene",), ) diff --git a/tests/test_dbos_jobs.py b/tests/test_dbos_jobs.py new file mode 100644 index 0000000..c962502 --- /dev/null +++ b/tests/test_dbos_jobs.py @@ -0,0 +1,211 @@ +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from threading import Event +from time import sleep +from unittest.mock import Mock + +from dbos import DBOS + +from vidxp.application_models import ( + ApplicationError, + CreateIndexCommand, + ErrorCategory, + IndexResult, + JobKind, + JobQueue, + JobState, + ListJobsCommand, +) +from vidxp.infrastructure.dbos_jobs import DBOSJobBackend +from vidxp.job_service import JobService +from vidxp.settings import VidXPSettings +from vidxp.workflow_contracts import QUEUE_NAMES + + +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" +SNAPSHOT_ID = "323456781234423481234567890abcde" + + +class DBOSJobIntegrationTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + from vidxp.infrastructure.dbos_workflows import VidXPWorkerWorkflows + + cls.worker = VidXPWorkerWorkflows(Mock()) + + def setUp(self): + self.directory = TemporaryDirectory(ignore_cleanup_errors=True) + database = Path(self.directory.name) / "jobs.sqlite3" + self.database_url = f"sqlite:///{database.as_posix()}" + DBOS.destroy() + DBOS( + config={ + "name": "vidxp", + "system_database_url": self.database_url, + "application_version": "test-v1", + "executor_id": "test-worker", + "use_listen_notify": False, + } + ) + self.application = Mock() + self.application.create_index.return_value = IndexResult( + media_id=MEDIA_ID, + generation_id=GENERATION_ID, + snapshot_id=SNAPSHOT_ID, + active_media_count=1, + record_counts={"scene": 1}, + ) + self.worker.application = self.application + DBOS.reset_system_database() + DBOS.listen_queues([QUEUE_NAMES[JobQueue.cpu]]) + DBOS.launch() + DBOS.register_queue( + QUEUE_NAMES[JobQueue.cpu], + worker_concurrency=1, + ) + self.backend = DBOSJobBackend( + system_database_url=self.database_url, + application_version="test-v1", + ) + self.jobs = JobService( + settings=VidXPSettings( + repository_root=self.directory.name, + runtime_backend="cpu", + workflow_poll_interval_seconds=0.01, + ), + backend=self.backend, + ) + + def tearDown(self): + self.backend.close() + DBOS.destroy() + self.directory.cleanup() + + def test_sqlite_queue_persists_progress_and_typed_result(self): + def create_index(command, *, execution): + execution.report( + { + "stage": "frames", + "message": "Indexed one frame.", + "current": 1, + "total": 1, + } + ) + return IndexResult( + media_id=command.media_id, + generation_id=GENERATION_ID, + snapshot_id=SNAPSHOT_ID, + active_media_count=1, + record_counts={"scene": 1}, + ) + + self.application.create_index.side_effect = create_index + submitted = self.jobs.submit_index( + CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + ) + ) + completed = self.jobs.wait(submitted.job_id) + + self.assertEqual(completed.state, JobState.succeeded) + self.assertEqual(completed.kind, JobKind.index) + self.assertEqual(completed.progress.stage, "complete") + self.assertEqual( + completed.result.result.generation_id, + GENERATION_ID, + ) + page = self.jobs.list(ListJobsCommand(page_size=1)) + self.assertEqual(page.items[0].job_id, submitted.job_id) + self.application.create_index.assert_called_once() + + def test_cancellation_reaches_the_running_application_operation(self): + started = Event() + stopped = Event() + + def create_index(_command, *, execution): + started.set() + try: + while True: + execution.checkpoint() + sleep(0.01) + finally: + stopped.set() + + self.application.create_index.side_effect = create_index + submitted = self.jobs.submit_index( + CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + ) + ) + + self.assertTrue(started.wait(timeout=5)) + cancelled = self.jobs.cancel(submitted.job_id) + + self.assertEqual(cancelled.state, JobState.cancelled) + self.assertTrue(stopped.wait(timeout=5)) + + def test_failed_job_retries_as_a_new_typed_execution(self): + attempts = 0 + + def create_index(command, *, execution): + nonlocal attempts + attempts += 1 + if attempts == 1: + raise ApplicationError( + "temporary_index_failure", + ErrorCategory.unavailable, + "The temporary indexing dependency is unavailable.", + retryable=True, + ) + return IndexResult( + media_id=command.media_id, + generation_id=GENERATION_ID, + snapshot_id=SNAPSHOT_ID, + active_media_count=1, + record_counts={"scene": 1}, + ) + + self.application.create_index.side_effect = create_index + submitted = self.jobs.submit_index( + CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + ) + ) + with self.assertRaises(ApplicationError): + self.jobs.wait(submitted.job_id) + + failed = self.jobs.get(submitted.job_id) + steps = self.backend.client.list_workflow_steps( + failed.job_id, + load_output=True, + ) + failed_step = next(step for step in steps if step["error"] is not None) + self.assertEqual( + str(failed_step["error"]), + "VidXP job execution failed.", + ) + self.assertNotIn( + "temporary indexing dependency", + repr(failed_step["error"]).lower(), + ) + retried = self.jobs.retry(failed.job_id) + completed = self.jobs.wait(retried.job_id) + + self.assertNotEqual(retried.job_id, failed.job_id) + self.assertEqual(completed.state, JobState.succeeded) + self.assertEqual(completed.queue, JobQueue.cpu) + self.assertEqual( + completed.result.result.generation_id, + GENERATION_ID, + ) + self.assertIsNone(completed.error) + self.assertEqual(attempts, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_frontend.py b/tests/test_frontend.py index c6a48c2..e185b57 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -27,6 +27,7 @@ def getvalue(self): class FrontendTests(unittest.TestCase): def tearDown(self): frontend._configured_service.cache_clear() + frontend._configured_jobs.cache_clear() def service(self, root: Path) -> Mock: service = Mock() diff --git a/tests/test_index_state.py b/tests/test_index_state.py deleted file mode 100644 index 3103c85..0000000 --- a/tests/test_index_state.py +++ /dev/null @@ -1,81 +0,0 @@ -import json -import tempfile -import unittest -from pathlib import Path - -from vidxp.index_state import ( - fingerprint_file, - read_index_status, - write_index_status, -) - - -class IndexStateTests(unittest.TestCase): - def test_progress_status_round_trip(self): - with tempfile.TemporaryDirectory() as directory: - write_index_status( - state="indexing", - stage="scene_indexing", - message="Indexing video scenes.", - current=5, - total=10, - index_directory=directory, - ) - - status = read_index_status(directory) - self.assertEqual(status["state"], "indexing") - self.assertEqual(status["current"], 5) - - write_index_status( - state="ready", - stage="complete", - message="Video indexing completed successfully.", - summary={"scene_frames": 10}, - index_directory=directory, - ) - self.assertEqual(read_index_status(directory)["state"], "ready") - - def test_unreadable_status_is_failed(self): - with tempfile.TemporaryDirectory() as directory: - status_path = Path(directory) / "index_status.json" - status_path.write_text("{not-json", encoding="utf-8") - - status = read_index_status(directory) - self.assertEqual(status["state"], "failed") - - def test_file_fingerprint_is_stable(self): - with tempfile.TemporaryDirectory() as directory: - video_path = Path(directory) / "sample.mp4" - video_path.write_bytes(b"vidxp-test-video") - - first = fingerprint_file(video_path) - second = fingerprint_file(video_path) - - self.assertEqual(first["sha256"], second["sha256"]) - self.assertEqual(first["size"], len(b"vidxp-test-video")) - self.assertEqual(first["path"], str(video_path.resolve())) - - def test_status_write_replaces_previous_payload(self): - with tempfile.TemporaryDirectory() as directory: - write_index_status( - state="failed", - stage="audio", - message="Failed.", - error="example", - index_directory=directory, - ) - write_index_status( - state="ready", - stage="complete", - message="Ready.", - index_directory=directory, - ) - - status_path = Path(directory) / "index_status.json" - payload = json.loads(status_path.read_text(encoding="utf-8")) - self.assertEqual(payload["state"], "ready") - self.assertNotIn("error", payload) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_index_worker.py b/tests/test_index_worker.py deleted file mode 100644 index 56d7ee2..0000000 --- a/tests/test_index_worker.py +++ /dev/null @@ -1,158 +0,0 @@ -import unittest -from pathlib import Path -from unittest.mock import Mock, patch - -from vidxp import index_worker -from vidxp.index_state import IndexingInProgressError -from vidxp.settings import VidXPSettings - - -MEDIA_ID = "123456781234423481234567890abcde" - - -class FakeProcess: - def __init__(self, **options): - self.options = options - self.alive = False - - def start(self): - self.alive = True - - def is_alive(self): - return self.alive - - -class FakeEvent: - def __init__(self): - self.set_called = False - - def set(self): - self.set_called = True - - def is_set(self): - return self.set_called - - -class FakeContext: - def __init__(self): - self.process = None - self.event = FakeEvent() - - def Event(self): - return self.event - - def Process(self, **options): - self.process = FakeProcess(**options) - return self.process - - -class IndexWorkerTests(unittest.TestCase): - def setUp(self): - index_worker._process = None - index_worker._cancel_event = None - self.service = Mock() - self.service.settings = VidXPSettings( - repository_root=Path("selected-repository"), - runtime_backend="cpu", - model_cache=Path("selected-model-cache"), - allow_model_downloads=False, - max_loaded_models=5, - max_concurrent_indexing=2, - max_concurrent_inference=4, - cpu_thread_budget=6, - minimum_available_memory_mb=2048, - external_capabilities=True, - capability_allowlist=("acme:ocr",), - ) - self.service.indexing_in_progress.return_value = False - - def tearDown(self): - index_worker._process = None - index_worker._cancel_event = None - - def test_worker_starts_indexing_in_a_separate_process(self): - context = FakeContext() - - with patch.object(index_worker, "get_context", return_value=context): - index_worker.start_indexing( - MEDIA_ID, - self.service, - modalities=("scene",), - ) - - self.assertTrue(context.process.is_alive()) - self.assertEqual(context.process.options["name"], "vidxp-indexer") - self.assertTrue(context.process.options["daemon"]) - self.assertEqual( - context.process.options["args"], - ( - MEDIA_ID, - context.event, - self.service.settings.model_dump(mode="python"), - ("scene",), - ), - ) - self.assertIs(context.process.options["target"], index_worker._run_indexing) - - def test_worker_process_reconstructs_the_selected_service(self): - service = Mock() - with patch.object( - index_worker, - "create_application", - return_value=service, - ) as create: - index_worker._run_indexing( - MEDIA_ID, - FakeEvent(), - self.service.settings.model_dump(mode="python"), - ("scene",), - ) - - settings = create.call_args.args[0] - self.assertEqual(settings, self.service.settings) - service.create_index.assert_called_once() - self.assertEqual( - service.create_index.call_args.args[0].media_id, - MEDIA_ID, - ) - self.assertEqual( - service.create_index.call_args.args[0].modalities, - ("scene",), - ) - - def test_worker_rejects_a_second_indexing_run(self): - context = FakeContext() - - with patch.object(index_worker, "get_context", return_value=context): - index_worker.start_indexing( - MEDIA_ID, - self.service, - modalities=("scene",), - ) - with self.assertRaises(IndexingInProgressError): - index_worker.start_indexing( - MEDIA_ID, - self.service, - modalities=("scene",), - ) - - def test_existing_service_run_remains_visible(self): - self.service.indexing_in_progress.return_value = True - - self.assertTrue(index_worker.indexing_in_progress(self.service)) - - def test_cancellation_requests_are_cooperative(self): - context = FakeContext() - with patch.object(index_worker, "get_context", return_value=context): - index_worker.start_indexing( - MEDIA_ID, - self.service, - modalities=("scene",), - ) - - self.assertTrue(index_worker.cancel_indexing()) - self.assertTrue(context.event.set_called) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_job_contracts.py b/tests/test_job_contracts.py new file mode 100644 index 0000000..0482bca --- /dev/null +++ b/tests/test_job_contracts.py @@ -0,0 +1,136 @@ +import json +import unittest +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import Mock + +from pydantic import ValidationError + +from vidxp.application_models import ( + ApplicationError, + CreateIndexCommand, + Job, + JobKind, + JobProgress, + JobQueue, + JobState, + InvalidRequestError, + ListJobsCommand, +) +from vidxp.job_service import JobService +from vidxp.ports import InvalidJobBackendRequestError +from vidxp.execution import ExecutionContext +from vidxp.settings import ApplicationMode, VidXPSettings +from vidxp.workflow_runtime import workflow_database_url +from vidxp.workflow_worker import _resolved_database_url + + +MEDIA_ID = "123456781234423481234567890abcde" +JOB_ID = "223456781234423481234567890abcde" + + +class JobContractTests(unittest.TestCase): + def test_progress_is_typed_and_position_is_bounded(self): + progress = JobProgress( + stage="frames", + message="Indexing frames.", + current=2, + total=3, + updated_at=datetime.now(timezone.utc), + ) + + self.assertEqual(progress.schema_version, 1) + with self.assertRaises(ValidationError): + JobProgress( + stage="frames", + message="Invalid.", + current=4, + total=3, + updated_at=datetime.now(timezone.utc), + ) + + def test_public_job_contract_has_no_path_or_storage_fields(self): + schema = json.dumps(Job.model_json_schema()) + self.assertNotIn("storage_key", schema) + self.assertNotIn('"path"', schema) + self.assertNotIn("model_cache", schema) + + def test_job_service_routes_model_work_without_reimplementing_it(self): + backend = Mock() + backend.submit.return_value = Job( + job_id=JOB_ID, + kind=JobKind.index, + state=JobState.queued, + queue=JobQueue.cpu, + ) + service = JobService( + settings=VidXPSettings( + repository_root=Path("repository"), + runtime_backend="cpu", + ), + backend=backend, + ) + + job = service.submit_index( + CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + ) + ) + + self.assertEqual(job.job_id, JOB_ID) + request = backend.submit.call_args.args[0] + self.assertEqual(request.kind, JobKind.index) + self.assertEqual(request.command.media_id, MEDIA_ID) + self.assertEqual( + backend.submit.call_args.kwargs["queue"], + JobQueue.cpu, + ) + + def test_job_list_cursor_is_bounded(self): + with self.assertRaises(ValidationError): + ListJobsCommand(cursor="x" * 513) + + def test_canonical_dbos_job_id_has_a_hex_operation_identity(self): + execution = ExecutionContext( + job_id="22345678-1234-4234-8123-4567890abcde" + ) + + self.assertEqual(execution.operation_id, JOB_ID) + + def test_server_workflow_database_must_be_postgres(self): + settings = VidXPSettings( + mode=ApplicationMode.server, + runtime_backend="cpu", + workflow_database_url="sqlite:///jobs.sqlite3", + ) + + with self.assertRaisesRegex(ValueError, "PostgreSQL"): + workflow_database_url(settings) + with self.assertRaisesRegex(ValueError, "PostgreSQL"): + _resolved_database_url(settings, "sqlite:///override.sqlite3") + + def test_job_backend_errors_are_normalized_for_every_adapter(self): + backend = Mock() + service = JobService( + settings=VidXPSettings( + repository_root=Path("repository"), + runtime_backend="cpu", + ), + backend=backend, + ) + backend.get.side_effect = InvalidJobBackendRequestError( + "invalid workflow id" + ) + + with self.assertRaises(InvalidRequestError): + service.get("invalid") + + backend.get.side_effect = RuntimeError("database unavailable") + with self.assertRaises(ApplicationError) as raised: + service.get(JOB_ID) + self.assertEqual(raised.exception.code, "job_backend_unavailable") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_local_snapshots.py b/tests/test_local_snapshots.py index 9d8e16b..70fccaa 100644 --- a/tests/test_local_snapshots.py +++ b/tests/test_local_snapshots.py @@ -197,6 +197,138 @@ def fail_pointer(path, payload): self.repository.generation_directory(a2.generation_id).is_dir() ) + def test_completed_job_replay_returns_committed_generation(self): + config, reference = self.generation("a", input_sha="a" * 64) + committed = self.repository.publish_generation(reference, config) + backend = LocalIndexBackend( + Mock(), + Mock(), + self.repository.layout, + ) + storage = MagicMock() + storage.__enter__.return_value = storage + build = IndexConfig.local( + video_id="a", + enabled_modalities=("scene",), + collection_names={"scene": "scene"}, + storage_directory=self.repository.indexes, + ) + + with ( + patch.object( + backend, + "_open_committed_storage", + return_value=storage, + ), + patch.object(backend, "_validate_snapshot_storage"), + patch( + "vidxp.infrastructure.local_index.index_video" + ) as index_video, + ): + result = backend.create( + Path("video.mp4"), + config=build, + progress=None, + cancellation=None, + source_name="video.mp4", + source_checksum="a" * 64, + operation_id=reference.generation_id, + ) + + self.assertEqual(result["generation_id"], reference.generation_id) + self.assertEqual(result["snapshot_id"], committed.snapshot_id) + index_video.assert_not_called() + + def test_completed_generation_replay_publishes_without_rebuilding(self): + config, reference = self.generation("a", input_sha="a" * 64) + backend = LocalIndexBackend( + Mock(), + Mock(), + self.repository.layout, + ) + build = IndexConfig.local( + video_id="a", + enabled_modalities=("scene",), + collection_names={"scene": "scene"}, + storage_directory=self.repository.indexes, + ) + + with patch( + "vidxp.infrastructure.local_index.index_video" + ) as index_video: + result = backend.create( + Path("video.mp4"), + config=build, + progress=None, + cancellation=None, + source_name="video.mp4", + source_checksum="a" * 64, + operation_id=reference.generation_id, + ) + + self.assertEqual(result["generation_id"], reference.generation_id) + self.assertEqual( + self.repository.read_active(required=True) + .generations["a"] + .generation_id, + reference.generation_id, + ) + index_video.assert_not_called() + + def test_replay_repairs_counts_after_crash_before_generation_validation(self): + config, reference = self.generation("a", input_sha="a" * 64) + manifest_path = ( + self.repository.generation_directory(reference.generation_id) + / MANIFEST_FILE + ) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest.pop("record_counts") + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + backend = LocalIndexBackend( + Mock(), + Mock(), + self.repository.layout, + ) + build = IndexConfig.local( + video_id="a", + enabled_modalities=("scene",), + collection_names={"scene": "scene"}, + storage_directory=self.repository.indexes, + ) + storage = MagicMock() + storage.__enter__.return_value = storage + storage.records.return_value = [ + { + "generation_id": reference.generation_id, + "video_id": "a", + "modality": "scene", + } + ] + + with ( + patch( + "vidxp.infrastructure.local_index.IndexStorage", + return_value=storage, + ), + patch( + "vidxp.infrastructure.local_index.index_video" + ) as index_video, + ): + result = backend.create( + Path("video.mp4"), + config=build, + progress=None, + cancellation=None, + source_name="video.mp4", + source_checksum="a" * 64, + operation_id=reference.generation_id, + ) + + self.assertEqual(result["record_counts"], {"scene": 1}) + repaired = json.loads(manifest_path.read_text(encoding="utf-8")) + self.assertEqual(repaired["record_counts"], {"scene": 1}) + index_video.assert_not_called() + def test_corrupt_or_missing_generation_fails_closed(self): config, reference = self.generation("a", input_sha="a" * 64) self.repository.publish_generation(reference, config) diff --git a/tests/test_local_worker.py b/tests/test_local_worker.py new file mode 100644 index 0000000..fe3f78e --- /dev/null +++ b/tests/test_local_worker.py @@ -0,0 +1,159 @@ +import os +import subprocess +import sys +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from time import sleep +from unittest.mock import Mock, patch + +from filelock import Timeout +from vidxp.application_models import ( + ApplicationError, + JobState, + PrepareModelsCommand, +) +from vidxp.infrastructure.dbos_jobs import DBOSJobBackend +from vidxp.infrastructure.local_worker import LocalWorkerSupervisor +from vidxp.job_service import JobService +from vidxp.settings import VidXPSettings +from vidxp.workflow_runtime import ( + LOCAL_WORKER_SETTINGS_ENV, + local_executor_id, + workflow_application_version, + workflow_database_url, +) + + +class LocalWorkerSupervisorTests(unittest.TestCase): + def test_worker_is_spawned_detached_without_job_state(self): + with TemporaryDirectory() as directory: + settings = VidXPSettings( + repository_root=Path(directory), + runtime_backend="cpu", + ) + supervisor = LocalWorkerSupervisor(settings) + with ( + patch( + "vidxp.infrastructure.local_worker.subprocess.Popen" + ) as popen, + patch.object(supervisor, "_wait_for_worker") as wait, + ): + supervisor.ensure_running() + + command = popen.call_args.args[0] + options = popen.call_args.kwargs + self.assertEqual(command[:3], [sys.executable, "-m", "vidxp.workflow_worker"]) + self.assertIn("--lock-file", command) + lock_file = command[command.index("--lock-file") + 1] + self.assertIn(workflow_application_version(), lock_file) + self.assertNotIn("--settings-json", command) + self.assertIs(options["stdin"], subprocess.DEVNULL) + self.assertIs(options["stdout"], subprocess.DEVNULL) + worker_settings = VidXPSettings.model_validate_json( + options["env"][LOCAL_WORKER_SETTINGS_ENV] + ) + self.assertEqual( + worker_settings.repository_root, + settings.repository_root, + ) + wait.assert_called_once() + if sys.platform == "win32": + self.assertTrue( + options["creationflags"] & subprocess.DETACHED_PROCESS + ) + else: + self.assertTrue(options["start_new_session"]) + + def test_startup_wait_finishes_when_worker_owns_lock(self): + with TemporaryDirectory() as directory: + ready_path = Path(directory) / "worker.ready" + ready_path.write_text("123", encoding="utf-8") + process = Mock() + process.poll.return_value = None + worker_lock = Mock() + worker_lock.acquire.side_effect = Timeout("worker.lock") + + LocalWorkerSupervisor._wait_for_worker( + process, + worker_lock, + ready_path, + ) + + worker_lock.acquire.assert_called_once_with(timeout=0) + + def test_fresh_database_runs_a_job_in_a_separate_worker_process(self): + with TemporaryDirectory(ignore_cleanup_errors=True) as directory: + settings = VidXPSettings( + repository_root=Path(directory), + runtime_backend="cpu", + allow_model_downloads=False, + workflow_poll_interval_seconds=0.01, + ) + settings.layout.ensure_local_directories() + database_url = workflow_database_url(settings) + ready_path = settings.layout.local_workflows / "test.ready" + lock_path = settings.layout.local_workflows / "test.lock" + environment = os.environ.copy() + environment[LOCAL_WORKER_SETTINGS_ENV] = ( + settings.model_dump_json() + ) + process = subprocess.Popen( + [ + sys.executable, + "-m", + "vidxp.workflow_worker", + "--database-url", + database_url, + "--executor-id", + local_executor_id(settings), + "--role", + "all", + "--lock-file", + str(lock_path), + "--ready-file", + str(ready_path), + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=environment, + ) + backend = None + try: + for _ in range(200): + if ready_path.is_file(): + break + if process.poll() is not None: + self.fail("The separate worker exited during startup.") + sleep(0.05) + else: + self.fail("The separate worker did not become ready.") + + backend = DBOSJobBackend( + system_database_url=database_url, + application_version=workflow_application_version(), + ) + jobs = JobService(settings=settings, backend=backend) + submitted = jobs.submit_prepare_models( + PrepareModelsCommand(modalities=()) + ) + with self.assertRaises(ApplicationError): + jobs.wait(submitted.job_id) + completed = jobs.get(submitted.job_id) + + self.assertEqual(completed.state, JobState.failed) + self.assertEqual(completed.error.code, "invalid_request") + finally: + if backend is not None: + backend.close() + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_media_services.py b/tests/test_media_services.py index 95f1671..b93609f 100644 --- a/tests/test_media_services.py +++ b/tests/test_media_services.py @@ -18,6 +18,7 @@ InvalidArtifactError, ) from vidxp.core.artifacts import ArtifactState +from vidxp.core.contracts import CancellationToken, IndexCancelledError from vidxp.core.media import ( MediaProbe, MediaRecord, @@ -26,7 +27,11 @@ StagedMedia, StoredMedia, ) -from vidxp.infrastructure.local_artifacts import LocalArtifactStore +from vidxp.execution import ExecutionContext +from vidxp.infrastructure.local_artifacts import ( + FFmpegSnippetRenderer, + LocalArtifactStore, +) from vidxp.infrastructure.local_media import InvalidMediaError from vidxp.media_service import MediaService from vidxp.ports import LocalFileResource @@ -34,6 +39,7 @@ MEDIA_ID = "123456781234423481234567890abcde" +JOB_ID = "223456781234423481234567890abcde" ARTIFACT_ID = "323456781234423481234567890abcde" @@ -269,7 +275,7 @@ def test_actor_overlay_uses_catalog_media_and_store_owned_destination(self): catalog.put_artifact.side_effect = lambda item: item renderer = Mock() renderer.render.side_effect = ( - lambda _source, destination, _cluster, _detections: + lambda _source, destination, _cluster, _detections, **_kwargs: destination.write_bytes(b"rendered") ) probe = Mock() @@ -314,6 +320,161 @@ def test_actor_overlay_uses_catalog_media_and_store_owned_destination(self): self.assertNotIn("path", result.model_dump(mode="json")) catalog.put_artifact.assert_called_once() + def test_progress_failure_after_catalog_commit_keeps_artifact_content(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.mp4" + source.write_bytes(b"source") + media = Mock() + media.content.return_value = LocalFileResource( + path=source, + filename="source.mp4", + mime_type="video/mp4", + byte_size=6, + etag="1" * 64, + ) + catalog = Mock() + catalog.get_artifact_by_request.return_value = None + catalog.put_artifact.side_effect = lambda item: item + renderer = Mock() + renderer.render.side_effect = ( + lambda _source, destination, _cluster, _detections, **_kwargs: + destination.write_bytes(b"rendered") + ) + probe = Mock() + probe.probe.return_value = MediaProbe( + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=1, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + ) + store = LocalArtifactStore(root / "artifacts") + service = ArtifactService( + catalog=catalog, + store=store, + media=media, + probe=probe, + actor_renderer=renderer, + snippet_renderer=Mock(), + max_snippet_duration_seconds=300, + ) + + def fail_final_progress(event): + if event["stage"] == "complete": + raise RuntimeError("progress unavailable") + + with ( + patch("vidxp.artifact_service.uuid4") as identifier, + self.assertRaisesRegex(RuntimeError, "progress unavailable"), + ): + identifier.return_value.hex = ARTIFACT_ID + service.create_actor_overlay( + media_id=MEDIA_ID, + generation_id="223456781234423481234567890abcde", + cluster_id="cluster", + detections=[], + profile=ActorOverlayProfile.default, + execution=ExecutionContext(progress=fail_final_progress), + ) + + committed = catalog.put_artifact.call_args.args[0] + self.assertTrue(store.resolve(committed.storage_key).is_file()) + + def test_durable_replay_recovers_object_published_before_catalog_commit(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.mp4" + source.write_bytes(b"source") + media = Mock() + media.content.return_value = LocalFileResource( + path=source, + filename="source.mp4", + mime_type="video/mp4", + byte_size=6, + etag="1" * 64, + ) + catalog = Mock() + catalog.get_artifact_by_request.return_value = None + catalog.put_artifact.side_effect = lambda item: item + renderer = Mock() + probe = Mock() + probe.probe.return_value = MediaProbe( + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=1, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + ) + store = LocalArtifactStore(root / "artifacts") + orphan = store.stage(JOB_ID, suffix=".mp4") + orphan.path.write_bytes(b"rendered-before-crash") + store.publish(orphan) + service = ArtifactService( + catalog=catalog, + store=store, + media=media, + probe=probe, + actor_renderer=renderer, + snippet_renderer=Mock(), + max_snippet_duration_seconds=300, + ) + + result = service.create_actor_overlay( + media_id=MEDIA_ID, + generation_id="423456781234423481234567890abcde", + cluster_id="cluster", + detections=[], + profile=ActorOverlayProfile.default, + job_id=JOB_ID, + execution=ExecutionContext(job_id=JOB_ID), + ) + + self.assertEqual(result.artifact_id, JOB_ID) + renderer.render.assert_not_called() + catalog.put_artifact.assert_called_once() + + def test_ffmpeg_snippet_renderer_terminates_on_cancellation(self): + cancellation = CancellationToken() + cancellation.cancel() + process = Mock() + process.poll.return_value = None + process.wait.return_value = 0 + renderer = FFmpegSnippetRenderer() + + with ( + patch( + "vidxp.infrastructure.local_artifacts.subprocess.Popen", + return_value=process, + ), + self.assertRaises(IndexCancelledError), + ): + renderer.render( + Path("source.mp4"), + Path("snippet.mp4"), + start_seconds=0, + end_seconds=1, + compatible_mp4=True, + cancellation=cancellation, + progress=None, + ) + + process.terminate.assert_called_once() + def test_actor_renderer_failure_leaves_no_staged_or_ready_artifact(self): with TemporaryDirectory() as directory: root = Path(directory) @@ -374,7 +535,7 @@ def test_invalid_rendered_media_is_not_published_or_cataloged(self): catalog.get_artifact_by_request.return_value = None renderer = Mock() renderer.render.side_effect = ( - lambda _source, destination, _cluster, _detections: + lambda _source, destination, _cluster, _detections, **_kwargs: destination.write_bytes(b"not-video") ) probe = Mock() diff --git a/tests/test_runner.py b/tests/test_runner.py index 67442a2..ea290cf 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -610,22 +610,12 @@ def test_local_index_hash_is_reused_by_run_index(self): "vidxp.core.runner.run_index", return_value=manifest, ) as run, - patch( - "vidxp.core.runner.write_index_status", - ) as write_status, ): index_video(str(path), config=config) hash_source.assert_called_once() indexed_source = run.call_args.args[0][0] self.assertEqual(indexed_source.checksum, checksum) - self.assertTrue( - all( - call.kwargs["index_directory"] - == config.index_directory - for call in write_status.call_args_list - ) - ) def test_manifest_and_timing_files_are_valid_json(self): with TemporaryDirectory() as directory: diff --git a/tests/test_video.py b/tests/test_video.py index ac35371..740ff15 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -105,6 +105,7 @@ def writer(path, codec, *_): CAP_PROP_FPS=1, CAP_PROP_FRAME_WIDTH=2, CAP_PROP_FRAME_HEIGHT=3, + CAP_PROP_FRAME_COUNT=5, FONT_HERSHEY_SIMPLEX=4, VideoCapture=lambda _: capture, VideoWriter=writer, diff --git a/uv.lock b/uv.lock index e2628f1..17f9e8d 100644 --- a/uv.lock +++ b/uv.lock @@ -512,6 +512,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/03/126e963fc3237a416f3085b8a663ebd8ab449ed6c37195b4e0b49597ba0c/ctranslate2-4.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dc9f1abef55579cc02cdc74b3a55df38491ec56d177d6e6039609d61d09ed30e", size = 19499597, upload-time = "2026-07-03T12:40:01.68Z" }, ] +[[package]] +name = "dbos" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "psycopg", extra = ["binary"] }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "typer-slim" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/b1/8a6c8ace5764e19850f2fd3f010251c3135daf1d682f43566c18dd0d432f/dbos-2.28.0.tar.gz", hash = "sha256:e738d62baf3953639d3e70988912b533ffd9de047d8b90d12755c10c03cac6fe", size = 592187, upload-time = "2026-07-21T15:24:08.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/d4/17f83581209153dff0bb7f6369c83d98d06303f8674746a7d47875add155/dbos-2.28.0-py3-none-any.whl", hash = "sha256:ba1bec5fcb2505b1bdd99251056cecf79669337699a81d5ef2b7598f701f31b6", size = 236036, upload-time = "2026-07-21T15:24:06.354Z" }, +] + [[package]] name = "durationpy" version = "0.10" @@ -704,6 +721,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/16/71eefcf68267bbf06a9b6bff57d0b222e49432326e85d74348b67694b8d4/greenlet-3.5.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e883de250e299654b1f1680f72a1a9f9ba62c9bd1bce84099c90657349a8dfbb", size = 294266, upload-time = "2026-07-22T11:37:56.142Z" }, + { url = "https://files.pythonhosted.org/packages/36/ea/a0b19adfc35d07e10acb626e9d22a3893b95f1309c42c4a20161dec16800/greenlet-3.5.4-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32802705c2c1ff25e8237b3bdacf2594fa02be80af8a66703eb7853ea7e68686", size = 613712, upload-time = "2026-07-22T12:26:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a121978b3337407d05a1ce5f79b4aa5998a43a9d8422f9726029b90b4471/greenlet-3.5.4-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57aa201b351f7c7c75627c60d29e4d5b97a07d37efeb62b903466fca42c097d7", size = 625582, upload-time = "2026-07-22T12:29:00.814Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4a/f301f1d85c69a86b90b5d581a73e8927bba4e79450037e6e2cbca05eb4fd/greenlet-3.5.4-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9667862a2e38ad379f11b845daeda22c8989186def44f06962c9c4c05e556da7", size = 633429, upload-time = "2026-07-22T12:43:42.073Z" }, + { url = "https://files.pythonhosted.org/packages/34/c2/080f16cf870e929e592f55767f01d6c98d2ee83bfdc36c3b892f2d0459ab/greenlet-3.5.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3fe76c2cac86b4f7a1e92865ac0a54384deb05c92986287c1a7110d9bd53071", size = 624663, upload-time = "2026-07-22T11:51:08.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/26884072b0eb343a4d5fee903341bfe5171b32b7f14553886e2b6349135a/greenlet-3.5.4-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:ae53534b5dec0f4c2ec26f898f538dc8ea1ca3ef2927d597a9439e40a09da937", size = 428238, upload-time = "2026-07-22T12:39:49.973Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bb/8f3ca88370b817369008faeceeee85970adc16c92a70a3e5fe5fea495a57/greenlet-3.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1e1a4a684b16c45ba324e60b32a4386a87722bcb815d2a149d2182f9b401ca72", size = 1585010, upload-time = "2026-07-22T12:25:02.539Z" }, + { url = "https://files.pythonhosted.org/packages/51/c2/45877154689709ebce9a0b83c2235e6ca0f31577889b02af308c8cc5f8fb/greenlet-3.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e849e6e139b9671adeac505f72fc05f4af7fd1921faef40295e214fc3b361b59", size = 1651283, upload-time = "2026-07-22T11:51:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/8711a75cb61d85246277c07ff6e1a6504621ba473d808c11ad225ffca43f/greenlet-3.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:dc418cf4c873357964d6624445ed09472e50def990c65dd4e76fc3ba8cd9cef6", size = 246434, upload-time = "2026-07-22T11:43:15.557Z" }, + { url = "https://files.pythonhosted.org/packages/00/62/e290b3bce433da8f0324ac02da0b128d683482229f1a8b789fa47818a4cd/greenlet-3.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:c38c902a0986eba1f6e7ba1ab39ad5195926abde90f3fe080e08212db62176da", size = 244990, upload-time = "2026-07-22T11:39:22.626Z" }, + { url = "https://files.pythonhosted.org/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4", size = 295909, upload-time = "2026-07-22T11:38:09.261Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17", size = 612011, upload-time = "2026-07-22T12:26:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a", size = 624299, upload-time = "2026-07-22T12:29:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f9/03e26be3487c5238e81f2b84714959a86ea8515a869828cf41f4fc54b34e/greenlet-3.5.4-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7c895310363f310361e0fe2072af85269d2a2a285cd04c0c59e79a5e3670dcf", size = 629603, upload-time = "2026-07-22T12:43:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f", size = 621541, upload-time = "2026-07-22T11:51:09.464Z" }, + { url = "https://files.pythonhosted.org/packages/57/6b/7c55ca72ef80d57c16c4a55210f82582622462dc4485799a30f4ec6f3372/greenlet-3.5.4-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:13b980043cb1b3134e81ea469da1250ddcc6bfe6d245bbaa59168d9cdc8f228f", size = 432554, upload-time = "2026-07-22T12:39:51.379Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d", size = 1581444, upload-time = "2026-07-22T12:25:03.818Z" }, + { url = "https://files.pythonhosted.org/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9", size = 1645842, upload-time = "2026-07-22T11:51:12.295Z" }, + { url = "https://files.pythonhosted.org/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3", size = 247169, upload-time = "2026-07-22T11:38:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e3/ef56864b4c35fcb3eb3b41b869f6cc46f4cd3f5e2c68e74acde8ac433951/greenlet-3.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0", size = 245565, upload-time = "2026-07-22T11:38:27.061Z" }, + { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/1b/80/fb4d4788bbc8e54761f1fc88533af9523a6e86299fa113d6e8a8503ed9fc/greenlet-3.5.4-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c", size = 632845, upload-time = "2026-07-22T12:43:45.19Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" }, + { url = "https://files.pythonhosted.org/packages/42/e3/6086fa578ebb72772722cdc4bcd628459814b42e0c2db1e3cbd6552b3271/greenlet-3.5.4-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861", size = 435053, upload-time = "2026-07-22T12:39:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" }, + { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500, upload-time = "2026-07-22T11:40:22.185Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" }, + { url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/9c/bf/250c2921c7b585dde12f5239e313ca2dcbc464d161ecca36e4e6ef21762d/greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c", size = 677968, upload-time = "2026-07-22T12:43:46.788Z" }, + { url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/18/40/10bfcf6513558d82f7b95dd728001c63bd388259fe27d3e30ae01f103430/greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c", size = 480643, upload-time = "2026-07-22T12:39:54.149Z" }, + { url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" }, + { url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" }, + { url = "https://files.pythonhosted.org/packages/05/75/625bcdd74d5e6b2dca1ecba3c3ac77bcf8a026c21a649a46cef23e421f97/greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f", size = 246892, upload-time = "2026-07-22T11:40:27.357Z" }, + { url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" }, + { url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" }, + { url = "https://files.pythonhosted.org/packages/ae/db/24a10af12bf8e639cec46c38b9ce1a282543ba42ff4fb0b31a970f1ab603/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8", size = 681690, upload-time = "2026-07-22T12:43:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" }, + { url = "https://files.pythonhosted.org/packages/f4/60/44a2eca7b9fd71ae0fae7ff184da1cd3169d176652b97aa1cffcbb0ef961/greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd", size = 510263, upload-time = "2026-07-22T12:39:55.678Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" }, + { url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" }, +] + [[package]] name = "grpcio" version = "1.83.0" @@ -1984,6 +2058,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" }, + { url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" }, + { url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" }, + { url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" }, + { url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" }, + { url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" }, + { url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, + { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, + { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, +] + [[package]] name = "pyarrow" version = "24.0.0" @@ -2955,6 +3098,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/69/a67c69e5f28fc9c99d6f7bd60bd50e91f2fed2423e3b30fb228fa00e51f3/sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", size = 2161838, upload-time = "2026-06-15T16:05:17.144Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/c8c22b8438bddc0a030157c6ec0f6ef97b3c38effa444bdab2a27af04090/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", size = 3319402, upload-time = "2026-06-15T16:10:40.002Z" }, + { url = "https://files.pythonhosted.org/packages/90/54/44012d32fd77d991256d2ff793ba3807c51d40cb27a85b4796224f6744df/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", size = 3319675, upload-time = "2026-06-15T16:12:25.658Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/de0592acaf5906cd7430874392d6f7e8b4a7c8437610953ee2d1501c0b44/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", size = 3270777, upload-time = "2026-06-15T16:10:42.125Z" }, + { url = "https://files.pythonhosted.org/packages/cb/14/a44c90739c780b362238e4ac3cb19dd0ca40d13e6ddc5daa112166ddab4f/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", size = 3293940, upload-time = "2026-06-15T16:12:27.156Z" }, + { url = "https://files.pythonhosted.org/packages/65/eb/fbd0f206a330e66f8c602a99c37c4e731f107faed62954b41b01f16dd9d9/sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", size = 2121183, upload-time = "2026-06-15T16:13:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fd/005bf80f3cf6e5c62b5dd68616280f51cd012c60840fa74781b3ed7b1623/sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", size = 2145796, upload-time = "2026-06-15T16:13:31.283Z" }, + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + [[package]] name = "srt" version = "3.5.3" @@ -3194,6 +3390,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, ] +[[package]] +name = "typer-slim" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/a7/e6aecc4b4eb59598829a3b5076a93aff291b4fdaa2ded25efc4e1f4d219c/typer_slim-0.24.0.tar.gz", hash = "sha256:f0ed36127183f52ae6ced2ecb2521789995992c521a46083bfcdbb652d22ad34", size = 4776, upload-time = "2026-02-16T22:08:51.2Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/24/5480c20380dfd18cf33d14784096dca45a24eae6102e91d49a718d3b6855/typer_slim-0.24.0-py3-none-any.whl", hash = "sha256:d5d7ee1ee2834d5020c7c616ed5e0d0f29b9a4b1dd283bdebae198ec09778d0e", size = 3394, upload-time = "2026-02-16T22:08:49.92Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -3299,6 +3507,7 @@ name = "vidxp" version = "0.2.1b1" source = { editable = "." } dependencies = [ + { name = "dbos" }, { name = "filelock" }, { name = "packaging" }, { name = "platformdirs" }, @@ -3390,6 +3599,7 @@ requires-dist = [ { name = "chromadb", marker = "extra == 'scene'", specifier = ">=1.5.9,<2" }, { name = "chromadb", marker = "extra == 'server'", specifier = ">=1.5.9,<2" }, { name = "chromadb", marker = "extra == 'storage'", specifier = ">=1.5.9,<2" }, + { name = "dbos", specifier = ">=2.28,<3" }, { name = "faster-whisper", marker = "extra == 'all'", specifier = ">=1.2.1,<2" }, { name = "faster-whisper", marker = "extra == 'dialogue'", specifier = ">=1.2.1,<2" }, { name = "faster-whisper", marker = "extra == 'local-worker'", specifier = ">=1.2.1,<2" }, From 910a0cd5268a6b7cc7b0b28f14e8f59c9602952d Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 05:46:32 +0500 Subject: [PATCH 07/46] feat: add authenticated http api --- .github/workflows/ci.yml | 2 + README.md | 65 +- changes/http-api.feature.md | 8 + changes/http-auth.security.md | 9 + changes/http-jobs.feature.md | 8 + changes/http-media.feature.md | 9 + pyproject.toml | 2 + src/vidxp/api.py | 117 ++++ src/vidxp/api_cli.py | 16 + src/vidxp/api_errors.py | 133 ++++ src/vidxp/api_middleware.py | 319 ++++++++++ src/vidxp/api_models.py | 26 + src/vidxp/api_routes/__init__.py | 23 + src/vidxp/api_routes/artifacts.py | 70 +++ src/vidxp/api_routes/dependencies.py | 185 ++++++ src/vidxp/api_routes/index.py | 26 + src/vidxp/api_routes/jobs.py | 235 +++++++ src/vidxp/api_routes/media.py | 132 ++++ src/vidxp/api_routes/platform.py | 51 ++ src/vidxp/application.py | 252 +++----- src/vidxp/application_boundary.py | 188 ++++++ src/vidxp/application_models.py | 36 ++ src/vidxp/artifact_service.py | 116 ++-- src/vidxp/authentication.py | 203 +++++++ src/vidxp/authorization.py | 38 ++ src/vidxp/capability_service.py | 41 ++ src/vidxp/composition.py | 68 ++- src/vidxp/control_plane.py | 166 +++++ src/vidxp/core/media.py | 26 + src/vidxp/infrastructure/dbos_jobs.py | 75 ++- src/vidxp/infrastructure/local_catalog.py | 89 ++- src/vidxp/infrastructure/local_files.py | 11 - src/vidxp/infrastructure/local_media.py | 2 +- src/vidxp/job_service.py | 43 +- src/vidxp/media_service.py | 106 +++- src/vidxp/ports.py | 26 +- src/vidxp/readiness_service.py | 88 +++ src/vidxp/requirements/server.txt | 6 +- src/vidxp/requirements/test.txt | 1 + src/vidxp/settings.py | 199 +++++- tests/test_api.py | 707 ++++++++++++++++++++++ tests/test_authentication.py | 224 +++++++ tests/test_dbos_jobs.py | 40 +- tests/test_media_services.py | 171 +++++- tests/test_packaging.py | 23 +- uv.lock | 199 +++++- 46 files changed, 4319 insertions(+), 261 deletions(-) create mode 100644 changes/http-api.feature.md create mode 100644 changes/http-auth.security.md create mode 100644 changes/http-jobs.feature.md create mode 100644 changes/http-media.feature.md create mode 100644 src/vidxp/api.py create mode 100644 src/vidxp/api_cli.py create mode 100644 src/vidxp/api_errors.py create mode 100644 src/vidxp/api_middleware.py create mode 100644 src/vidxp/api_models.py create mode 100644 src/vidxp/api_routes/__init__.py create mode 100644 src/vidxp/api_routes/artifacts.py create mode 100644 src/vidxp/api_routes/dependencies.py create mode 100644 src/vidxp/api_routes/index.py create mode 100644 src/vidxp/api_routes/jobs.py create mode 100644 src/vidxp/api_routes/media.py create mode 100644 src/vidxp/api_routes/platform.py create mode 100644 src/vidxp/application_boundary.py create mode 100644 src/vidxp/authentication.py create mode 100644 src/vidxp/authorization.py create mode 100644 src/vidxp/capability_service.py create mode 100644 src/vidxp/control_plane.py create mode 100644 src/vidxp/readiness_service.py create mode 100644 src/vidxp/requirements/test.txt create mode 100644 tests/test_api.py create mode 100644 tests/test_authentication.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58a7060..cd1caac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,8 @@ jobs: --extra all \ --extra frontend \ --extra benchmarks \ + --extra server \ + --extra test \ --no-dev - name: Lint diff --git a/README.md b/README.md index b438253..97e89c6 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@

  • From the command line
  • Through its browser interface
  • As an indexing and retrieval layer inside another application
  • -
  • Through API, MCP, and desktop interfaces as those roadmap phases land
  • +
  • Through the HTTP API, with MCP and desktop interfaces on the roadmap
  • @@ -74,7 +74,7 @@ Some ideas on how to use VidXP: | Dialogue search | Transcription, word alignment, semantic phrase indexing | Matching video time | | Scene search | Text search over sampled video frames | Matching frame and time | | Actor grouping | Within-video face detection and clustering | Clustered detections and highlighted output video | -| Interfaces | Typer CLI, Streamlit browser interface, Python API | Interactive or programmatic use | +| Interfaces | Typer CLI, Streamlit browser interface, Python and HTTP APIs | Interactive or programmatic use | | Index management | Saved progress, ready/failed state, cancellation, isolated programmatic runs | Traceable and reusable indexes | ## Quick start @@ -187,6 +187,67 @@ The interface can upload a video, start or cancel indexing, restore saved progress after a page reload, and search the capabilities available in the completed index. +## HTTP API + +Install the server profile and run the app factory: + +```bash +uv sync --frozen --extra server +uv run vidxp-api +``` + +The local default binds to `127.0.0.1:8000` without network authentication. +Its OpenAPI document and interactive reference are available at +`/openapi.json` and `/docs`. + +The API process is a model-free control plane. Under `/api/v1`, it exposes +bounded media import and delivery, capability metadata, index status, durable +job submission and control, artifact delivery, and authenticated readiness. +Indexing and other model work execute only through DBOS workers. Synchronous +search is intentionally not mounted in this phase because it would perform +model work in the API process; the remote query boundary is added with the +search/MCP phases. + +Large resumable uploads remain assigned to the deployment profile's `tusd` +service. The compatibility endpoint accepts at most 256 MiB. Media imports, +job creation, and job retries require an opaque `Idempotency-Key`; keys are +scoped to the repository, authenticated subject, and operation. + +```bash +curl -F "upload=@samplevideo.mp4" \ + -H "Idempotency-Key: import-samplevideo-2026-07-28" \ + http://127.0.0.1:8000/api/v1/media +curl -X POST http://127.0.0.1:8000/api/v1/jobs/index \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: 12345678-1234-4234-8123-123456789abc" \ + -d '{"media_id":"","modalities":["scene"]}' +``` + +Non-loopback and server-mode deployments must configure static bearer or OIDC +authentication. Static mode requires a token of at least 32 characters. OIDC +mode validates a fixed issuer, audience, asymmetric algorithm allowlist, JWKS +URL, token lifetime, subject, and configured baseline scopes through PyJWT. +Routes then enforce repository-wide `vidxp.read`, `vidxp.write`, or +`vidxp.admin` authorization. These are shared-repository permissions, not +per-media ownership rules. + +```text +VIDXP_MODE=server +VIDXP_RUNTIME_BACKEND=cpu +VIDXP_WORKFLOW_DATABASE_URL=postgresql://... +VIDXP_HTTP_BIND_HOST=0.0.0.0 +VIDXP_HTTP_AUTH_MODE=static +VIDXP_HTTP_STATIC_BEARER_TOKEN= +VIDXP_HTTP_TRUSTED_HOSTS=["api.example.com"] +``` + +Use `Authorization: Bearer ` for every route except `/health` and +`/ready`. Configure `VIDXP_HTTP_ALLOWED_ORIGINS` only for browser origins that +must call `/api/*`; that CORS policy does not apply to the reserved MCP +namespace. Authenticated profiles do not publish `/docs` or `/openapi.json`; +generate or expose API documentation separately when operating a remote +deployment. + ## Container Stable releases are available from GitHub Container Registry. Start the local diff --git a/changes/http-api.feature.md b/changes/http-api.feature.md new file mode 100644 index 0000000..bff28c5 --- /dev/null +++ b/changes/http-api.feature.md @@ -0,0 +1,8 @@ +Add a versioned thin HTTP API: + +- expose shared capability, index-status, media, artifact, readiness, and + durable-job contracts under `/api/v1` +- compose a model-free control-plane facade instead of constructing the model + runtime, index execution backend, or renderers in the API process +- publish a `vidxp-api` app-factory command without constructing services at + module import diff --git a/changes/http-auth.security.md b/changes/http-auth.security.md new file mode 100644 index 0000000..be745c4 --- /dev/null +++ b/changes/http-auth.security.md @@ -0,0 +1,9 @@ +Protect the HTTP control plane: + +- support loopback, constant-time static bearer, and cached + PyJWT/JWKS-backed OIDC profiles +- enforce repository-wide read, write, and admin scopes after authentication +- return one safe typed error envelope with correlation IDs, including host, + CORS, validation, and unexpected failures +- enforce trusted hosts, scoped CORS, streamed request-body limits, exact + issuer/audience checks, and JWK-bound asymmetric algorithms diff --git a/changes/http-jobs.feature.md b/changes/http-jobs.feature.md new file mode 100644 index 0000000..5240b00 --- /dev/null +++ b/changes/http-jobs.feature.md @@ -0,0 +1,8 @@ +Expose durable work through the HTTP control plane: + +- submit indexing, artifact generation, and model preparation only through the + existing DBOS job service +- derive workflow IDs from opaque repository-, subject-, and operation-scoped + idempotency keys +- make HTTP retries idempotent while preserving the original validated job + request diff --git a/changes/http-media.feature.md b/changes/http-media.feature.md new file mode 100644 index 0000000..77f0272 --- /dev/null +++ b/changes/http-media.feature.md @@ -0,0 +1,9 @@ +Add HTTP media transfer primitives: + +- stream small multipart uploads through the shared ingestion service with a + 256 MiB ceiling +- persist subject- and repository-scoped upload idempotency records +- deliver authorized media and artifacts with strong checksum ETags and + Starlette range responses +- keep large resumable uploads assigned to the separate `tusd` deployment + phase diff --git a/pyproject.toml b/pyproject.toml index 353be1b..b6a5164 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dependencies = [ [project.scripts] vidxp = "vidxp.cli:main" +vidxp-api = "vidxp.api_cli:main" vidxp-worker = "vidxp.workflow_worker:main" [project.urls] @@ -95,6 +96,7 @@ local-worker = { file = [ "src/vidxp/capabilities/actor/requirements.txt", ] } server = { file = ["src/vidxp/requirements/server.txt"] } +test = { file = ["src/vidxp/requirements/test.txt"] } frontend = { file = ["src/vidxp/requirements/frontend.txt"] } benchmarks = { file = ["src/vidxp/benchmarks/requirements.txt"] } diff --git a/src/vidxp/api.py b/src/vidxp/api.py new file mode 100644 index 0000000..78d67ac --- /dev/null +++ b/src/vidxp/api.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import Annotated, AsyncIterator + +from asgi_correlation_id import CorrelationIdMiddleware +from fastapi import Depends, FastAPI, Request, Response +from vidxp import __version__ +from vidxp.api_errors import install_exception_handlers +from vidxp.api_middleware import ( + ApiCORSMiddleware, + BearerAuthenticationMiddleware, + RequestBodyLimitMiddleware, + RequestBodyTooLarge, + TypedTrustedHostMiddleware, + request_too_large_response, +) +from vidxp.api_models import HealthResponse, ReadinessResponse +from vidxp.api_routes import create_api_router +from vidxp.api_routes.dependencies import context +from vidxp.composition import HttpApplicationContext, create_http_application +from vidxp.settings import VidXPSettings + + +def create_app( + settings: VidXPSettings | None = None, + *, + context: HttpApplicationContext | None = None, +) -> FastAPI: + active_context = context or create_http_application(settings) + active_settings = active_context.settings + active_settings.validate_http_server() + owns_context = context is None + + @asynccontextmanager + async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + try: + yield + finally: + if owns_context: + active_context.close() + + publish_docs = active_settings.http_auth_mode.value == "none" + app = FastAPI( + title="VidXP API", + version=__version__, + lifespan=lifespan, + docs_url="/docs" if publish_docs else None, + redoc_url="/redoc" if publish_docs else None, + openapi_url="/openapi.json" if publish_docs else None, + ) + app.state.vidxp = active_context + install_exception_handlers(app) + + @app.get( + "/health", + response_model=HealthResponse, + operation_id="health", + summary="Check process liveness", + ) + def health() -> HealthResponse: + return HealthResponse() + + @app.get( + "/ready", + response_model=ReadinessResponse, + responses={503: {"model": ReadinessResponse}}, + operation_id="ready", + summary="Check aggregate readiness", + ) + def ready( + response: Response, + service: Annotated[HttpApplicationContext, Depends(context)], + ) -> ReadinessResponse: + is_ready = service.readiness.ready() + if not is_ready: + response.status_code = 503 + return ReadinessResponse( + ready=is_ready, + status="ready" if is_ready else "not_ready", + ) + + app.include_router(create_api_router()) + app.add_exception_handler( + RequestBodyTooLarge, + _request_body_too_large_response, + ) + app.add_middleware( + RequestBodyLimitMiddleware, + json_limit=active_settings.http_max_json_body_bytes, + upload_limit=active_settings.http_max_small_upload_bytes, + ) + app.add_middleware( + BearerAuthenticationMiddleware, + authenticator=active_context.authenticator, + ) + if active_settings.http_allowed_origins: + app.add_middleware( + ApiCORSMiddleware, + allow_origins=active_settings.http_allowed_origins, + ) + app.add_middleware( + TypedTrustedHostMiddleware, + allowed_hosts=active_settings.http_trusted_hosts, + ) + app.add_middleware( + CorrelationIdMiddleware, + header_name="X-Request-ID", + ) + return app + + +async def _request_body_too_large_response( + _request: Request, + _exc: RequestBodyTooLarge, +) -> Response: + return request_too_large_response() diff --git a/src/vidxp/api_cli.py b/src/vidxp/api_cli.py new file mode 100644 index 0000000..d298b7d --- /dev/null +++ b/src/vidxp/api_cli.py @@ -0,0 +1,16 @@ +from __future__ import annotations + + +def main() -> None: + import uvicorn + + from vidxp.api import create_app + from vidxp.settings import VidXPSettings + + settings = VidXPSettings() + settings.validate_http_server() + uvicorn.run( + create_app(settings), + host=settings.http_bind_host, + port=settings.http_port, + ) diff --git a/src/vidxp/api_errors.py b/src/vidxp/api_errors.py new file mode 100644 index 0000000..887ff90 --- /dev/null +++ b/src/vidxp/api_errors.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import logging + +from asgi_correlation_id import correlation_id +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException + +from vidxp.api_models import ErrorEnvelope +from vidxp.application_models import ( + ApplicationError, + ErrorCategory, + ErrorDetail, + InvalidRequestError, +) + + +LOGGER = logging.getLogger("vidxp.api") + +_STATUS_BY_CATEGORY = { + ErrorCategory.validation: 422, + ErrorCategory.authentication: 401, + ErrorCategory.authorization: 403, + ErrorCategory.not_found: 404, + ErrorCategory.conflict: 409, + ErrorCategory.unavailable: 503, + ErrorCategory.resource_limit: 429, + ErrorCategory.cancelled: 409, + ErrorCategory.internal: 500, +} + + +def public_error_response( + detail: ErrorDetail, + *, + status_code: int | None = None, + headers: dict[str, str] | None = None, +) -> JSONResponse: + request_id = correlation_id.get() + if detail.correlation_id is None and request_id is not None: + detail = detail.model_copy(update={"correlation_id": request_id}) + active_headers = dict(headers or {}) + if detail.category == ErrorCategory.authentication: + active_headers.setdefault("WWW-Authenticate", "Bearer") + return JSONResponse( + status_code=status_code or _STATUS_BY_CATEGORY[detail.category], + content=ErrorEnvelope(error=detail).model_dump(mode="json"), + headers=active_headers, + ) + + +def _validation_error(request_error: RequestValidationError) -> ErrorDetail: + errors = [ + { + "type": item["type"], + "location": [str(part) for part in item["loc"]], + "message": item["msg"], + } + for item in request_error.errors() + ] + return InvalidRequestError(errors=errors).detail + + +def install_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(ApplicationError) + async def application_error_handler( + _request: Request, + exc: ApplicationError, + ) -> JSONResponse: + status = ( + 413 + if exc.code in {"media_too_large", "request_body_too_large"} + else None + ) + return public_error_response(exc.detail, status_code=status) + + @app.exception_handler(RequestValidationError) + async def request_validation_handler( + _request: Request, + exc: RequestValidationError, + ) -> JSONResponse: + return public_error_response(_validation_error(exc), status_code=422) + + @app.exception_handler(HTTPException) + async def http_error_handler( + _request: Request, + exc: HTTPException, + ) -> JSONResponse: + if exc.status_code == 404: + detail = ErrorDetail( + code="http_not_found", + category=ErrorCategory.not_found, + message="The requested HTTP resource was not found.", + ) + elif exc.status_code == 405: + detail = ErrorDetail( + code="http_method_not_allowed", + category=ErrorCategory.validation, + message="The HTTP method is not allowed for this resource.", + ) + else: + detail = ErrorDetail( + code="http_request_rejected", + category=ErrorCategory.validation, + message="The HTTP request was rejected.", + ) + return public_error_response( + detail, + status_code=exc.status_code, + headers=exc.headers, + ) + + @app.exception_handler(Exception) + async def unexpected_error_handler( + request: Request, + exc: Exception, + ) -> JSONResponse: + LOGGER.exception( + "Unhandled API error for %s %s", + request.method, + request.url.path, + exc_info=exc, + ) + return public_error_response( + ErrorDetail( + code="internal_error", + category=ErrorCategory.internal, + message="The request could not be completed.", + ), + status_code=500, + ) diff --git a/src/vidxp/api_middleware.py b/src/vidxp/api_middleware.py new file mode 100644 index 0000000..23f803a --- /dev/null +++ b/src/vidxp/api_middleware.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +from fastapi.security.utils import get_authorization_scheme_param +from starlette.concurrency import run_in_threadpool +from starlette.datastructures import Headers +from starlette.exceptions import HTTPException +from starlette.middleware.cors import CORSMiddleware, SAFELISTED_HEADERS +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from vidxp.api_errors import public_error_response +from vidxp.application_models import ( + ApplicationError, + ErrorCategory, + ErrorDetail, +) +from vidxp.authentication import Authenticator + + +PUBLIC_HTTP_PATHS = frozenset({"/health", "/ready"}) +UPLOAD_PATH = "/api/v1/media" + + +def _is_reserved_mcp_path(path: str) -> bool: + return ( + path == "/mcp" + or path.startswith("/mcp/") + or path == "/.well-known/oauth-protected-resource" + or path.startswith("/.well-known/oauth-protected-resource/") + ) + + +class RequestBodyTooLarge(HTTPException, OSError): + """Abort body parsing while preserving Starlette's file cleanup path.""" + + def __init__(self) -> None: + super().__init__(status_code=413) + + +class ApiCORSMiddleware: + """Apply Starlette CORS policy only to the REST namespace.""" + + def __init__( + self, + app: ASGIApp, + *, + allow_origins: tuple[str, ...], + ) -> None: + self.app = app + self.allow_origins = frozenset(allow_origins) + self.allow_methods = frozenset({"DELETE", "GET", "HEAD", "POST"}) + self.allow_headers = frozenset( + header.lower() + for header in ( + *SAFELISTED_HEADERS, + "Authorization", + "Idempotency-Key", + "Range", + "X-Request-ID", + ) + ) + self.cors = CORSMiddleware( + app, + allow_origins=list(allow_origins), + allow_credentials=False, + allow_methods=sorted(self.allow_methods), + allow_headers=[ + "Authorization", + "Content-Type", + "Idempotency-Key", + "Range", + "X-Request-ID", + ], + expose_headers=[ + "Accept-Ranges", + "Content-Disposition", + "Content-Length", + "Content-Range", + "ETag", + "Location", + "X-Request-ID", + ], + max_age=600, + ) + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + if ( + scope["type"] == "http" + and str(scope.get("path", "")).startswith("/api/") + ): + headers = Headers(scope=scope) + origin = headers.get("origin") + requested_method = headers.get("access-control-request-method") + if origin is not None and requested_method is not None: + requested_headers = { + value.strip().lower() + for value in headers.get( + "access-control-request-headers", + "", + ).split(",") + if value.strip() + } + if ( + "*" not in self.allow_origins + and origin not in self.allow_origins + ): + await public_error_response( + ErrorDetail( + code="cors_origin_forbidden", + category=ErrorCategory.authorization, + message="The browser origin is not allowed.", + ), + status_code=403, + )(scope, receive, send) + return + if ( + requested_method not in self.allow_methods + or not requested_headers.issubset(self.allow_headers) + ): + await public_error_response( + ErrorDetail( + code="cors_preflight_invalid", + category=ErrorCategory.validation, + message="The CORS preflight request is invalid.", + ), + status_code=400, + )(scope, receive, send) + return + await self.cors(scope, receive, send) + return + await self.app(scope, receive, send) + + +class TypedTrustedHostMiddleware: + def __init__( + self, + app: ASGIApp, + *, + allowed_hosts: tuple[str, ...], + ) -> None: + self.app = app + self.allowed_hosts = allowed_hosts + self.allow_any = "*" in allowed_hosts + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + if self.allow_any or scope["type"] != "http": + await self.app(scope, receive, send) + return + host = _host_name(Headers(scope=scope).get("host", "")) + if any( + host == pattern + or ( + pattern.startswith("*.") + and host.endswith(pattern[1:]) + ) + for pattern in self.allowed_hosts + ): + await self.app(scope, receive, send) + return + await public_error_response( + ErrorDetail( + code="host_not_allowed", + category=ErrorCategory.validation, + message="The HTTP Host header is not allowed.", + ), + status_code=400, + )(scope, receive, send) + + +def _host_name(header: str) -> str: + value = header.lower() + if value.startswith("["): + closing = value.find("]") + if closing < 0: + return "" + remainder = value[closing + 1 :] + if remainder and ( + not remainder.startswith(":") + or not remainder[1:].isdigit() + ): + return "" + return value[1:closing] + if value.count(":") == 1: + candidate, port = value.rsplit(":", 1) + if port.isdigit(): + return candidate + return value + + +class BearerAuthenticationMiddleware: + def __init__(self, app: ASGIApp, *, authenticator: Authenticator) -> None: + self.app = app + self.authenticator = authenticator + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + if ( + scope["type"] != "http" + or str(scope.get("path", "")) in PUBLIC_HTTP_PATHS + or _is_reserved_mcp_path(str(scope.get("path", ""))) + ): + await self.app(scope, receive, send) + return + authorization = Headers(scope=scope).get("authorization") + scheme, credentials = get_authorization_scheme_param(authorization) + token = credentials if scheme.lower() == "bearer" else None + try: + principal = await run_in_threadpool( + self.authenticator.authenticate, + token, + ) + except ApplicationError as exc: + await public_error_response(exc.detail)( + scope, + receive, + send, + ) + return + scope["vidxp.principal"] = principal + await self.app(scope, receive, send) + + +class RequestBodyLimitMiddleware: + def __init__( + self, + app: ASGIApp, + *, + json_limit: int, + upload_limit: int, + ) -> None: + self.app = app + self.json_limit = json_limit + self.upload_limit = upload_limit + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + if _is_reserved_mcp_path(str(scope.get("path", ""))): + await self.app(scope, receive, send) + return + limit = ( + self.upload_limit + 1024 * 1024 + if scope.get("path") == UPLOAD_PATH + else self.json_limit + ) + headers = Headers(scope=scope) + content_length = headers.get("content-length") + if content_length is not None: + try: + declared = int(content_length) + except ValueError: + await public_error_response( + ErrorDetail( + code="content_length_invalid", + category=ErrorCategory.validation, + message="The Content-Length header is invalid.", + ), + status_code=400, + )(scope, receive, send) + return + if declared < 0: + await public_error_response( + ErrorDetail( + code="content_length_invalid", + category=ErrorCategory.validation, + message="The Content-Length header is invalid.", + ), + status_code=400, + )(scope, receive, send) + return + if declared > limit: + await request_too_large_response()(scope, receive, send) + return + + received = 0 + + async def limited_receive() -> Message: + nonlocal received + message = await receive() + if message["type"] == "http.request": + received += len(message.get("body", b"")) + if received > limit: + raise RequestBodyTooLarge + return message + + try: + await self.app(scope, limited_receive, send) + except RequestBodyTooLarge: + await request_too_large_response()(scope, receive, send) + + +def request_too_large_response(): + return public_error_response( + ErrorDetail( + code="request_body_too_large", + category=ErrorCategory.resource_limit, + message="The HTTP request body exceeds the configured limit.", + ), + status_code=413, + ) diff --git a/src/vidxp/api_models.py b/src/vidxp/api_models.py new file mode 100644 index 0000000..48d8877 --- /dev/null +++ b/src/vidxp/api_models.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import Literal + +from vidxp.application_models import ( + ApplicationModel, + CapabilityInfo, + ErrorDetail, +) + + +class ErrorEnvelope(ApplicationModel): + error: ErrorDetail + + +class HealthResponse(ApplicationModel): + status: Literal["ok"] = "ok" + + +class ReadinessResponse(ApplicationModel): + ready: bool + status: Literal["ready", "not_ready"] + + +class CapabilityList(ApplicationModel): + items: tuple[CapabilityInfo, ...] = () diff --git a/src/vidxp/api_routes/__init__.py b/src/vidxp/api_routes/__init__.py new file mode 100644 index 0000000..caddbab --- /dev/null +++ b/src/vidxp/api_routes/__init__.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter + +from vidxp.api_models import ErrorEnvelope +from vidxp.api_routes import artifacts, index, jobs, media, platform + + +ERROR_RESPONSES = { + status: {"model": ErrorEnvelope} + for status in (400, 401, 403, 404, 409, 413, 422, 429, 500, 503) +} + + +def create_api_router() -> APIRouter: + router = APIRouter( + prefix="/api/v1", + responses=ERROR_RESPONSES, + ) + router.include_router(platform.router) + router.include_router(media.router) + router.include_router(index.router) + router.include_router(jobs.router) + router.include_router(artifacts.router) + return router diff --git a/src/vidxp/api_routes/artifacts.py b/src/vidxp/api_routes/artifacts.py new file mode 100644 index 0000000..bd36e48 --- /dev/null +++ b/src/vidxp/api_routes/artifacts.py @@ -0,0 +1,70 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Request, Response + +from vidxp.api_routes.dependencies import ( + context, + file_response, + read_principal, +) +from vidxp.application_models import Artifact +from vidxp.composition import HttpApplicationContext +from vidxp.core.identifiers import ArtifactId + + +router = APIRouter( + prefix="/artifacts", + tags=["artifacts"], + dependencies=[Depends(read_principal)], +) + + +@router.get( + "/{artifact_id}", + response_model=Artifact, + operation_id="getArtifact", + summary="Get artifact metadata", +) +def get_artifact( + artifact_id: ArtifactId, + service: Annotated[HttpApplicationContext, Depends(context)], +) -> Artifact: + return service.application.get_artifact(artifact_id) + + +def _content( + artifact_id: ArtifactId, + request: Request, + service: HttpApplicationContext, +) -> Response: + return file_response( + request, + service.application.open_artifact_content(artifact_id), + disposition="attachment", + ) + + +@router.get( + "/{artifact_id}/content", + response_model=None, + operation_id="getArtifactContent", + summary="Download artifact content", +) +def get_artifact_content( + artifact_id: ArtifactId, + request: Request, + service: Annotated[HttpApplicationContext, Depends(context)], +) -> Response: + return _content(artifact_id, request, service) + + +@router.head( + "/{artifact_id}/content", + include_in_schema=False, +) +def head_artifact_content( + artifact_id: ArtifactId, + request: Request, + service: Annotated[HttpApplicationContext, Depends(context)], +) -> Response: + return _content(artifact_id, request, service) diff --git a/src/vidxp/api_routes/dependencies.py b/src/vidxp/api_routes/dependencies.py new file mode 100644 index 0000000..1876024 --- /dev/null +++ b/src/vidxp/api_routes/dependencies.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import os +import tempfile +from hashlib import sha256 +from pathlib import Path +from typing import Annotated +from uuid import UUID + +from fastapi import Header, Request, Response, UploadFile +from fastapi.responses import FileResponse + +from vidxp.application_models import ( + ApplicationError, + ErrorCategory, + Job, + Principal, +) +from vidxp.composition import HttpApplicationContext +from vidxp.authorization import AuthorizationPolicy, RepositoryPermission +from vidxp.core.media import safe_media_suffix +from vidxp.ports import LocalFileResource + + +HttpIdempotencyKey = Annotated[ + str, + Header( + alias="Idempotency-Key", + min_length=8, + max_length=200, + pattern=r"^[\x21-\x7e]+$", + ), +] + + +def context(request: Request) -> HttpApplicationContext: + return request.app.state.vidxp + + +def principal( + request: Request, +) -> Principal: + active = request.scope.get("vidxp.principal") + if not isinstance(active, Principal): + raise ApplicationError( + "authentication_required", + ErrorCategory.authentication, + "Valid bearer authentication is required.", + ) + return active + + +def _authorized( + request: Request, + permission: RepositoryPermission, +) -> Principal: + active = principal(request) + service = context(request) + policy: AuthorizationPolicy = service.authorization + return policy.require(active, permission) + + +def read_principal( + request: Request, +) -> Principal: + return _authorized(request, RepositoryPermission.read) + + +def write_principal( + request: Request, +) -> Principal: + return _authorized(request, RepositoryPermission.write) + + +def accepted(response: Response, job: Job) -> Job: + response.headers["Location"] = f"/api/v1/jobs/{job.job_id}" + return job + + +def scoped_job_id( + service: HttpApplicationContext, + actor: Principal, + *, + operation: str, + idempotency_key: str, +) -> str: + """Derive a non-reversible DBOS workflow ID from an HTTP request key.""" + + digest = scoped_request_key( + service, + actor, + operation=operation, + idempotency_key=idempotency_key, + ) + value = bytearray(bytes.fromhex(digest)[:16]) + value[6] = (value[6] & 0x0F) | 0x40 + value[8] = (value[8] & 0x3F) | 0x80 + return UUID(bytes=bytes(value)).hex + + +def scoped_request_key( + service: HttpApplicationContext, + actor: Principal, + *, + operation: str, + idempotency_key: str, +) -> str: + material = "\0".join( + ( + "vidxp-http-request-v1", + str(service.settings.layout.root.resolve()), + actor.subject, + operation, + idempotency_key, + ) + ).encode() + return sha256(material).hexdigest() + + +def _etag_matches(request: Request, etag: str) -> bool: + supplied = request.headers.get("if-none-match") + if supplied is None: + return False + return any( + candidate == "*" or candidate.removeprefix("W/") == etag + for candidate in ( + item.strip() for item in supplied.split(",") + ) + ) + + +def file_response( + request: Request, + resource: LocalFileResource, + *, + disposition: str, +) -> Response: + etag = f'"{resource.etag}"' + common_headers = { + "ETag": etag, + "Cache-Control": "private, no-store", + "Accept-Ranges": "bytes", + } + if _etag_matches(request, etag): + return Response(status_code=304, headers=common_headers) + return FileResponse( + resource.path, + media_type=resource.mime_type, + filename=resource.filename, + headers=common_headers, + content_disposition_type=disposition, + ) + + +def copy_upload( + upload: UploadFile, + *, + maximum: int, +) -> Path: + suffix = safe_media_suffix(Path(upload.filename or "upload.bin")) + handle = tempfile.NamedTemporaryFile( + mode="w+b", + prefix="vidxp-upload-", + suffix=suffix, + delete=False, + ) + path = Path(handle.name) + total = 0 + try: + with handle: + while chunk := upload.file.read(1024 * 1024): + total += len(chunk) + if total > maximum: + raise ApplicationError( + "media_too_large", + ErrorCategory.resource_limit, + "The uploaded media exceeds the configured limit.", + ) + handle.write(chunk) + handle.flush() + os.fsync(handle.fileno()) + return path + except BaseException: + path.unlink(missing_ok=True) + raise diff --git a/src/vidxp/api_routes/index.py b/src/vidxp/api_routes/index.py new file mode 100644 index 0000000..84ee004 --- /dev/null +++ b/src/vidxp/api_routes/index.py @@ -0,0 +1,26 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends + +from vidxp.api_routes.dependencies import context, read_principal +from vidxp.application_models import IndexStatus +from vidxp.composition import HttpApplicationContext + + +router = APIRouter( + prefix="/index", + tags=["index"], + dependencies=[Depends(read_principal)], +) + + +@router.get( + "/status", + response_model=IndexStatus, + operation_id="getIndexStatus", + summary="Get index status", +) +def get_index_status( + service: Annotated[HttpApplicationContext, Depends(context)], +) -> IndexStatus: + return service.application.index_status() diff --git a/src/vidxp/api_routes/jobs.py b/src/vidxp/api_routes/jobs.py new file mode 100644 index 0000000..117e36e --- /dev/null +++ b/src/vidxp/api_routes/jobs.py @@ -0,0 +1,235 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Query, Response + +from vidxp.api_routes.dependencies import ( + accepted, + context, + HttpIdempotencyKey, + read_principal, + scoped_job_id, + write_principal, +) +from vidxp.application_models import ( + CreateActorOverlayCommand, + CreateIndexCommand, + CreateSnippetCommand, + Job, + JobPage, + JobResult, + ListJobsCommand, + Principal, + PrepareModelsCommand, +) +from vidxp.composition import HttpApplicationContext +from vidxp.core.identifiers import JobId + + +router = APIRouter(prefix="/jobs", tags=["jobs"]) + + +@router.post( + "/index", + response_model=Job, + status_code=202, + operation_id="startIndexing", + summary="Start indexing", + dependencies=[Depends(write_principal)], +) +def submit_index( + command: CreateIndexCommand, + response: Response, + service: Annotated[HttpApplicationContext, Depends(context)], + actor: Annotated[Principal, Depends(write_principal)], + idempotency_key: HttpIdempotencyKey, +) -> Job: + return accepted( + response, + service.jobs.submit_index( + command, + job_id=scoped_job_id( + service, + actor, + operation="index", + idempotency_key=idempotency_key, + ), + ), + ) + + +@router.post( + "/snippet", + response_model=Job, + status_code=202, + operation_id="createSnippet", + summary="Create a snippet", + dependencies=[Depends(write_principal)], +) +def submit_snippet( + command: CreateSnippetCommand, + response: Response, + service: Annotated[HttpApplicationContext, Depends(context)], + actor: Annotated[Principal, Depends(write_principal)], + idempotency_key: HttpIdempotencyKey, +) -> Job: + return accepted( + response, + service.jobs.submit_snippet( + command, + job_id=scoped_job_id( + service, + actor, + operation="snippet", + idempotency_key=idempotency_key, + ), + ), + ) + + +@router.post( + "/actor-overlay", + response_model=Job, + status_code=202, + operation_id="createActorOverlay", + summary="Create an actor overlay", + dependencies=[Depends(write_principal)], +) +def submit_actor_overlay( + command: CreateActorOverlayCommand, + response: Response, + service: Annotated[HttpApplicationContext, Depends(context)], + actor: Annotated[Principal, Depends(write_principal)], + idempotency_key: HttpIdempotencyKey, +) -> Job: + return accepted( + response, + service.jobs.submit_actor_overlay( + command, + job_id=scoped_job_id( + service, + actor, + operation="actor-overlay", + idempotency_key=idempotency_key, + ), + ), + ) + + +@router.post( + "/model-preparation", + response_model=Job, + status_code=202, + operation_id="prepareModels", + summary="Prepare models", + dependencies=[Depends(write_principal)], +) +def submit_model_preparation( + command: PrepareModelsCommand, + response: Response, + service: Annotated[HttpApplicationContext, Depends(context)], + actor: Annotated[Principal, Depends(write_principal)], + idempotency_key: HttpIdempotencyKey, +) -> Job: + return accepted( + response, + service.jobs.submit_prepare_models( + command, + job_id=scoped_job_id( + service, + actor, + operation="model-preparation", + idempotency_key=idempotency_key, + ), + ), + ) + + +@router.get( + "", + response_model=JobPage, + operation_id="listJobs", + summary="List jobs", + dependencies=[Depends(read_principal)], +) +def list_jobs( + service: Annotated[HttpApplicationContext, Depends(context)], + page_size: Annotated[int, Query(gt=0, le=100)] = 50, + cursor: Annotated[ + str | None, + Query(min_length=1, max_length=512), + ] = None, +) -> JobPage: + return service.jobs.list( + ListJobsCommand(page_size=page_size, cursor=cursor) + ) + + +@router.get( + "/{job_id}", + response_model=Job, + operation_id="getJob", + summary="Get a job", + dependencies=[Depends(read_principal)], +) +def get_job( + job_id: JobId, + service: Annotated[HttpApplicationContext, Depends(context)], +) -> Job: + return service.jobs.get(job_id) + + +@router.get( + "/{job_id}/result", + response_model=JobResult, + operation_id="getJobResult", + summary="Get a job result", + dependencies=[Depends(read_principal)], +) +def get_job_result( + job_id: JobId, + service: Annotated[HttpApplicationContext, Depends(context)], +) -> JobResult: + return service.jobs.result(job_id) + + +@router.post( + "/{job_id}/cancellation", + response_model=Job, + operation_id="cancelJob", + summary="Cancel a job", + dependencies=[Depends(write_principal)], +) +def cancel_job( + job_id: JobId, + service: Annotated[HttpApplicationContext, Depends(context)], +) -> Job: + return service.jobs.cancel(job_id) + + +@router.post( + "/{job_id}/retries", + response_model=Job, + status_code=202, + operation_id="retryJob", + summary="Retry a job", + dependencies=[Depends(write_principal)], +) +def retry_job( + job_id: JobId, + response: Response, + service: Annotated[HttpApplicationContext, Depends(context)], + actor: Annotated[Principal, Depends(write_principal)], + idempotency_key: HttpIdempotencyKey, +) -> Job: + return accepted( + response, + service.jobs.retry( + job_id, + retry_id=scoped_job_id( + service, + actor, + operation=f"retry:{job_id}", + idempotency_key=idempotency_key, + ), + ), + ) diff --git a/src/vidxp/api_routes/media.py b/src/vidxp/api_routes/media.py new file mode 100644 index 0000000..a53030c --- /dev/null +++ b/src/vidxp/api_routes/media.py @@ -0,0 +1,132 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile + +from vidxp.api_routes.dependencies import ( + context, + copy_upload, + file_response, + HttpIdempotencyKey, + read_principal, + scoped_request_key, + write_principal, +) +from vidxp.application_models import ( + ListMediaCommand, + MediaAsset, + MediaPage, + Principal, +) +from vidxp.composition import HttpApplicationContext +from vidxp.core.identifiers import MediaId + + +router = APIRouter(prefix="/media", tags=["media"]) + + +@router.post( + "", + response_model=MediaAsset, + status_code=201, + operation_id="importSmallMedia", + summary="Import a small media file", + dependencies=[Depends(write_principal)], +) +def import_media( + upload: Annotated[UploadFile, File()], + service: Annotated[HttpApplicationContext, Depends(context)], + actor: Annotated[Principal, Depends(write_principal)], + idempotency_key: HttpIdempotencyKey, +) -> MediaAsset: + staged = copy_upload( + upload, + maximum=service.settings.http_max_small_upload_bytes, + ) + try: + return service.application.import_uploaded_media( + staged_path=staged, + original_filename=upload.filename or "", + declared_mime_type=upload.content_type, + request_key=scoped_request_key( + service, + actor, + operation="media-import", + idempotency_key=idempotency_key, + ), + ) + finally: + staged.unlink(missing_ok=True) + + +@router.get( + "", + response_model=MediaPage, + operation_id="listMedia", + summary="List media", + dependencies=[Depends(read_principal)], +) +def list_media( + service: Annotated[HttpApplicationContext, Depends(context)], + page_size: Annotated[int, Query(gt=0, le=100)] = 50, + cursor: Annotated[ + str | None, + Query(min_length=1, max_length=512), + ] = None, +) -> MediaPage: + return service.application.list_media( + ListMediaCommand(page_size=page_size, cursor=cursor) + ) + + +@router.get( + "/{media_id}", + response_model=MediaAsset, + operation_id="getMedia", + summary="Get media metadata", + dependencies=[Depends(read_principal)], +) +def get_media( + media_id: MediaId, + service: Annotated[HttpApplicationContext, Depends(context)], +) -> MediaAsset: + return service.application.get_media(media_id) + + +def _content( + media_id: MediaId, + request: Request, + service: HttpApplicationContext, +) -> Response: + return file_response( + request, + service.application.open_media_content(media_id), + disposition="inline", + ) + + +@router.get( + "/{media_id}/content", + response_model=None, + operation_id="getMediaContent", + summary="Stream media content", + dependencies=[Depends(read_principal)], +) +def get_media_content( + media_id: MediaId, + request: Request, + service: Annotated[HttpApplicationContext, Depends(context)], +) -> Response: + return _content(media_id, request, service) + + +@router.head( + "/{media_id}/content", + include_in_schema=False, + dependencies=[Depends(read_principal)], +) +def head_media_content( + media_id: MediaId, + request: Request, + service: Annotated[HttpApplicationContext, Depends(context)], +) -> Response: + return _content(media_id, request, service) diff --git a/src/vidxp/api_routes/platform.py b/src/vidxp/api_routes/platform.py new file mode 100644 index 0000000..072fc61 --- /dev/null +++ b/src/vidxp/api_routes/platform.py @@ -0,0 +1,51 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends + +from vidxp.api_models import CapabilityList +from vidxp.api_routes.dependencies import context, read_principal +from vidxp.application_models import CapabilityInfo, RuntimeReadiness +from vidxp.composition import HttpApplicationContext + + +router = APIRouter( + tags=["platform"], + dependencies=[Depends(read_principal)], +) + + +@router.get( + "/runtime/readiness", + response_model=RuntimeReadiness, + operation_id="getRuntimeReadiness", + summary="Inspect runtime readiness", +) +def runtime_readiness( + service: Annotated[HttpApplicationContext, Depends(context)], +) -> RuntimeReadiness: + return service.readiness.details() + + +@router.get( + "/capabilities", + response_model=CapabilityList, + operation_id="listCapabilities", + summary="List capabilities", +) +def list_capabilities( + service: Annotated[HttpApplicationContext, Depends(context)], +) -> CapabilityList: + return CapabilityList(items=service.application.list_capabilities()) + + +@router.get( + "/capabilities/{name}", + response_model=CapabilityInfo, + operation_id="getCapability", + summary="Get capability metadata", +) +def get_capability( + name: str, + service: Annotated[HttpApplicationContext, Depends(context)], +) -> CapabilityInfo: + return service.application.get_capability(name) diff --git a/src/vidxp/application.py b/src/vidxp/application.py index 1bcb711..89a6c34 100644 --- a/src/vidxp/application.py +++ b/src/vidxp/application.py @@ -2,14 +2,16 @@ from contextlib import contextmanager from pathlib import Path -from functools import wraps -from typing import Any, Callable, Iterator, Mapping, cast +from typing import Any, Iterator, Mapping, cast -from pydantic import BaseModel, JsonValue, ValidationError +from pydantic import BaseModel +from vidxp.application_boundary import application_boundary from vidxp.application_models import ( ApplicationError, Artifact, + CapabilityInfo, + ComponentReadiness, CreateIndexCommand, CreateActorOverlayCommand, CreateSnippetCommand, @@ -28,13 +30,13 @@ PrepareModelsResult, RemoveIndexCommand, ResourceNotFoundError, + RuntimeReadiness, SearchCommand, ) from vidxp.capabilities.actor.schemas import ( ActorClustersOutput, ActorDetectionsOutput, ) -from vidxp.capabilities.actor.results import ActorClusterNotFoundError from vidxp.capabilities.contracts import ( CapabilityDependencyError, CapabilityContext, @@ -42,184 +44,31 @@ PreparationContext, ) from vidxp.capabilities.registry import CapabilityRegistry +from vidxp.capability_service import CapabilityService from vidxp.capabilities.schemas import SearchResult from vidxp.core.contracts import ( - IndexCancelledError, IndexConfig, - IndexSchemaError, -) -from vidxp.core.artifacts import ( - ArtifactIntegrityError, - ArtifactRenderError, - ArtifactRendererUnavailableError, ) from vidxp.execution import ExecutionContext, execution_context from vidxp.index_state import ( INDEX_STATUS_SCHEMA, - IndexingInProgressError, - IndexNotReadyError, ) -from vidxp.ports import IndexBackend, ModelRuntimePort, ResourceLimitError +from vidxp.ports import IndexBackend, ModelRuntimePort from vidxp.ports import LocalFileResource from vidxp.model_contracts import ModelArtifactUnavailableError from vidxp.repository_layout import RepositoryLayout from vidxp.settings import VidXPSettings from vidxp.artifact_service import ( - ArtifactRequestError, ArtifactService, - ArtifactUnavailableError, - InvalidArtifactError, ) from vidxp.core.media import ( - InvalidMediaError, - MediaImportLimitError, - MediaProbeUnavailableError, - MediaStoreIntegrityError, - MediaUnavailableError, + QuarantinedMedia, ) from vidxp.media_service import ( - MediaImportNotAllowedError, MediaService, ) -def _validation_details( - exc: ValidationError, -) -> list[dict[str, JsonValue]]: - return [ - { - "type": item["type"], - "location": [str(part) for part in item["loc"]], - "message": item["msg"], - } - for item in exc.errors( - include_url=False, - include_context=False, - include_input=False, - ) - ] - - -def application_boundary(handler: Callable) -> Callable: - """Translate expected domain failures once for every transport.""" - - @wraps(handler) - def wrapped(*args: Any, **kwargs: Any) -> Any: - try: - return handler(*args, **kwargs) - except ApplicationError: - raise - except ActorClusterNotFoundError as exc: - raise ApplicationError( - "actor_cluster_not_found", - ErrorCategory.not_found, - "The requested actor cluster was not found.", - ) from exc - except IndexingInProgressError as exc: - raise ApplicationError( - "indexing_in_progress", - ErrorCategory.conflict, - "An indexing operation is already in progress.", - retryable=True, - ) from exc - except IndexNotReadyError as exc: - raise ApplicationError( - "index_not_ready", - ErrorCategory.conflict, - "The index is not ready.", - retryable=True, - ) from exc - except IndexSchemaError as exc: - raise ApplicationError( - "index_schema_incompatible", - ErrorCategory.conflict, - "The index schema is incompatible with this version.", - ) from exc - except IndexCancelledError as exc: - raise ApplicationError( - "operation_cancelled", - ErrorCategory.cancelled, - "The operation was cancelled.", - ) from exc - except ResourceLimitError as exc: - raise ApplicationError( - "resource_limit", - ErrorCategory.resource_limit, - "The worker does not currently have enough host capacity.", - retryable=True, - ) from exc - except ValidationError as exc: - raise InvalidRequestError( - errors=_validation_details(exc), - ) from exc - except CapabilityRequestError as exc: - raise InvalidRequestError() from exc - except InvalidMediaError as exc: - raise ApplicationError( - "media_invalid", - ErrorCategory.validation, - "The selected file is not a valid supported video.", - ) from exc - except MediaImportLimitError as exc: - raise ApplicationError( - "media_too_large", - ErrorCategory.resource_limit, - "The selected media exceeds the configured import limit.", - ) from exc - except MediaProbeUnavailableError as exc: - raise ApplicationError( - "media_probe_unavailable", - ErrorCategory.unavailable, - "The media probe dependency is unavailable.", - retryable=True, - ) from exc - except MediaImportNotAllowedError as exc: - raise ApplicationError( - "media_import_forbidden", - ErrorCategory.authorization, - "The selected local media cannot be imported.", - ) from exc - except MediaStoreIntegrityError as exc: - raise ApplicationError( - "media_integrity_failed", - ErrorCategory.conflict, - "The media changed or failed an integrity check.", - ) from exc - except MediaUnavailableError as exc: - raise ResourceNotFoundError("media") from exc - except ArtifactUnavailableError as exc: - raise ResourceNotFoundError("artifact") from exc - except ArtifactIntegrityError as exc: - raise ApplicationError( - "artifact_integrity_failed", - ErrorCategory.conflict, - "The artifact failed an integrity check.", - ) from exc - except ArtifactRendererUnavailableError as exc: - raise ApplicationError( - "artifact_renderer_unavailable", - ErrorCategory.unavailable, - "The configured artifact renderer is unavailable.", - retryable=True, - ) from exc - except ArtifactRenderError as exc: - raise ApplicationError( - "artifact_render_failed", - ErrorCategory.internal, - "The requested artifact could not be rendered.", - ) from exc - except ArtifactRequestError as exc: - raise InvalidRequestError() from exc - except InvalidArtifactError as exc: - raise ApplicationError( - "artifact_render_invalid", - ErrorCategory.internal, - "The generated artifact failed media validation.", - ) from exc - - return wrapped - - class VidXPApplication: """The transport-neutral command and query boundary.""" @@ -237,6 +86,7 @@ def __init__( self.settings = settings self.layout = layout self.registry = registry + self.capabilities = CapabilityService(registry) self.runtime = runtime self.index_backend = index_backend self.media_service = media_service @@ -303,6 +153,88 @@ def import_media(self, command: ImportMediaCommand) -> MediaAsset: self.layout.ensure_local_directories() return self.media_service.import_local(command) + @application_boundary + def import_uploaded_media( + self, + *, + staged_path: Path, + original_filename: str, + declared_mime_type: str | None, + ) -> MediaAsset: + self.layout.ensure_local_directories() + return self.media_service.import_quarantined( + QuarantinedMedia( + path=staged_path, + original_filename=original_filename, + declared_mime_type=declared_mime_type, + ) + ) + + @application_boundary + def list_capabilities(self) -> tuple[CapabilityInfo, ...]: + return self.capabilities.list() + + @application_boundary + def get_capability(self, name: str) -> CapabilityInfo: + try: + return self.capabilities.get(name) + except CapabilityRequestError as exc: + raise ResourceNotFoundError("capability") from exc + + @application_boundary + def control_plane_readiness(self) -> tuple[ComponentReadiness, ...]: + components: list[ComponentReadiness] = [] + try: + self.media_service.list(ListMediaCommand(page_size=1)) + except Exception: + components.append( + ComponentReadiness( + name="catalog", + ready=False, + message="The media catalog is unavailable.", + ) + ) + else: + components.append( + ComponentReadiness( + name="catalog", + ready=True, + message="The media catalog is available.", + ) + ) + try: + self.index_status() + except ApplicationError: + components.append( + ComponentReadiness( + name="index", + ready=False, + message="The index catalog is unavailable.", + ) + ) + else: + components.append( + ComponentReadiness( + name="index", + ready=True, + message="The index catalog is available.", + ) + ) + return tuple(components) + + @application_boundary + def runtime_readiness(self) -> RuntimeReadiness: + components = self.control_plane_readiness() + dependencies = self.check_dependencies( + DependencyCheckCommand(modalities=self.registry.names()) + ) + return RuntimeReadiness( + ready=all(component.ready for component in components), + runtime=self.runtime.backends, + components=components, + dependencies=dependencies, + ) + @application_boundary def get_media(self, media_id: str) -> MediaAsset: return self.media_service.get(media_id) diff --git a/src/vidxp/application_boundary.py b/src/vidxp/application_boundary.py new file mode 100644 index 0000000..5fdca35 --- /dev/null +++ b/src/vidxp/application_boundary.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +from functools import wraps +from typing import Any, Callable + +from pydantic import JsonValue, ValidationError + +from vidxp.application_models import ( + ApplicationError, + ErrorCategory, + InvalidRequestError, + ResourceNotFoundError, +) +from vidxp.artifact_service import ( + ArtifactRequestError, + ArtifactUnavailableError, + InvalidArtifactError, +) +from vidxp.capabilities.actor.results import ActorClusterNotFoundError +from vidxp.capabilities.contracts import CapabilityRequestError +from vidxp.core.artifacts import ( + ArtifactIntegrityError, + ArtifactRenderError, + ArtifactRendererUnavailableError, +) +from vidxp.core.contracts import ( + IndexCancelledError, + IndexSchemaError, +) +from vidxp.core.media import ( + InvalidMediaError, + MediaImportLimitError, + MediaProbeUnavailableError, + MediaStoreIntegrityError, + MediaUnavailableError, +) +from vidxp.index_state import ( + IndexingInProgressError, + IndexNotReadyError, +) +from vidxp.media_service import ( + MediaIdempotencyConflictError, + MediaImportNotAllowedError, +) +from vidxp.ports import ResourceLimitError + + +def _validation_details( + exc: ValidationError, +) -> list[dict[str, JsonValue]]: + return [ + { + "type": item["type"], + "location": [str(part) for part in item["loc"]], + "message": item["msg"], + } + for item in exc.errors( + include_url=False, + include_context=False, + include_input=False, + ) + ] + + +def application_boundary(handler: Callable) -> Callable: + """Translate expected application failures once for every transport.""" + + @wraps(handler) + def wrapped(*args: Any, **kwargs: Any) -> Any: + try: + return handler(*args, **kwargs) + except ApplicationError: + raise + except ActorClusterNotFoundError as exc: + raise ApplicationError( + "actor_cluster_not_found", + ErrorCategory.not_found, + "The requested actor cluster was not found.", + ) from exc + except IndexingInProgressError as exc: + raise ApplicationError( + "indexing_in_progress", + ErrorCategory.conflict, + "An indexing operation is already in progress.", + retryable=True, + ) from exc + except IndexNotReadyError as exc: + raise ApplicationError( + "index_not_ready", + ErrorCategory.conflict, + "The index is not ready.", + retryable=True, + ) from exc + except IndexSchemaError as exc: + raise ApplicationError( + "index_schema_incompatible", + ErrorCategory.conflict, + "The index schema is incompatible with this version.", + ) from exc + except IndexCancelledError as exc: + raise ApplicationError( + "operation_cancelled", + ErrorCategory.cancelled, + "The operation was cancelled.", + ) from exc + except ResourceLimitError as exc: + raise ApplicationError( + "resource_limit", + ErrorCategory.resource_limit, + "The worker does not currently have enough host capacity.", + retryable=True, + ) from exc + except ValidationError as exc: + raise InvalidRequestError( + errors=_validation_details(exc), + ) from exc + except CapabilityRequestError as exc: + raise InvalidRequestError() from exc + except InvalidMediaError as exc: + raise ApplicationError( + "media_invalid", + ErrorCategory.validation, + "The selected file is not a valid supported video.", + ) from exc + except MediaImportLimitError as exc: + raise ApplicationError( + "media_too_large", + ErrorCategory.resource_limit, + "The selected media exceeds the configured import limit.", + ) from exc + except MediaProbeUnavailableError as exc: + raise ApplicationError( + "media_probe_unavailable", + ErrorCategory.unavailable, + "The media probe dependency is unavailable.", + retryable=True, + ) from exc + except MediaImportNotAllowedError as exc: + raise ApplicationError( + "media_import_forbidden", + ErrorCategory.authorization, + "The selected local media cannot be imported.", + ) from exc + except MediaIdempotencyConflictError as exc: + raise ApplicationError( + "idempotency_key_reused", + ErrorCategory.validation, + "The idempotency key was already used for other media.", + ) from exc + except MediaStoreIntegrityError as exc: + raise ApplicationError( + "media_integrity_failed", + ErrorCategory.conflict, + "The media changed or failed an integrity check.", + ) from exc + except MediaUnavailableError as exc: + raise ResourceNotFoundError("media") from exc + except ArtifactUnavailableError as exc: + raise ResourceNotFoundError("artifact") from exc + except ArtifactIntegrityError as exc: + raise ApplicationError( + "artifact_integrity_failed", + ErrorCategory.conflict, + "The artifact failed an integrity check.", + ) from exc + except ArtifactRendererUnavailableError as exc: + raise ApplicationError( + "artifact_renderer_unavailable", + ErrorCategory.unavailable, + "The configured artifact renderer is unavailable.", + retryable=True, + ) from exc + except ArtifactRenderError as exc: + raise ApplicationError( + "artifact_render_failed", + ErrorCategory.internal, + "The requested artifact could not be rendered.", + ) from exc + except ArtifactRequestError as exc: + raise InvalidRequestError() from exc + except InvalidArtifactError as exc: + raise ApplicationError( + "artifact_render_invalid", + ErrorCategory.internal, + "The generated artifact failed media validation.", + ) from exc + + return wrapped diff --git a/src/vidxp/application_models.py b/src/vidxp/application_models.py index 41bec5e..06b1a46 100644 --- a/src/vidxp/application_models.py +++ b/src/vidxp/application_models.py @@ -65,6 +65,12 @@ class RuntimeProfile(ApplicationModel): cuda_available: bool = False +class Principal(ApplicationModel): + subject: str = Field(min_length=1, max_length=255) + client_id: str | None = Field(default=None, min_length=1, max_length=255) + scopes: frozenset[str] = Field(default_factory=frozenset) + + class ErrorCategory(StrEnum): validation = "validation" authentication = "authentication" @@ -209,6 +215,36 @@ class CapabilityDependencyCheck(ApplicationModel): error: str | None = None +class CapabilityOperationInfo(ApplicationModel): + name: str = Field(min_length=1) + requires_index: bool + input_schema: dict[str, JsonValue] + output_schema: dict[str, JsonValue] + + +class CapabilityInfo(ApplicationModel): + name: str = Field(min_length=1) + description: str = Field(min_length=1) + install_extra: str = Field(min_length=1) + supports_indexing: bool + prepares_models: bool + operations: tuple[CapabilityOperationInfo, ...] = () + provenance: CapabilityProvenance | None = None + + +class ComponentReadiness(ApplicationModel): + name: str = Field(min_length=1) + ready: bool + message: str = Field(min_length=1) + + +class RuntimeReadiness(ApplicationModel): + ready: bool + runtime: RuntimeProfile | None + components: tuple[ComponentReadiness, ...] + dependencies: DependencyCheckResult | None + + class ImportMediaCommand(ApplicationModel): """Local-adapter command; remote ingestion uses the upload workflow.""" diff --git a/src/vidxp/artifact_service.py b/src/vidxp/artifact_service.py index 65f5aa1..b326a81 100644 --- a/src/vidxp/artifact_service.py +++ b/src/vidxp/artifact_service.py @@ -73,7 +73,69 @@ def _request_key(payload: dict) -> str: return hashlib.sha256(encoded).hexdigest() -class ArtifactService: +class ArtifactQueryService: + """Read-only artifact boundary shared by worker and control-plane profiles.""" + + def __init__( + self, + *, + catalog: ArtifactCatalogPort, + store: ArtifactStorePort, + ) -> None: + self.catalog = catalog + self.store = store + + def get(self, artifact_id: str) -> Artifact: + return artifact_result(self.require_record(artifact_id)) + + def require_record(self, artifact_id: str) -> ArtifactRecord: + record = self.catalog.get_artifact(artifact_id) + if record is None: + raise ArtifactUnavailableError("The artifact is unavailable.") + self._require_ready(record) + return record + + @staticmethod + def _require_ready(record: ArtifactRecord) -> None: + if ( + record.state != ArtifactState.ready + or ( + record.expires_at is not None + and record.expires_at <= utc_now() + ) + ): + raise ArtifactUnavailableError("The artifact is unavailable.") + + def content(self, artifact_id: str) -> LocalFileResource: + record = self.require_record(artifact_id) + path = self._verified_content(record) + suffix = ".mp4" if record.mime_type == "video/mp4" else ".mkv" + return LocalFileResource( + path=path, + filename=f"{record.kind.value}-{record.artifact_id}{suffix}", + mime_type=record.mime_type, + byte_size=record.byte_size, + etag=record.sha256, + ) + + def _verified_content(self, record: ArtifactRecord) -> Path: + try: + return self.store.verify( + record.storage_key, + sha256=record.sha256, + byte_size=record.byte_size, + ) + except (FileNotFoundError, PermissionError) as exc: + raise ArtifactUnavailableError( + "The artifact content is unavailable." + ) from exc + except RuntimeError as exc: + raise ArtifactIntegrityError( + "The artifact failed its integrity check." + ) from exc + + +class ArtifactService(ArtifactQueryService): def __init__( self, *, @@ -85,8 +147,7 @@ def __init__( snippet_renderer: SnippetRendererPort, max_snippet_duration_seconds: float, ) -> None: - self.catalog = catalog - self.store = store + super().__init__(catalog=catalog, store=store) self.media = media self.probe = probe self.actor_renderer = actor_renderer @@ -315,52 +376,3 @@ def _delete_quietly(self, storage_key: str) -> None: self.store.delete(storage_key) except OSError: pass - - def get(self, artifact_id: str) -> Artifact: - return artifact_result(self.require_record(artifact_id)) - - def require_record(self, artifact_id: str) -> ArtifactRecord: - record = self.catalog.get_artifact(artifact_id) - if record is None: - raise ArtifactUnavailableError("The artifact is unavailable.") - self._require_ready(record) - return record - - @staticmethod - def _require_ready(record: ArtifactRecord) -> None: - if ( - record.state != ArtifactState.ready - or ( - record.expires_at is not None - and record.expires_at <= utc_now() - ) - ): - raise ArtifactUnavailableError("The artifact is unavailable.") - - def content(self, artifact_id: str) -> LocalFileResource: - record = self.require_record(artifact_id) - path = self._verified_content(record) - suffix = ".mp4" if record.mime_type == "video/mp4" else ".mkv" - return LocalFileResource( - path=path, - filename=f"{record.kind.value}-{record.artifact_id}{suffix}", - mime_type=record.mime_type, - byte_size=record.byte_size, - etag=record.sha256, - ) - - def _verified_content(self, record: ArtifactRecord) -> Path: - try: - return self.store.verify( - record.storage_key, - sha256=record.sha256, - byte_size=record.byte_size, - ) - except (FileNotFoundError, PermissionError) as exc: - raise ArtifactUnavailableError( - "The artifact content is unavailable." - ) from exc - except RuntimeError as exc: - raise ArtifactIntegrityError( - "The artifact failed its integrity check." - ) from exc diff --git a/src/vidxp/authentication.py b/src/vidxp/authentication.py new file mode 100644 index 0000000..ac1afdb --- /dev/null +++ b/src/vidxp/authentication.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +from hmac import compare_digest +from typing import Protocol + +from vidxp.application_models import ( + ApplicationError, + ComponentReadiness, + ErrorCategory, + Principal, +) +from vidxp.settings import HttpAuthMode, VidXPSettings + + +class Authenticator(Protocol): + def authenticate(self, token: str | None) -> Principal: ... + + def readiness(self) -> ComponentReadiness: ... + + +def _ready() -> ComponentReadiness: + return ComponentReadiness( + name="authentication", + ready=True, + message="The HTTP authentication profile is ready.", + ) + + +def _authentication_error() -> ApplicationError: + return ApplicationError( + "authentication_required", + ErrorCategory.authentication, + "Valid bearer authentication is required.", + ) + + +def _authorization_error() -> ApplicationError: + return ApplicationError( + "insufficient_scope", + ErrorCategory.authorization, + "The authenticated principal lacks a required scope.", + ) + + +class LocalAuthenticator: + def authenticate(self, token: str | None) -> Principal: + del token + return Principal(subject="local", scopes=frozenset({"*"})) + + def readiness(self) -> ComponentReadiness: + return _ready() + + +class StaticBearerAuthenticator: + def __init__(self, expected_token: str) -> None: + self._expected_token = expected_token + + def authenticate(self, token: str | None) -> Principal: + if token is None or not compare_digest(token, self._expected_token): + raise _authentication_error() + return Principal( + subject="static", + client_id="static", + scopes=frozenset({"*"}), + ) + + def readiness(self) -> ComponentReadiness: + return _ready() + + +class OIDCBearerAuthenticator: + def __init__( + self, + *, + issuer: str, + audience: str, + jwks_url: str, + algorithms: tuple[str, ...], + required_scopes: tuple[str, ...], + ) -> None: + import jwt + + self._jwt = jwt + self._issuer = issuer + self._audience = audience + self._algorithms = algorithms + self._required_scopes = frozenset(required_scopes) + self._jwks = jwt.PyJWKClient( + jwks_url, + cache_keys=True, + cache_jwk_set=True, + lifespan=300, + timeout=5, + ) + + def authenticate(self, token: str | None) -> Principal: + if token is None: + raise _authentication_error() + try: + signing_key = self._jwks.get_signing_key_from_jwt(token) + claims = self._jwt.decode( + token, + signing_key, + algorithms=list(self._algorithms), + audience=self._audience, + issuer=self._issuer, + options={ + "require": ["exp", "iss", "aud", "sub"], + "verify_signature": True, + "verify_exp": True, + "verify_nbf": True, + "verify_iss": True, + "verify_aud": True, + }, + ) + except self._jwt.PyJWTError as exc: + raise _authentication_error() from exc + subject = claims.get("sub") + if not isinstance(subject, str) or not subject: + raise _authentication_error() + scopes = _token_scopes(claims) + if not self._required_scopes.issubset(scopes): + raise _authorization_error() + client_id = claims.get("client_id") + return Principal( + subject=subject, + client_id=client_id if isinstance(client_id, str) else None, + scopes=scopes, + ) + + def readiness(self) -> ComponentReadiness: + try: + key_set = self._jwks.get_jwk_set() + usable = any( + self._usable_signing_key(key) + for key in key_set.keys + ) + if not usable: + raise ValueError( + "The OIDC JWKS contains no usable signing keys." + ) + except Exception: + return ComponentReadiness( + name="authentication", + ready=False, + message="The OIDC signing-key service is unavailable.", + ) + return _ready() + + def _usable_signing_key(self, key) -> bool: + if ( + key.public_key_use not in {None, "sig"} + or key.algorithm_name not in self._algorithms + ): + return False + data = getattr(key, "_jwk_data", {}) + key_operations = ( + data.get("key_ops") if isinstance(data, dict) else None + ) + return ( + key_operations is None + or ( + isinstance(key_operations, list) + and "verify" in key_operations + ) + ) + + +def _token_scopes(claims: dict) -> frozenset[str]: + scope = claims.get("scope") + if isinstance(scope, str): + values = scope.split() + else: + scp = claims.get("scp") + if isinstance(scp, str): + values = scp.split() + elif isinstance(scp, list) and all( + isinstance(value, str) for value in scp + ): + values = scp + else: + values = [] + return frozenset(value for value in values if value) + + +def create_authenticator(settings: VidXPSettings) -> Authenticator: + if settings.http_auth_mode == HttpAuthMode.none: + return LocalAuthenticator() + if settings.http_auth_mode == HttpAuthMode.static: + assert settings.http_static_bearer_token is not None + return StaticBearerAuthenticator( + settings.http_static_bearer_token.get_secret_value() + ) + assert settings.http_oidc_issuer is not None + assert settings.http_oidc_audience is not None + assert settings.http_oidc_jwks_url is not None + return OIDCBearerAuthenticator( + issuer=settings.http_oidc_issuer, + audience=settings.http_oidc_audience, + jwks_url=settings.http_oidc_jwks_url, + algorithms=settings.http_oidc_algorithms, + required_scopes=settings.http_required_scopes, + ) diff --git a/src/vidxp/authorization.py b/src/vidxp/authorization.py new file mode 100644 index 0000000..f597c26 --- /dev/null +++ b/src/vidxp/authorization.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from enum import StrEnum + +from vidxp.application_models import ( + ApplicationError, + ErrorCategory, + Principal, +) + + +class RepositoryPermission(StrEnum): + read = "vidxp.read" + write = "vidxp.write" + admin = "vidxp.admin" + + +class AuthorizationPolicy: + """Repository-wide authorization shared by HTTP and future MCP adapters.""" + + def require( + self, + principal: Principal, + permission: RepositoryPermission, + ) -> Principal: + scopes = principal.scopes + if ( + "*" in scopes + or RepositoryPermission.admin.value in scopes + or permission.value in scopes + ): + return principal + raise ApplicationError( + "insufficient_scope", + ErrorCategory.authorization, + "The authenticated principal lacks the required repository scope.", + details={"required_scope": permission.value}, + ) diff --git a/src/vidxp/capability_service.py b/src/vidxp/capability_service.py new file mode 100644 index 0000000..a066f8b --- /dev/null +++ b/src/vidxp/capability_service.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from vidxp.application_models import ( + CapabilityInfo, + CapabilityOperationInfo, +) +from vidxp.capabilities.registry import CapabilityRegistry + + +class CapabilityService: + """Transport-neutral capability metadata projection.""" + + def __init__(self, registry: CapabilityRegistry) -> None: + self.registry = registry + + def list(self) -> tuple[CapabilityInfo, ...]: + return tuple(self._info(name) for name in self.registry.names()) + + def get(self, name: str) -> CapabilityInfo: + return self._info(name) + + def _info(self, name: str) -> CapabilityInfo: + definition = self.registry.get(name) + return CapabilityInfo( + name=definition.name, + description=definition.description, + install_extra=definition.extra, + supports_indexing=definition.collection_name is not None, + prepares_models=definition.prepares_models, + operations=tuple( + CapabilityOperationInfo( + name=operation_name, + requires_index=operation.requires_index, + input_schema=operation.input_model.model_json_schema(), + output_schema=operation.output_model.model_json_schema(), + ) + for operation_name, operation in definition.operations.items() + if operation.public + ), + provenance=self.registry.provenance(name), + ) diff --git a/src/vidxp/composition.py b/src/vidxp/composition.py index 9c25729..851f5ef 100644 --- a/src/vidxp/composition.py +++ b/src/vidxp/composition.py @@ -7,7 +7,12 @@ from vidxp.application import VidXPApplication from vidxp.application_models import ApplicationError, ErrorCategory +from vidxp.artifact_service import ArtifactQueryService, ArtifactService +from vidxp.authentication import Authenticator, create_authenticator +from vidxp.authorization import AuthorizationPolicy +from vidxp.capability_service import CapabilityService from vidxp.capabilities.registry import create_capability_registry +from vidxp.control_plane import ControlPlaneApplication from vidxp.infrastructure.local_index import ( LOCAL_INDEX_RUNTIME_CHECKS, LocalIndexBackend, @@ -19,11 +24,12 @@ ) from vidxp.infrastructure.local_catalog import LocalCatalog from vidxp.infrastructure.local_media import FFprobeMediaProbe, LocalMediaStore +from vidxp.infrastructure.local_snapshots import LocalSnapshotRepository from vidxp.infrastructure.dbos_jobs import DBOSJobBackend from vidxp.infrastructure.local_worker import LocalWorkerSupervisor -from vidxp.artifact_service import ArtifactService from vidxp.job_service import JobService from vidxp.media_service import MediaService +from vidxp.readiness_service import ReadinessService from vidxp.runtime import ModelRuntime, RuntimeBackendUnavailableError from vidxp.repositories import ( RepositoryConfig, @@ -46,6 +52,19 @@ class LocalApplicationContext: repository: RepositoryConfig +@dataclass(frozen=True) +class HttpApplicationContext: + application: ControlPlaneApplication + jobs: JobService + readiness: ReadinessService + authenticator: Authenticator + authorization: AuthorizationPolicy + settings: VidXPSettings + + def close(self) -> None: + self.jobs.close() + + def settings_for_repository(repository: RepositoryConfig) -> VidXPSettings: values = {"repository_root": repository.index_directory} if repository.device is not None: @@ -124,6 +143,53 @@ def create_job_service(settings: VidXPSettings) -> JobService: ) +def create_http_application( + settings: VidXPSettings | None = None, +) -> HttpApplicationContext: + active_settings = settings or VidXPSettings() + active_settings.validate_http_server() + active_settings.layout.ensure_local_directories() + registry = create_capability_registry( + external=active_settings.external_capabilities, + allowlist=active_settings.capability_allowlist, + platform_runtime_checks=LOCAL_INDEX_RUNTIME_CHECKS, + ) + catalog = LocalCatalog(active_settings.layout.catalog) + media = MediaService( + settings=active_settings, + catalog=catalog, + store=LocalMediaStore( + active_settings.layout.media, + max_bytes=active_settings.max_local_import_bytes, + ), + probe=FFprobeMediaProbe(active_settings.ffprobe_executable), + ) + application = ControlPlaneApplication( + layout=active_settings.layout, + capabilities=CapabilityService(registry), + media=media, + artifacts=ArtifactQueryService( + catalog=catalog, + store=LocalArtifactStore(active_settings.layout.artifacts), + ), + index=LocalSnapshotRepository(active_settings.layout.indexes), + ) + jobs = create_job_service(active_settings) + authenticator = create_authenticator(active_settings) + return HttpApplicationContext( + application=application, + jobs=jobs, + readiness=ReadinessService( + application=application, + jobs=jobs, + authenticator=authenticator, + ), + authenticator=authenticator, + authorization=AuthorizationPolicy(), + settings=active_settings, + ) + + def create_local_application( *, registry_path: str | Path | None = None, diff --git a/src/vidxp/control_plane.py b/src/vidxp/control_plane.py new file mode 100644 index 0000000..c9d39d3 --- /dev/null +++ b/src/vidxp/control_plane.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Protocol + +from vidxp.application_boundary import application_boundary +from vidxp.application_models import ( + Artifact, + CapabilityInfo, + ComponentReadiness, + IndexStatus, + InvalidRequestError, + ListMediaCommand, + MediaAsset, + MediaPage, + ResourceNotFoundError, + RuntimeReadiness, +) +from vidxp.artifact_service import ArtifactQueryService +from vidxp.capabilities.contracts import CapabilityRequestError +from vidxp.capability_service import CapabilityService +from vidxp.core.media import QuarantinedMedia +from vidxp.index_state import INDEX_STATUS_SCHEMA +from vidxp.media_service import MediaService +from vidxp.ports import LocalFileResource +from vidxp.repository_layout import RepositoryLayout + + +class IndexStatusPort(Protocol): + def status(self) -> dict | None: ... + + +class ControlPlaneApplication: + """Model-free application facade used by HTTP and future remote adapters.""" + + def __init__( + self, + *, + layout: RepositoryLayout, + capabilities: CapabilityService, + media: MediaService, + artifacts: ArtifactQueryService, + index: IndexStatusPort, + ) -> None: + self.layout = layout + self.capabilities = capabilities + self.media = media + self.artifacts = artifacts + self.index = index + + @application_boundary + def import_uploaded_media( + self, + *, + staged_path: Path, + original_filename: str, + declared_mime_type: str | None, + request_key: str | None = None, + ) -> MediaAsset: + self.layout.ensure_local_directories() + return self.media.import_quarantined( + QuarantinedMedia( + path=staged_path, + original_filename=original_filename, + declared_mime_type=declared_mime_type, + ), + request_key=request_key, + ) + + @application_boundary + def list_capabilities(self) -> tuple[CapabilityInfo, ...]: + return self.capabilities.list() + + @application_boundary + def get_capability(self, name: str) -> CapabilityInfo: + try: + return self.capabilities.get(name) + except CapabilityRequestError as exc: + raise ResourceNotFoundError("capability") from exc + + @application_boundary + def index_status(self) -> IndexStatus: + stored = self.index.status() + payload = ( + dict(stored) + if stored is not None + else { + "schema_version": INDEX_STATUS_SCHEMA, + "state": "missing", + "stage": "status", + "message": "No local video index was found.", + } + ) + return IndexStatus.model_validate(payload) + + @application_boundary + def get_media(self, media_id: str) -> MediaAsset: + return self.media.get(media_id) + + @application_boundary + def list_media(self, command: ListMediaCommand) -> MediaPage: + try: + return self.media.list(command) + except ValueError as exc: + raise InvalidRequestError() from exc + + @application_boundary + def open_media_content(self, media_id: str) -> LocalFileResource: + return self.media.content(media_id) + + @application_boundary + def get_artifact(self, artifact_id: str) -> Artifact: + return self.artifacts.get(artifact_id) + + @application_boundary + def open_artifact_content(self, artifact_id: str) -> LocalFileResource: + return self.artifacts.content(artifact_id) + + def control_plane_readiness(self) -> tuple[ComponentReadiness, ...]: + components: list[ComponentReadiness] = [] + try: + self.media.list(ListMediaCommand(page_size=1)) + except Exception: + components.append( + ComponentReadiness( + name="catalog", + ready=False, + message="The media catalog is unavailable.", + ) + ) + else: + components.append( + ComponentReadiness( + name="catalog", + ready=True, + message="The media catalog is available.", + ) + ) + try: + self.index_status() + except Exception: + components.append( + ComponentReadiness( + name="index", + ready=False, + message="The index catalog is unavailable.", + ) + ) + else: + components.append( + ComponentReadiness( + name="index", + ready=True, + message="The index catalog is available.", + ) + ) + return tuple(components) + + def runtime_readiness(self) -> RuntimeReadiness: + components = self.control_plane_readiness() + return RuntimeReadiness( + ready=all(component.ready for component in components), + runtime=None, + components=components, + dependencies=None, + ) diff --git a/src/vidxp/core/media.py b/src/vidxp/core/media.py index 274381b..95b6baf 100644 --- a/src/vidxp/core/media.py +++ b/src/vidxp/core/media.py @@ -109,6 +109,19 @@ def _validate_storage_key(cls, value: str) -> str: return validate_storage_key(value) +class QuarantinedMedia(_MediaModel): + """Adapter-staged media accepted by the shared ingestion boundary.""" + + path: Path + original_filename: str = Field(min_length=1, max_length=255) + declared_mime_type: MimeType | None = None + + @field_validator("original_filename") + @classmethod + def _filename_only(cls, value: str) -> str: + return validate_display_filename(value) + + class MediaRecord(_MediaModel): """Authoritative internal catalog entry; storage_key is never projected.""" @@ -162,3 +175,16 @@ def validate_display_filename(value: str) -> str: "original_filename must be a basename without control characters" ) return value + + +def safe_media_suffix(path: Path) -> str: + """Return a bounded suffix suitable for temporary media staging.""" + + suffix = path.suffix.lower() + if ( + len(suffix) <= 10 + and suffix.startswith(".") + and suffix[1:].isalnum() + ): + return suffix + return ".bin" diff --git a/src/vidxp/infrastructure/dbos_jobs.py b/src/vidxp/infrastructure/dbos_jobs.py index 4b6cdab..35f2c82 100644 --- a/src/vidxp/infrastructure/dbos_jobs.py +++ b/src/vidxp/infrastructure/dbos_jobs.py @@ -30,7 +30,10 @@ PrepareModelsResult, ) from vidxp.core.identifiers import JobId -from vidxp.ports import InvalidJobBackendRequestError +from vidxp.ports import ( + InvalidJobBackendRequestError, + JobIdempotencyConflictError, +) from vidxp.workflow_contracts import ( ERROR_EVENT, PROGRESS_EVENT, @@ -44,6 +47,7 @@ _JOB_ID_ADAPTER = TypeAdapter(JobId) +_JOB_REQUEST_ADAPTER = TypeAdapter(JobRequest) _LIST_SCOPE = "vidxp:jobs" _STATUS_STATES = { "ENQUEUED": JobState.queued, @@ -137,6 +141,10 @@ def submit( ) -> Job: self._prepare_access() identifier = _job_id(job_id or uuid4().hex) + if job_id is not None: + existing = self._status(identifier, load_input=True) + if existing is not None: + return self._idempotent_job(existing, request) options: EnqueueOptions = { "workflow_name": WORKFLOW_NAMES[request.kind], "queue_name": QUEUE_NAMES[queue], @@ -147,25 +155,46 @@ def submit( "instance_name": WORKFLOW_INSTANCE_NAME, } self.client.enqueue(options, request.model_dump(mode="json")) - job = self.get(identifier) - if job is None: + status = self._status(identifier, load_input=True) + if status is None: raise RuntimeError("DBOS did not persist the submitted workflow.") - return job + return self._idempotent_job(status, request) def get(self, job_id: str) -> Job | None: self._prepare_access() identifier = _job_id(job_id) + status = self._status(identifier) + if status is None or status.name not in WORKFLOW_KINDS: + return None + return self._job(status) + + def _status( + self, + identifier: str, + *, + load_input: bool = False, + ) -> Any | None: statuses = self.client.list_workflows( workflow_ids=[identifier], limit=1, - load_input=False, + load_input=load_input, load_output=True, ) - if not statuses: - return None - status = statuses[0] - if status.name not in WORKFLOW_KINDS: - return None + return statuses[0] if statuses else None + + def _idempotent_job(self, status: Any, request: JobRequest) -> Job: + try: + stored = _JOB_REQUEST_ADAPTER.validate_python( + status.input["args"][0] + ) + except (KeyError, IndexError, TypeError, ValidationError) as exc: + raise JobIdempotencyConflictError from exc + if ( + status.name not in WORKFLOW_KINDS + or WORKFLOW_KINDS[status.name] != request.kind + or stored != request + ): + raise JobIdempotencyConflictError return self._job(status) def list(self, command: ListJobsCommand) -> JobPage: @@ -197,11 +226,31 @@ def cancel(self, job_id: str) -> Job | None: self.client.cancel_workflow(current.job_id) return self.get(current.job_id) - def retry(self, job_id: str) -> Job | None: + def retry( + self, + job_id: str, + *, + retry_id: str | None = None, + ) -> Job | None: self._prepare_access() current = self.get(job_id) if current is None: return None + if retry_id is not None: + status = self._status(current.job_id, load_input=True) + if status is None: + return None + try: + request = _JOB_REQUEST_ADAPTER.validate_python( + status.input["args"][0] + ) + except (KeyError, IndexError, TypeError, ValidationError) as exc: + raise InvalidJobBackendRequestError from exc + return self.submit( + request, + queue=current.queue, + job_id=retry_id, + ) handle = self.client.fork_workflow( current.job_id, 1, @@ -211,6 +260,10 @@ def retry(self, job_id: str) -> Job | None: retried_id = handle.get_workflow_id() return self.get(retried_id) + def health(self) -> None: + self._prepare_access() + self.client.list_workflows(limit=1, load_input=False, load_output=False) + def _prepare_access(self) -> None: if self.before_access is not None: self.before_access() diff --git a/src/vidxp/infrastructure/local_catalog.py b/src/vidxp/infrastructure/local_catalog.py index c93a35c..a9de483 100644 --- a/src/vidxp/infrastructure/local_catalog.py +++ b/src/vidxp/infrastructure/local_catalog.py @@ -9,7 +9,7 @@ from vidxp.core.media import MediaRecord, utc_now -CATALOG_SCHEMA_VERSION = 1 +CATALOG_SCHEMA_VERSION = 2 class LocalCatalog: @@ -64,6 +64,12 @@ def _initialize(self) -> None: artifact_id TEXT NOT NULL UNIQUE, FOREIGN KEY(artifact_id) REFERENCES artifacts(artifact_id) ); + CREATE TABLE IF NOT EXISTS media_import_requests ( + request_key TEXT PRIMARY KEY, + request_fingerprint TEXT NOT NULL, + media_id TEXT, + FOREIGN KEY(media_id) REFERENCES media(media_id) + ); """ ) row = connection.execute( @@ -75,6 +81,11 @@ def _initialize(self) -> None: "(schema_version) VALUES (?)", (CATALOG_SCHEMA_VERSION,), ) + elif row[0] == 1: + connection.execute( + "UPDATE catalog_metadata SET schema_version = ?", + (CATALOG_SCHEMA_VERSION,), + ) elif row[0] != CATALOG_SCHEMA_VERSION: raise RuntimeError( "The repository catalog schema is incompatible." @@ -154,6 +165,82 @@ def count_media(self) -> int: row = connection.execute("SELECT COUNT(*) FROM media").fetchone() return int(row[0]) + def reserve_media_import( + self, + request_key: str, + request_fingerprint: str, + ) -> MediaRecord | None: + with self._session() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + """ + SELECT request_fingerprint, media_id + FROM media_import_requests + WHERE request_key = ? + """, + (request_key,), + ).fetchone() + if row is None: + connection.execute( + """ + INSERT INTO media_import_requests( + request_key, request_fingerprint + ) + VALUES (?, ?) + """, + (request_key, request_fingerprint), + ) + return None + stored_fingerprint, media_id = row + if stored_fingerprint != request_fingerprint: + raise FileExistsError( + "The media import key is bound to different content." + ) + if media_id is None: + return None + media = connection.execute( + "SELECT payload FROM media WHERE media_id = ?", + (media_id,), + ).fetchone() + if media is None: + raise RuntimeError( + "A completed media import references missing media." + ) + return MediaRecord.model_validate_json(media[0]) + + def complete_media_import( + self, + request_key: str, + request_fingerprint: str, + record: MediaRecord, + ) -> None: + with self._session() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + """ + SELECT request_fingerprint, media_id + FROM media_import_requests + WHERE request_key = ? + """, + (request_key,), + ).fetchone() + if row is None or row[0] != request_fingerprint: + raise FileExistsError( + "The media import reservation does not match the content." + ) + if row[1] is not None and row[1] != record.media_id: + raise FileExistsError( + "The media import key is already completed." + ) + connection.execute( + """ + UPDATE media_import_requests + SET media_id = ? + WHERE request_key = ? + """, + (record.media_id, request_key), + ) + def get_artifact(self, artifact_id: str) -> ArtifactRecord | None: with self._session() as connection: row = connection.execute( diff --git a/src/vidxp/infrastructure/local_files.py b/src/vidxp/infrastructure/local_files.py index bef009d..fdc1cb0 100644 --- a/src/vidxp/infrastructure/local_files.py +++ b/src/vidxp/infrastructure/local_files.py @@ -52,14 +52,3 @@ def prepare_managed_destination(root: Path, storage_key: str) -> Path: if destination.is_symlink() or is_junction() or not destination.is_file(): raise PermissionError("Managed storage links are not permitted.") return destination - - -def safe_media_suffix(path: Path) -> str: - suffix = path.suffix.lower() - if ( - len(suffix) <= 10 - and suffix.startswith(".") - and suffix[1:].isalnum() - ): - return suffix - return ".bin" diff --git a/src/vidxp/infrastructure/local_media.py b/src/vidxp/infrastructure/local_media.py index e2669b9..ecbc35b 100644 --- a/src/vidxp/infrastructure/local_media.py +++ b/src/vidxp/infrastructure/local_media.py @@ -19,12 +19,12 @@ MediaImportLimitError, MediaStream, MediaStoreIntegrityError, + safe_media_suffix, StagedMedia, StoredMedia, ) from vidxp.infrastructure.local_files import ( prepare_managed_destination, - safe_media_suffix, ) from vidxp.infrastructure.local_objects import LocalObjectStore diff --git a/src/vidxp/job_service.py b/src/vidxp/job_service.py index 27e2ca3..a54aa5e 100644 --- a/src/vidxp/job_service.py +++ b/src/vidxp/job_service.py @@ -7,6 +7,7 @@ from vidxp.application_models import ( ActorOverlayJobRequest, ApplicationError, + ComponentReadiness, CreateActorOverlayCommand, CreateIndexCommand, CreateSnippetCommand, @@ -24,7 +25,11 @@ ResourceNotFoundError, SnippetJobRequest, ) -from vidxp.ports import InvalidJobBackendRequestError, JobBackend +from vidxp.ports import ( + InvalidJobBackendRequestError, + JobBackend, + JobIdempotencyConflictError, +) from vidxp.settings import VidXPSettings @@ -39,6 +44,12 @@ def wrapped(*args: Any, **kwargs: Any) -> Any: raise except InvalidJobBackendRequestError as exc: raise InvalidRequestError() from exc + except JobIdempotencyConflictError as exc: + raise ApplicationError( + "idempotency_key_reused", + ErrorCategory.validation, + "The idempotency key was already used for another request.", + ) from exc except Exception as exc: raise ApplicationError( "job_backend_unavailable", @@ -66,40 +77,52 @@ def __init__( def submit_index( self, command: CreateIndexCommand, + *, + job_id: str | None = None, ) -> Job: return self.backend.submit( IndexJobRequest(command=command), queue=self._model_queue(), + job_id=job_id, ) @job_boundary def submit_snippet( self, command: CreateSnippetCommand, + *, + job_id: str | None = None, ) -> Job: return self.backend.submit( SnippetJobRequest(command=command), queue=JobQueue.cpu, + job_id=job_id, ) @job_boundary def submit_actor_overlay( self, command: CreateActorOverlayCommand, + *, + job_id: str | None = None, ) -> Job: return self.backend.submit( ActorOverlayJobRequest(command=command), queue=JobQueue.cpu, + job_id=job_id, ) @job_boundary def submit_prepare_models( self, command: PrepareModelsCommand, + *, + job_id: str | None = None, ) -> Job: return self.backend.submit( PrepareModelsJobRequest(command=command), queue=self._model_queue(), + job_id=job_id, ) @job_boundary @@ -129,7 +152,12 @@ def cancel(self, job_id: str) -> Job: return cancelled @job_boundary - def retry(self, job_id: str) -> Job: + def retry( + self, + job_id: str, + *, + retry_id: str | None = None, + ) -> Job: current = self.get(job_id) if current.state not in { JobState.failed, @@ -141,7 +169,7 @@ def retry(self, job_id: str) -> Job: ErrorCategory.conflict, "Only failed or cancelled jobs can be retried.", ) - retried = self.backend.retry(job_id) + retried = self.backend.retry(job_id, retry_id=retry_id) if retried is None: raise ResourceNotFoundError("job") return retried @@ -204,5 +232,14 @@ def _model_queue(self) -> JobQueue: else JobQueue.cpu ) + @job_boundary + def readiness(self) -> ComponentReadiness: + self.backend.health() + return ComponentReadiness( + name="workflow", + ready=True, + message="The durable workflow database is available.", + ) + def close(self) -> None: self.backend.close() diff --git a/src/vidxp/media_service.py b/src/vidxp/media_service.py index adfb984..12148d3 100644 --- a/src/vidxp/media_service.py +++ b/src/vidxp/media_service.py @@ -15,6 +15,7 @@ from vidxp.core.media import ( MediaRecord, MediaState, + QuarantinedMedia, StagedMedia, MediaUnavailableError, utc_now, @@ -32,6 +33,10 @@ class MediaImportNotAllowedError(PermissionError): """Raised when a local path is outside the local import policy.""" +class MediaIdempotencyConflictError(FileExistsError): + """Raised when an import key is reused for different media content.""" + + def media_asset(record: MediaRecord) -> MediaAsset: return MediaAsset( schema_version=record.schema_version, @@ -70,17 +75,108 @@ def import_local(self, command: ImportMediaCommand) -> MediaAsset: "Local path imports are available only in local mode." ) source = self._resolve_import_source(command.path) + return self._import( + source, + original_filename=command.original_filename or source.name, + declared_mime_type=command.declared_mime_type, + ) + + def import_quarantined( + self, + media: QuarantinedMedia, + *, + request_key: str | None = None, + ) -> MediaAsset: + try: + source = media.path.resolve(strict=True) + except (FileNotFoundError, OSError) as exc: + raise MediaUnavailableError( + "The quarantined media source is unavailable." + ) from exc + if not source.is_file(): + raise MediaUnavailableError( + "The quarantined media source is not a file." + ) + return self._import( + source, + original_filename=media.original_filename, + declared_mime_type=media.declared_mime_type, + request_key=request_key, + ) + + def _import( + self, + source: Path, + *, + original_filename: str, + declared_mime_type: str | None, + request_key: str | None = None, + ) -> MediaAsset: staged = self.store.stage_local(source) try: with self.store.publication_lock(staged.sha256): - return self._publish_import(command, source, staged) + if request_key is not None: + request_fingerprint = self._import_fingerprint( + staged, + original_filename=original_filename, + declared_mime_type=declared_mime_type, + ) + try: + completed = self.catalog.reserve_media_import( + request_key, + request_fingerprint, + ) + except FileExistsError as exc: + raise MediaIdempotencyConflictError from exc + if completed is not None: + return media_asset(completed) + result = self._publish_import( + original_filename=original_filename, + declared_mime_type=declared_mime_type, + staged=staged, + ) + if request_key is not None: + record = self.catalog.get_media(result.media_id) + if record is None: + raise RuntimeError( + "The imported media record is unavailable." + ) + try: + self.catalog.complete_media_import( + request_key, + request_fingerprint, + record, + ) + except FileExistsError as exc: + raise MediaIdempotencyConflictError from exc + return result finally: self.store.discard(staged) + @staticmethod + def _import_fingerprint( + staged: StagedMedia, + *, + original_filename: str, + declared_mime_type: str | None, + ) -> str: + payload = json.dumps( + { + "version": 1, + "sha256": staged.sha256, + "original_filename": original_filename, + "declared_mime_type": declared_mime_type, + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + return hashlib.sha256(payload).hexdigest() + def _publish_import( self, - command: ImportMediaCommand, - source: Path, + *, + original_filename: str, + declared_mime_type: str | None, staged: StagedMedia, ) -> MediaAsset: if existing := self.catalog.get_media_by_checksum(staged.sha256): @@ -97,9 +193,9 @@ def _publish_import( media_id=media_id, video_id=media_id, sha256=stored.sha256, - original_filename=command.original_filename or source.name, + original_filename=original_filename, byte_size=stored.byte_size, - declared_mime_type=command.declared_mime_type, + declared_mime_type=declared_mime_type, detected_mime_type=probe.detected_mime_type, container=probe.container, duration_seconds=probe.duration_seconds, diff --git a/src/vidxp/ports.py b/src/vidxp/ports.py index 9b18137..ff92bad 100644 --- a/src/vidxp/ports.py +++ b/src/vidxp/ports.py @@ -47,6 +47,10 @@ class InvalidJobBackendRequestError(ValueError): """Raised when a durable job identifier or cursor is malformed.""" +class JobIdempotencyConflictError(RuntimeError): + """Raised when one workflow ID is reused for a different request.""" + + class LocalFileResource: """Authorized local delivery handle; never serialize this object.""" @@ -85,6 +89,19 @@ def list_media( def count_media(self) -> int: ... + def reserve_media_import( + self, + request_key: str, + request_fingerprint: str, + ) -> MediaRecord | None: ... + + def complete_media_import( + self, + request_key: str, + request_fingerprint: str, + record: MediaRecord, + ) -> None: ... + @runtime_checkable class MediaStorePort(Protocol): @@ -344,6 +361,13 @@ def list(self, command: ListJobsCommand) -> JobPage: ... def cancel(self, job_id: str) -> Job | None: ... - def retry(self, job_id: str) -> Job | None: ... + def retry( + self, + job_id: str, + *, + retry_id: str | None = None, + ) -> Job | None: ... + + def health(self) -> None: ... def close(self) -> None: ... diff --git a/src/vidxp/readiness_service.py b/src/vidxp/readiness_service.py new file mode 100644 index 0000000..4b539cc --- /dev/null +++ b/src/vidxp/readiness_service.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from vidxp.application_models import ComponentReadiness, RuntimeReadiness +from vidxp.authentication import Authenticator +from vidxp.control_plane import ControlPlaneApplication +from vidxp.job_service import JobService + + +class ReadinessService: + """Transport-neutral aggregate health over injected application services.""" + + def __init__( + self, + *, + application: ControlPlaneApplication, + jobs: JobService, + authenticator: Authenticator, + ) -> None: + self.application = application + self.jobs = jobs + self.authenticator = authenticator + + def details(self) -> RuntimeReadiness: + try: + application = self.application.runtime_readiness() + except Exception: + application = RuntimeReadiness( + ready=False, + runtime=None, + components=( + ComponentReadiness( + name="application", + ready=False, + message="Application readiness is unavailable.", + ), + ), + dependencies=None, + ) + components = self._components(application.components) + return application.model_copy( + update={ + "ready": all(component.ready for component in components), + "components": tuple(components), + } + ) + + def ready(self) -> bool: + try: + application = self.application.control_plane_readiness() + except Exception: + application = ( + ComponentReadiness( + name="application", + ready=False, + message="Application readiness is unavailable.", + ), + ) + return all( + component.ready + for component in self._components(application) + ) + + def _components( + self, + application: tuple[ComponentReadiness, ...], + ) -> list[ComponentReadiness]: + components = list(application) + try: + components.append(self.authenticator.readiness()) + except Exception: + components.append( + ComponentReadiness( + name="authentication", + ready=False, + message="Authentication readiness is unavailable.", + ) + ) + try: + components.append(self.jobs.readiness()) + except Exception: + components.append( + ComponentReadiness( + name="workflow", + ready=False, + message="The durable workflow database is unavailable.", + ) + ) + return components diff --git a/src/vidxp/requirements/server.txt b/src/vidxp/requirements/server.txt index 963f2c1..3e4cbae 100644 --- a/src/vidxp/requirements/server.txt +++ b/src/vidxp/requirements/server.txt @@ -1,2 +1,6 @@ -chromadb>=1.5.9,<2 +asgi-correlation-id>=5.0.1,<6 +fastapi>=0.140.13,<0.141 psutil>=7.2.2,<8 +pyjwt[crypto]>=2.13,<3 +python-multipart>=0.0.32,<0.1 +uvicorn[standard]>=0.51,<0.52 diff --git a/src/vidxp/requirements/test.txt b/src/vidxp/requirements/test.txt new file mode 100644 index 0000000..b24719e --- /dev/null +++ b/src/vidxp/requirements/test.txt @@ -0,0 +1 @@ +httpx>=0.28.1,<0.29 diff --git a/src/vidxp/settings.py b/src/vidxp/settings.py index 9ca3a73..92cffb3 100644 --- a/src/vidxp/settings.py +++ b/src/vidxp/settings.py @@ -6,7 +6,15 @@ from pathlib import Path from platformdirs import user_cache_path -from pydantic import Field, field_validator, model_validator +from pydantic import ( + Field, + HttpUrl, + SecretStr, + TypeAdapter, + ValidationInfo, + field_validator, + model_validator, +) from pydantic_settings import BaseSettings, SettingsConfigDict from vidxp.repository_layout import RepositoryLayout @@ -18,6 +26,15 @@ class ApplicationMode(StrEnum): server = "server" +class HttpAuthMode(StrEnum): + none = "none" + static = "static" + oidc = "oidc" + + +_HTTP_URL = TypeAdapter(HttpUrl) + + class VidXPSettings(BaseSettings): model_config = SettingsConfigDict( env_prefix="VIDXP_", @@ -57,6 +74,44 @@ class VidXPSettings(BaseSettings): gt=0, le=3600, ) + http_bind_host: str = Field(default="127.0.0.1", min_length=1) + http_port: int = Field(default=8000, gt=0, le=65535) + http_auth_mode: HttpAuthMode = HttpAuthMode.none + http_static_bearer_token: SecretStr | None = None + http_oidc_issuer: str | None = Field( + default=None, + min_length=1, + max_length=2048, + ) + http_oidc_audience: str | None = Field( + default=None, + min_length=1, + max_length=512, + ) + http_oidc_jwks_url: str | None = Field( + default=None, + min_length=1, + max_length=2048, + ) + http_oidc_algorithms: tuple[str, ...] = ("RS256",) + http_required_scopes: tuple[str, ...] = () + http_trusted_hosts: tuple[str, ...] = ( + "127.0.0.1", + "::1", + "localhost", + "testserver", + ) + http_allowed_origins: tuple[str, ...] = () + http_max_json_body_bytes: int = Field( + default=4 * 1024 * 1024, + gt=0, + le=16 * 1024 * 1024, + ) + http_max_small_upload_bytes: int = Field( + default=256 * 1024 * 1024, + gt=0, + le=256 * 1024 * 1024, + ) trusted_local_import_roots: tuple[Path, ...] = () ffprobe_executable: str = Field(default="ffprobe", min_length=1) ffmpeg_executable: str = Field(default="ffmpeg", min_length=1) @@ -93,6 +148,81 @@ def _clean_allowlist(cls, values: tuple[str, ...]) -> tuple[str, ...]: ) return cleaned + @field_validator( + "http_required_scopes", + "http_trusted_hosts", + "http_allowed_origins", + ) + @classmethod + def _clean_http_lists(cls, values: tuple[str, ...]) -> tuple[str, ...]: + return tuple( + dict.fromkeys(value.strip() for value in values if value.strip()) + ) + + @field_validator("http_trusted_hosts") + @classmethod + def _validate_trusted_hosts( + cls, + values: tuple[str, ...], + ) -> tuple[str, ...]: + normalized = tuple(value.lower() for value in values) + for value in normalized: + if ( + "*" in value[1:] + or ( + value.startswith("*") + and value != "*" + and not value.startswith("*.") + ) + ): + raise ValueError( + "Trusted-host wildcards must use *.example.com." + ) + return normalized + + @field_validator("http_oidc_algorithms") + @classmethod + def _validate_oidc_algorithms( + cls, + values: tuple[str, ...], + ) -> tuple[str, ...]: + allowed = { + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512", + "EdDSA", + } + cleaned = tuple(dict.fromkeys(values)) + if not cleaned or any(value not in allowed for value in cleaned): + raise ValueError( + "http_oidc_algorithms must contain supported asymmetric " + "signature algorithms." + ) + return cleaned + + @field_validator("http_oidc_issuer", "http_oidc_jwks_url") + @classmethod + def _validate_oidc_url( + cls, + value: str | None, + info: ValidationInfo, + ) -> str | None: + if value is None: + return None + if value != value.strip(): + raise ValueError(f"{info.field_name} must not contain whitespace.") + parsed = _HTTP_URL.validate_python(value) + if parsed.username is not None or parsed.password is not None: + raise ValueError(f"{info.field_name} must not contain credentials.") + if parsed.fragment is not None: + raise ValueError(f"{info.field_name} must not contain a fragment.") + if info.field_name == "http_oidc_issuer" and parsed.query is not None: + raise ValueError("http_oidc_issuer must not contain a query.") + return value + @field_validator("trusted_local_import_roots") @classmethod def _clean_import_roots( @@ -112,8 +242,75 @@ def _require_explicit_server_backend(self) -> "VidXPSettings": raise ValueError( "Server mode requires an explicit cpu or cuda runtime backend." ) + if self.http_auth_mode == HttpAuthMode.static: + if self.http_static_bearer_token is None or len( + self.http_static_bearer_token.get_secret_value() + ) < 32: + raise ValueError( + "Static HTTP authentication requires a bearer token of " + "at least 32 characters." + ) + if any( + value is not None + for value in ( + self.http_oidc_issuer, + self.http_oidc_audience, + self.http_oidc_jwks_url, + ) + ): + raise ValueError( + "Static HTTP authentication cannot include OIDC settings." + ) + elif self.http_auth_mode == HttpAuthMode.oidc: + if ( + self.http_oidc_issuer is None + or self.http_oidc_audience is None + or self.http_oidc_jwks_url is None + ): + raise ValueError( + "OIDC HTTP authentication requires issuer, audience, " + "and JWKS URL settings." + ) + if self.http_static_bearer_token is not None: + raise ValueError( + "OIDC HTTP authentication cannot include a static token." + ) + if not self.http_required_scopes: + raise ValueError( + "OIDC HTTP authentication requires at least one scope." + ) + elif self.http_static_bearer_token is not None or any( + value is not None + for value in ( + self.http_oidc_issuer, + self.http_oidc_audience, + self.http_oidc_jwks_url, + ) + ): + raise ValueError( + "HTTP credentials require an explicit authentication mode." + ) return self + def validate_http_server(self) -> None: + if ( + self.mode == ApplicationMode.server + and self.http_auth_mode == HttpAuthMode.none + ): + raise ValueError( + "Server-mode HTTP requires static bearer or OIDC " + "authentication." + ) + if ( + self.http_auth_mode == HttpAuthMode.none + and self.http_bind_host not in {"127.0.0.1", "::1", "localhost"} + ): + raise ValueError( + "Unauthenticated HTTP may bind only to a loopback address." + ) + if not self.http_trusted_hosts: + raise ValueError("At least one trusted HTTP host is required.") + @property def layout(self) -> RepositoryLayout: return RepositoryLayout(root=self.repository_root.expanduser()) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..7d2c24d --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,707 @@ +import asyncio +import gc +import unittest +import warnings +from dataclasses import replace +from datetime import datetime, timezone +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import Mock +from unittest.mock import patch + +import httpx +from fastapi.testclient import TestClient + +from vidxp.api import create_app +from vidxp.api_routes.dependencies import scoped_job_id +from vidxp.application_models import ( + ApplicationError, + ErrorCategory, + ComponentReadiness, + Job, + JobKind, + JobQueue, + JobState, + MediaAsset, + Principal, +) +from vidxp.composition import ( + HttpApplicationContext, + create_http_application, +) +from vidxp.control_plane import ControlPlaneApplication +from vidxp.authentication import create_authenticator +from vidxp.authorization import AuthorizationPolicy +from vidxp.core.media import MediaState, MediaStream +from vidxp.job_service import JobService +from vidxp.ports import LocalFileResource +from vidxp.readiness_service import ReadinessService +from vidxp.settings import HttpAuthMode, VidXPSettings + + +MEDIA_ID = "123456781234423481234567890abcde" +JOB_ID = "223456781234423481234567890abcde" +IDEMPOTENCY_KEY = "323456781234423481234567890abcde" +TOKEN = "a" * 32 + + +def media_asset() -> MediaAsset: + return MediaAsset( + media_id=MEDIA_ID, + video_id=MEDIA_ID, + original_filename="video.mp4", + sha256="1" * 64, + byte_size=10, + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=2, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + state=MediaState.ready, + created_at=datetime.now(timezone.utc), + ) + + +def queued_job() -> Job: + return Job( + job_id=JOB_ID, + kind=JobKind.index, + state=JobState.queued, + queue=JobQueue.cpu, + ) + + +class ApiTests(unittest.TestCase): + def context( + self, + root: Path, + *, + auth: HttpAuthMode = HttpAuthMode.none, + upload_limit: int = 256 * 1024 * 1024, + json_limit: int = 4 * 1024 * 1024, + allowed_origins: tuple[str, ...] = (), + ) -> HttpApplicationContext: + settings = VidXPSettings( + repository_root=root, + runtime_backend="cpu", + minimum_available_memory_mb=0, + http_auth_mode=auth, + http_static_bearer_token=TOKEN if auth == HttpAuthMode.static else None, + http_max_small_upload_bytes=upload_limit, + http_max_json_body_bytes=json_limit, + http_allowed_origins=allowed_origins, + ) + application = Mock() + jobs = Mock(spec=JobService) + readiness = Mock() + readiness.ready.return_value = True + return HttpApplicationContext( + application=application, + jobs=jobs, + readiness=readiness, + authenticator=create_authenticator(settings), + authorization=AuthorizationPolicy(), + settings=settings, + ) + + @staticmethod + def auth() -> dict[str, str]: + return {"Authorization": f"Bearer {TOKEN}"} + + def test_health_and_minimal_readiness_are_public(self): + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + auth=HttpAuthMode.static, + ) + with TestClient(create_app(context=context)) as client: + health = client.get("/health") + ready = client.get("/ready") + + self.assertEqual(health.status_code, 200) + self.assertEqual(health.json(), {"status": "ok"}) + self.assertEqual(ready.status_code, 200) + self.assertEqual(ready.json(), {"ready": True, "status": "ready"}) + + def test_readiness_failure_returns_503_without_details(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.readiness.ready.return_value = False + with TestClient(create_app(context=context)) as client: + response = client.get("/ready") + + self.assertEqual(response.status_code, 503) + self.assertEqual( + response.json(), + {"ready": False, "status": "not_ready"}, + ) + + def test_static_bearer_is_enforced_before_dispatch(self): + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + auth=HttpAuthMode.static, + ) + context.application.list_capabilities.return_value = () + with TestClient(create_app(context=context)) as client: + missing = client.get("/api/v1/capabilities") + accepted = client.get( + "/api/v1/capabilities", + headers=self.auth(), + ) + + self.assertEqual(missing.status_code, 401) + self.assertEqual( + missing.json()["error"]["code"], + "authentication_required", + ) + self.assertEqual(missing.headers["www-authenticate"], "Bearer") + self.assertEqual(accepted.status_code, 200) + self.assertEqual(accepted.json(), {"items": []}) + context.application.list_capabilities.assert_called_once_with() + + def test_repository_scopes_are_enforced_per_operation(self): + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + auth=HttpAuthMode.static, + ) + authenticator = Mock() + authenticator.authenticate.return_value = Principal( + subject="reader", + scopes=frozenset({"vidxp.read"}), + ) + authenticator.readiness.return_value = ComponentReadiness( + name="authentication", + ready=True, + message="Authentication is ready.", + ) + context = replace(context, authenticator=authenticator) + context.application.list_capabilities.return_value = () + with TestClient(create_app(context=context)) as client: + readable = client.get( + "/api/v1/capabilities", + headers=self.auth(), + ) + forbidden = client.post( + "/api/v1/jobs/index", + headers={ + **self.auth(), + "Idempotency-Key": IDEMPOTENCY_KEY, + }, + json={ + "media_id": MEDIA_ID, + "modalities": ["scene"], + "frame_stride": 1, + "capability_options": {}, + }, + ) + + self.assertEqual(readable.status_code, 200) + self.assertEqual(forbidden.status_code, 403) + self.assertEqual( + forbidden.json()["error"]["code"], + "insufficient_scope", + ) + context.jobs.submit_index.assert_not_called() + + def test_validation_errors_use_safe_shared_envelope(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + with TestClient(create_app(context=context)) as client: + response = client.get("/api/v1/media/not-an-id") + + self.assertEqual(response.status_code, 422) + error = response.json()["error"] + self.assertEqual(error["code"], "invalid_request") + self.assertEqual( + error["details"]["errors"][0]["location"], + ["path", "media_id"], + ) + self.assertTrue(error["correlation_id"]) + + def test_unexpected_errors_are_logged_and_masked(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.application.get_media.side_effect = RuntimeError( + "secret-path" + ) + with TestClient( + create_app(context=context), + raise_server_exceptions=False, + ) as client: + response = client.get(f"/api/v1/media/{MEDIA_ID}") + + self.assertEqual(response.status_code, 500) + payload = response.json() + self.assertEqual(payload["error"]["code"], "internal_error") + self.assertNotIn("secret-path", response.text) + + def test_job_submission_is_thin_idempotent_delegation(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.jobs.submit_index.return_value = queued_job() + with TestClient(create_app(context=context)) as client: + response = client.post( + "/api/v1/jobs/index", + headers={"Idempotency-Key": IDEMPOTENCY_KEY}, + json={ + "media_id": MEDIA_ID, + "modalities": ["scene"], + "frame_stride": 1, + "capability_options": {}, + }, + ) + + self.assertEqual(response.status_code, 202) + self.assertEqual(response.headers["location"], f"/api/v1/jobs/{JOB_ID}") + command = context.jobs.submit_index.call_args.args[0] + self.assertEqual(command.media_id, MEDIA_ID) + self.assertEqual(command.modalities, ("scene",)) + expected_job_id = scoped_job_id( + context, + context.authenticator.authenticate(None), + operation="index", + idempotency_key=IDEMPOTENCY_KEY, + ) + self.assertEqual( + context.jobs.submit_index.call_args.kwargs["job_id"], + expected_job_id, + ) + self.assertNotEqual(expected_job_id, IDEMPOTENCY_KEY) + self.assertNotEqual( + expected_job_id, + scoped_job_id( + context, + Principal(subject="other", scopes=frozenset({"*"})), + operation="index", + idempotency_key=IDEMPOTENCY_KEY, + ), + ) + self.assertNotEqual( + expected_job_id, + scoped_job_id( + context, + context.authenticator.authenticate(None), + operation="snippet", + idempotency_key=IDEMPOTENCY_KEY, + ), + ) + + def test_job_submission_requires_an_idempotency_key(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + with TestClient(create_app(context=context)) as client: + response = client.post( + "/api/v1/jobs/index", + json={ + "media_id": MEDIA_ID, + "modalities": ["scene"], + "frame_stride": 1, + "capability_options": {}, + }, + ) + + self.assertEqual(response.status_code, 422) + context.jobs.submit_index.assert_not_called() + + def test_idempotency_payload_mismatch_returns_422(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.jobs.submit_index.side_effect = ApplicationError( + "idempotency_key_reused", + ErrorCategory.validation, + "The idempotency key was used for another request.", + ) + with TestClient(create_app(context=context)) as client: + response = client.post( + "/api/v1/jobs/index", + headers={"Idempotency-Key": IDEMPOTENCY_KEY}, + json={ + "media_id": MEDIA_ID, + "modalities": ["scene"], + "frame_stride": 1, + "capability_options": {}, + }, + ) + + self.assertEqual(response.status_code, 422) + self.assertEqual( + response.json()["error"]["code"], + "idempotency_key_reused", + ) + + def test_small_upload_stages_then_calls_application_ingestion(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory), upload_limit=32) + captured = {} + + def ingest(**kwargs): + staged = kwargs["staged_path"] + captured["path"] = staged + captured["bytes"] = staged.read_bytes() + captured["filename"] = kwargs["original_filename"] + captured["mime"] = kwargs["declared_mime_type"] + captured["request_key"] = kwargs["request_key"] + return media_asset() + + context.application.import_uploaded_media.side_effect = ingest + with TestClient(create_app(context=context)) as client: + response = client.post( + "/api/v1/media", + headers={"Idempotency-Key": IDEMPOTENCY_KEY}, + files={"upload": ("video.mp4", b"0123456789", "video/mp4")}, + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()["media_id"], MEDIA_ID) + self.assertEqual(captured["bytes"], b"0123456789") + self.assertEqual(captured["filename"], "video.mp4") + self.assertEqual(captured["mime"], "video/mp4") + self.assertEqual(len(captured["request_key"]), 64) + self.assertFalse(captured["path"].exists()) + + def test_small_upload_enforces_streamed_file_limit(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory), upload_limit=4) + with TestClient(create_app(context=context)) as client: + response = client.post( + "/api/v1/media", + headers={"Idempotency-Key": IDEMPOTENCY_KEY}, + files={"upload": ("video.mp4", b"12345", "video/mp4")}, + ) + + self.assertEqual(response.status_code, 413) + self.assertEqual(response.json()["error"]["code"], "media_too_large") + context.application.import_uploaded_media.assert_not_called() + + def test_json_body_limit_rejects_before_job_dispatch(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory), json_limit=32) + with TestClient(create_app(context=context)) as client: + response = client.post( + "/api/v1/jobs/index", + json={ + "media_id": MEDIA_ID, + "modalities": ["scene"], + "frame_stride": 1, + "capability_options": {}, + }, + ) + + self.assertEqual(response.status_code, 413) + self.assertEqual( + response.json()["error"]["code"], + "request_body_too_large", + ) + context.jobs.submit_index.assert_not_called() + + def test_streamed_body_limit_does_not_require_content_length(self): + async def request() -> httpx.Response: + with TemporaryDirectory() as directory: + context = self.context(Path(directory), upload_limit=4) + app = create_app(context=context) + + async def body(): + yield ( + b"--boundary\r\n" + b'Content-Disposition: form-data; name="upload"; ' + b'filename="video.mp4"\r\n' + b"Content-Type: video/mp4\r\n\r\n" + ) + yield b"x" * (1024 * 1024 + 32) + yield b"\r\n--boundary--\r\n" + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + ) as client: + return await client.post( + "/api/v1/media", + headers={ + "Content-Type": ( + "multipart/form-data; boundary=boundary" + ), + "Idempotency-Key": IDEMPOTENCY_KEY, + }, + content=body(), + ) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", ResourceWarning) + response = asyncio.run(request()) + gc.collect() + + self.assertEqual(response.status_code, 413) + self.assertEqual( + response.json()["error"]["code"], + "request_body_too_large", + ) + self.assertFalse( + [ + warning + for warning in caught + if issubclass(warning.category, ResourceWarning) + and "Unclosed file" in str(warning.message) + ] + ) + + def test_file_delivery_uses_starlette_range_and_strong_etag(self): + with TemporaryDirectory() as directory: + root = Path(directory) + content = root / "video.mp4" + content.write_bytes(b"0123456789") + context = self.context(root) + context.application.open_media_content.return_value = ( + LocalFileResource( + path=content, + filename="video.mp4", + mime_type="video/mp4", + byte_size=10, + etag="1" * 64, + ) + ) + with TestClient(create_app(context=context)) as client: + ranged = client.get( + f"/api/v1/media/{MEDIA_ID}/content", + headers={"Range": "bytes=2-5"}, + ) + cached = client.get( + f"/api/v1/media/{MEDIA_ID}/content", + headers={"If-None-Match": f'"{"1" * 64}"'}, + ) + + self.assertEqual(ranged.status_code, 206) + self.assertEqual(ranged.content, b"2345") + self.assertEqual(ranged.headers["content-range"], "bytes 2-5/10") + self.assertEqual(ranged.headers["etag"], f'"{"1" * 64}"') + self.assertEqual(cached.status_code, 304) + self.assertEqual(cached.content, b"") + + def test_openapi_is_curated_and_has_unique_operation_ids(self): + with TemporaryDirectory() as directory: + app = create_app(context=self.context(Path(directory))) + schema = app.openapi() + + operations = [ + operation + for path in schema["paths"].values() + for method, operation in path.items() + if method.lower() in { + "get", + "post", + "put", + "patch", + "delete", + "head", + } + ] + operation_ids = [operation["operationId"] for operation in operations] + self.assertEqual(len(operation_ids), len(set(operation_ids))) + self.assertNotIn("/api/v1/execute", schema["paths"]) + self.assertNotIn("/mcp", schema["paths"]) + upload_schema = schema["paths"]["/api/v1/media"]["post"] + self.assertIn("multipart/form-data", upload_schema["requestBody"]["content"]) + self.assertNotIn('"format": "path"', str(upload_schema)) + + def test_cors_is_scoped_to_the_rest_namespace(self): + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + allowed_origins=("https://client.example",), + ) + headers = { + "Origin": "https://client.example", + "Access-Control-Request-Method": "GET", + } + with TestClient(create_app(context=context)) as client: + api = client.options("/api/v1/media", headers=headers) + mcp = client.options("/mcp", headers=headers) + + self.assertEqual(api.status_code, 200) + self.assertEqual( + api.headers["access-control-allow-origin"], + "https://client.example", + ) + self.assertNotIn("access-control-allow-origin", mcp.headers) + + def test_security_middleware_rejections_use_typed_envelopes(self): + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + allowed_origins=("https://client.example",), + ) + with TestClient(create_app(context=context)) as client: + host = client.get( + "/health", + headers={"Host": "untrusted.example"}, + ) + ipv6_loopback = client.get( + "/health", + headers={"Host": "[::1]:8000"}, + ) + origin = client.options( + "/api/v1/media", + headers={ + "Origin": "https://untrusted.example", + "Access-Control-Request-Method": "GET", + }, + ) + method = client.options( + "/api/v1/media", + headers={ + "Origin": "https://client.example", + "Access-Control-Request-Method": "TRACE", + }, + ) + + self.assertEqual(host.status_code, 400) + self.assertEqual(host.json()["error"]["code"], "host_not_allowed") + self.assertEqual(ipv6_loopback.status_code, 200) + self.assertEqual(origin.status_code, 403) + self.assertEqual( + origin.json()["error"]["code"], + "cors_origin_forbidden", + ) + self.assertEqual(method.status_code, 400) + self.assertEqual( + method.json()["error"]["code"], + "cors_preflight_invalid", + ) + + def test_authenticated_profiles_do_not_publish_interactive_docs(self): + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + auth=HttpAuthMode.static, + ) + with TestClient(create_app(context=context)) as client: + unauthenticated = client.get("/docs") + authenticated = client.get( + "/docs", + headers=self.auth(), + ) + schema = client.get( + "/openapi.json", + headers=self.auth(), + ) + + self.assertEqual(unauthenticated.status_code, 401) + self.assertEqual(authenticated.status_code, 404) + self.assertEqual(schema.status_code, 404) + + def test_readiness_masks_component_probe_failures(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.application.control_plane_readiness.side_effect = OSError( + "private-catalog-detail" + ) + context.application.runtime_readiness.side_effect = OSError( + "private-runtime-detail" + ) + context.jobs.readiness.return_value = ComponentReadiness( + name="workflow", + ready=True, + message="Workflow is ready.", + ) + readiness = ReadinessService( + application=context.application, + jobs=context.jobs, + authenticator=context.authenticator, + ) + context = replace(context, readiness=readiness) + with TestClient(create_app(context=context)) as client: + minimal = client.get("/ready") + details = client.get("/api/v1/runtime/readiness") + + self.assertEqual(minimal.status_code, 503) + self.assertEqual( + minimal.json(), + {"ready": False, "status": "not_ready"}, + ) + self.assertEqual(details.status_code, 200) + self.assertFalse(details.json()["ready"]) + self.assertNotIn("private-", details.text) + + def test_owned_context_is_closed_when_lifespan_exits(self): + with TemporaryDirectory() as directory: + owned = self.context(Path(directory)) + with patch( + "vidxp.api.create_http_application", + return_value=owned, + ): + with TestClient(create_app()) as client: + self.assertEqual(client.get("/health").status_code, 200) + + owned.jobs.close.assert_called_once_with() + + def test_http_composition_does_not_construct_model_runtime(self): + with TemporaryDirectory(ignore_cleanup_errors=True) as directory: + settings = VidXPSettings( + repository_root=Path(directory), + runtime_backend="cpu", + minimum_available_memory_mb=0, + ) + with patch( + "vidxp.composition.ModelRuntime", + side_effect=AssertionError("model runtime constructed"), + ) as runtime: + context = create_http_application(settings) + try: + self.assertIsInstance( + context.application, + ControlPlaneApplication, + ) + runtime.assert_not_called() + finally: + context.close() + + def test_reserved_mcp_namespace_bypasses_api_auth_and_body_policy(self): + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + auth=HttpAuthMode.static, + json_limit=32, + ) + with TestClient(create_app(context=context)) as client: + endpoint = client.post("/mcp", content=b"x" * 64) + metadata = client.get( + "/.well-known/oauth-protected-resource/mcp" + ) + + self.assertEqual(endpoint.status_code, 404) + self.assertEqual(metadata.status_code, 404) + self.assertNotEqual( + endpoint.json()["error"]["code"], + "authentication_required", + ) + self.assertNotEqual( + endpoint.json()["error"]["code"], + "request_body_too_large", + ) + + def test_typed_application_error_status_mapping(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.application.get_media.side_effect = ApplicationError( + "media_conflict", + ErrorCategory.conflict, + "The media is in conflict.", + ) + with TestClient(create_app(context=context)) as client: + response = client.get(f"/api/v1/media/{MEDIA_ID}") + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "media_conflict") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_authentication.py b/tests/test_authentication.py new file mode 100644 index 0000000..e53a166 --- /dev/null +++ b/tests/test_authentication.py @@ -0,0 +1,224 @@ +import unittest +from datetime import datetime, timedelta, timezone +from unittest.mock import Mock, patch + +import jwt +from cryptography.hazmat.primitives.asymmetric import rsa + +from vidxp.application_models import ApplicationError +from vidxp.authentication import ( + OIDCBearerAuthenticator, + StaticBearerAuthenticator, +) +from vidxp.settings import ApplicationMode, VidXPSettings + + +class AuthenticationTests(unittest.TestCase): + def test_static_bearer_uses_one_typed_principal(self): + authenticator = StaticBearerAuthenticator("a" * 32) + + principal = authenticator.authenticate("a" * 32) + + self.assertEqual(principal.subject, "static") + self.assertIn("*", principal.scopes) + with self.assertRaises(ApplicationError) as caught: + authenticator.authenticate("b" * 32) + self.assertEqual(caught.exception.code, "authentication_required") + + def test_oidc_uses_cached_jwks_and_fixed_validation_contract(self): + decoder = Mock( + return_value={ + "sub": "user-1", + "client_id": "client-1", + "scope": "vidxp.read vidxp.write", + } + ) + signing_key = Mock(key="public-key") + jwks = Mock() + jwks.get_signing_key_from_jwt.return_value = signing_key + fake_jwt = Mock() + fake_jwt.PyJWTError = Exception + fake_jwt.PyJWKClient.return_value = jwks + fake_jwt.decode = decoder + + with patch.dict("sys.modules", {"jwt": fake_jwt}): + authenticator = OIDCBearerAuthenticator( + issuer="https://issuer.example/", + audience="https://api.example", + jwks_url="https://issuer.example/jwks", + algorithms=("RS256",), + required_scopes=("vidxp.read",), + ) + principal = authenticator.authenticate("signed-token") + + self.assertEqual(principal.subject, "user-1") + self.assertEqual(principal.client_id, "client-1") + fake_jwt.PyJWKClient.assert_called_once_with( + "https://issuer.example/jwks", + cache_keys=True, + cache_jwk_set=True, + lifespan=300, + timeout=5, + ) + self.assertEqual(decoder.call_args.kwargs["algorithms"], ["RS256"]) + self.assertEqual( + decoder.call_args.kwargs["issuer"], + "https://issuer.example/", + ) + self.assertEqual( + decoder.call_args.kwargs["audience"], + "https://api.example", + ) + self.assertEqual( + decoder.call_args.kwargs["options"]["require"], + ["exp", "iss", "aud", "sub"], + ) + + def test_oidc_preserves_jwk_algorithm_binding(self): + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + public_jwk = jwt.PyJWK.from_dict( + { + **jwt.algorithms.RSAAlgorithm.to_jwk( + private_key.public_key(), + as_dict=True, + ), + "kid": "test-key", + "alg": "RS256", + "use": "sig", + } + ) + now = datetime.now(timezone.utc) + token = jwt.encode( + { + "sub": "user-1", + "iss": "https://issuer.example", + "aud": "https://api.example", + "scope": "vidxp.read", + "iat": now, + "exp": now + timedelta(minutes=5), + }, + private_key, + algorithm="RS512", + headers={"kid": "test-key"}, + ) + authenticator = OIDCBearerAuthenticator( + issuer="https://issuer.example", + audience="https://api.example", + jwks_url="https://issuer.example/jwks", + algorithms=("RS256", "RS512"), + required_scopes=("vidxp.read",), + ) + authenticator._jwks = Mock() + authenticator._jwks.get_signing_key_from_jwt.return_value = public_jwk + + with self.assertRaises(ApplicationError) as caught: + authenticator.authenticate(token) + + self.assertEqual(caught.exception.code, "authentication_required") + + def test_oidc_rejects_missing_configured_scope(self): + signing_key = Mock(key="public-key") + jwks = Mock() + jwks.get_signing_key_from_jwt.return_value = signing_key + fake_jwt = Mock() + fake_jwt.PyJWTError = Exception + fake_jwt.PyJWKClient.return_value = jwks + fake_jwt.decode.return_value = {"sub": "user-1", "scope": "other"} + + with ( + patch.dict("sys.modules", {"jwt": fake_jwt}), + self.assertRaises(ApplicationError) as caught, + ): + OIDCBearerAuthenticator( + issuer="https://issuer.example", + audience="https://api.example", + jwks_url="https://issuer.example/jwks", + algorithms=("RS256",), + required_scopes=("vidxp.read",), + ).authenticate("signed-token") + + self.assertEqual(caught.exception.code, "insufficient_scope") + + def test_oidc_readiness_checks_cached_signing_keys(self): + key_set = Mock( + keys=[ + Mock( + public_key_use="sig", + algorithm_name="RS256", + _jwk_data={"key_ops": ["verify"]}, + ) + ] + ) + jwks = Mock() + jwks.get_jwk_set.return_value = key_set + fake_jwt = Mock() + fake_jwt.PyJWTError = Exception + fake_jwt.PyJWKClient.return_value = jwks + + with patch.dict("sys.modules", {"jwt": fake_jwt}): + authenticator = OIDCBearerAuthenticator( + issuer="https://issuer.example", + audience="https://api.example", + jwks_url="https://issuer.example/jwks", + algorithms=("RS256",), + required_scopes=(), + ) + ready = authenticator.readiness() + jwks.get_jwk_set.return_value = Mock( + keys=[ + Mock( + public_key_use="enc", + algorithm_name="RSA-OAEP", + _jwk_data={"key_ops": ["decrypt"]}, + ) + ] + ) + unusable = authenticator.readiness() + jwks.get_jwk_set.side_effect = OSError("private-network-detail") + unavailable = authenticator.readiness() + + self.assertTrue(ready.ready) + self.assertFalse(unusable.ready) + self.assertFalse(unavailable.ready) + self.assertNotIn("private-network-detail", unavailable.message) + + def test_server_mode_refuses_unauthenticated_http(self): + settings = VidXPSettings( + mode=ApplicationMode.server, + runtime_backend="cpu", + ) + + with self.assertRaisesRegex(ValueError, "requires static bearer or OIDC"): + settings.validate_http_server() + + def test_oidc_settings_require_an_explicit_scope(self): + with self.assertRaisesRegex(ValueError, "at least one scope"): + VidXPSettings( + runtime_backend="cpu", + http_auth_mode="oidc", + http_oidc_issuer="https://issuer.example", + http_oidc_audience="https://api.example", + http_oidc_jwks_url="https://issuer.example/jwks", + ) + + def test_oidc_settings_preserve_exact_issuer(self): + settings = VidXPSettings( + runtime_backend="cpu", + http_auth_mode="oidc", + http_oidc_issuer="https://issuer.example", + http_oidc_audience="https://api.example", + http_oidc_jwks_url="https://issuer.example/jwks", + http_required_scopes=("vidxp.read",), + ) + + self.assertEqual( + settings.http_oidc_issuer, + "https://issuer.example", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_dbos_jobs.py b/tests/test_dbos_jobs.py index c962502..d0cd243 100644 --- a/tests/test_dbos_jobs.py +++ b/tests/test_dbos_jobs.py @@ -26,6 +26,7 @@ MEDIA_ID = "123456781234423481234567890abcde" GENERATION_ID = "223456781234423481234567890abcde" SNAPSHOT_ID = "323456781234423481234567890abcde" +IDEMPOTENCY_KEY = "423456781234423481234567890abcde" class DBOSJobIntegrationTests(unittest.TestCase): @@ -121,6 +122,32 @@ def create_index(command, *, execution): self.assertEqual(page.items[0].job_id, submitted.job_id) self.application.create_index.assert_called_once() + def test_submission_idempotency_replays_only_the_same_request(self): + command = CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + ) + + first = self.jobs.submit_index(command, job_id=IDEMPOTENCY_KEY) + second = self.jobs.submit_index(command, job_id=IDEMPOTENCY_KEY) + completed = self.jobs.wait(first.job_id) + + self.assertEqual(first.job_id, IDEMPOTENCY_KEY) + self.assertEqual(second.job_id, IDEMPOTENCY_KEY) + self.assertEqual(completed.state, JobState.succeeded) + self.application.create_index.assert_called_once() + + with self.assertRaises(ApplicationError) as caught: + self.jobs.submit_index( + command.model_copy(update={"frame_stride": 2}), + job_id=IDEMPOTENCY_KEY, + ) + self.assertEqual(caught.exception.code, "idempotency_key_reused") + self.assertEqual( + caught.exception.category, + ErrorCategory.validation, + ) + def test_cancellation_reaches_the_running_application_operation(self): started = Event() stopped = Event() @@ -148,7 +175,7 @@ def create_index(_command, *, execution): self.assertEqual(cancelled.state, JobState.cancelled) self.assertTrue(stopped.wait(timeout=5)) - def test_failed_job_retries_as_a_new_typed_execution(self): + def test_failed_job_retries_idempotently_as_new_typed_execution(self): attempts = 0 def create_index(command, *, execution): @@ -193,10 +220,19 @@ def create_index(command, *, execution): "temporary indexing dependency", repr(failed_step["error"]).lower(), ) - retried = self.jobs.retry(failed.job_id) + retried = self.jobs.retry( + failed.job_id, + retry_id=IDEMPOTENCY_KEY, + ) + replayed = self.jobs.retry( + failed.job_id, + retry_id=IDEMPOTENCY_KEY, + ) completed = self.jobs.wait(retried.job_id) self.assertNotEqual(retried.job_id, failed.job_id) + self.assertEqual(retried.job_id, IDEMPOTENCY_KEY) + self.assertEqual(replayed.job_id, IDEMPOTENCY_KEY) self.assertEqual(completed.state, JobState.succeeded) self.assertEqual(completed.queue, JobQueue.cpu) self.assertEqual( diff --git a/tests/test_media_services.py b/tests/test_media_services.py index b93609f..5121994 100644 --- a/tests/test_media_services.py +++ b/tests/test_media_services.py @@ -21,6 +21,7 @@ from vidxp.core.contracts import CancellationToken, IndexCancelledError from vidxp.core.media import ( MediaProbe, + QuarantinedMedia, MediaRecord, MediaState, MediaStream, @@ -32,8 +33,12 @@ FFmpegSnippetRenderer, LocalArtifactStore, ) +from vidxp.infrastructure.local_catalog import LocalCatalog from vidxp.infrastructure.local_media import InvalidMediaError -from vidxp.media_service import MediaService +from vidxp.media_service import ( + MediaIdempotencyConflictError, + MediaService, +) from vidxp.ports import LocalFileResource from vidxp.settings import VidXPSettings @@ -155,6 +160,170 @@ def test_invalid_probe_never_publishes_or_catalogs_media(self): catalog.put_media.assert_not_called() store.discard.assert_called_once_with(staged) + def test_quarantined_import_reuses_the_same_ingestion_pipeline(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "upload.mp4" + source.write_bytes(b"video") + service, catalog, store, probe = self.service(root) + staged = StagedMedia( + sha256="1" * 64, + byte_size=5, + storage_key="objects/11/video.mp4", + path=root / "staged.tmp", + ) + stored = StoredMedia( + sha256=staged.sha256, + byte_size=5, + storage_key=staged.storage_key, + local_path=root / "managed.mp4", + ) + store.stage_local.return_value = staged + store.publish.return_value = stored + probe.probe.return_value = MediaProbe( + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=2, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + ) + catalog.get_media_by_checksum.return_value = None + catalog.put_media.side_effect = lambda item: item + with patch("vidxp.media_service.uuid4") as identifier: + identifier.return_value.hex = MEDIA_ID + result = service.import_quarantined( + QuarantinedMedia( + path=source, + original_filename="client-name.mp4", + declared_mime_type="video/mp4", + ) + ) + + self.assertEqual(result.original_filename, "client-name.mp4") + store.stage_local.assert_called_once_with(source.resolve()) + probe.probe.assert_called_once_with(staged.path) + store.publish.assert_called_once_with(staged) + store.discard.assert_called_once_with(staged) + + def test_quarantined_import_completes_durable_idempotency_record(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "upload.mp4" + source.write_bytes(b"video") + service, catalog, store, probe = self.service(root) + staged = StagedMedia( + sha256="1" * 64, + byte_size=5, + storage_key="objects/11/video.mp4", + path=root / "staged.tmp", + ) + stored = StoredMedia( + sha256=staged.sha256, + byte_size=5, + storage_key=staged.storage_key, + local_path=root / "managed.mp4", + ) + store.stage_local.return_value = staged + store.publish.return_value = stored + probe.probe.return_value = MediaProbe( + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=2, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + ) + catalog.reserve_media_import.return_value = None + catalog.get_media_by_checksum.return_value = None + imported = record() + catalog.put_media.return_value = imported + catalog.get_media.return_value = imported + + result = service.import_quarantined( + QuarantinedMedia( + path=source, + original_filename="upload.mp4", + ), + request_key="request-key", + ) + + self.assertEqual(result.media_id, MEDIA_ID) + fingerprint = catalog.reserve_media_import.call_args.args[1] + self.assertEqual(len(fingerprint), 64) + catalog.complete_media_import.assert_called_once_with( + "request-key", + fingerprint, + imported, + ) + + def test_quarantined_import_rejects_reused_key_for_other_content(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "upload.mp4" + source.write_bytes(b"video") + service, catalog, store, _probe = self.service(root) + staged = StagedMedia( + sha256="2" * 64, + byte_size=5, + storage_key="objects/22/video.mp4", + path=root / "staged.tmp", + ) + store.stage_local.return_value = staged + catalog.reserve_media_import.side_effect = FileExistsError + + with self.assertRaises(MediaIdempotencyConflictError): + service.import_quarantined( + QuarantinedMedia( + path=source, + original_filename="upload.mp4", + ), + request_key="request-key", + ) + + store.publish.assert_not_called() + + def test_local_catalog_persists_media_import_idempotency(self): + with TemporaryDirectory() as directory: + catalog = LocalCatalog(Path(directory) / "catalog.sqlite3") + item = record() + catalog.put_media(item) + + self.assertIsNone( + catalog.reserve_media_import( + "request-key", + "f" * 64, + ) + ) + catalog.complete_media_import( + "request-key", + "f" * 64, + item, + ) + replay = catalog.reserve_media_import( + "request-key", + "f" * 64, + ) + + self.assertEqual(replay, item) + with self.assertRaises(FileExistsError): + catalog.reserve_media_import( + "request-key", + "e" * 64, + ) + def test_existing_checksum_is_reused_without_reprobe(self): with TemporaryDirectory() as directory: root = Path(directory) diff --git a/tests/test_packaging.py b/tests/test_packaging.py index d04f1c9..ac19422 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -73,10 +73,7 @@ def test_capability_extras_read_capability_owned_requirements(self): } self.assertEqual( chroma_contracts, - { - "src/vidxp/requirements/server.txt", - "src/vidxp/requirements/storage.txt", - }, + {"src/vidxp/requirements/storage.txt"}, ) self.assertNotIn("benchmarks/requirements.txt", all_block) self.assertNotIn("requirements/frontend.txt", all_block) @@ -112,8 +109,8 @@ def test_install_profiles_keep_server_free_of_ml_dependencies(self): server = ( ROOT / "src" / "vidxp" / "requirements" / "server.txt" ).read_text(encoding="utf-8") - self.assertIn("chromadb", server) for distribution in ( + "chromadb", "faster-whisper", "opencv-python-headless", "sentence-transformers", @@ -121,6 +118,22 @@ def test_install_profiles_keep_server_free_of_ml_dependencies(self): "transformers", ): self.assertNotIn(distribution, server) + for requirement in ( + "asgi-correlation-id>=5.0.1,<6", + "fastapi>=0.140.13,<0.141", + "pyjwt[crypto]>=2.13,<3", + "python-multipart>=0.0.32,<0.1", + "uvicorn[standard]>=0.51,<0.52", + ): + self.assertIn(requirement, server) + self.assertNotIn("fastapi-mcp", server) + self.assertNotIn("\nmcp", server) + self.assertNotIn("httpx2", server) + test_requirements = ( + ROOT / "src" / "vidxp" / "requirements" / "test.txt" + ).read_text(encoding="utf-8") + self.assertIn("httpx>=0.28.1,<0.29", test_requirements) + self.assertNotIn("httpx2", test_requirements) if __name__ == "__main__": diff --git a/uv.lock b/uv.lock index 17f9e8d..ba5137b 100644 --- a/uv.lock +++ b/uv.lock @@ -199,6 +199,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[package]] +name = "asgi-correlation-id" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/25/df29145ef02ca69548e82b50ed15ac5c438ab5caf7b8e717d2b1258f5bea/asgi_correlation_id-5.0.1.tar.gz", hash = "sha256:ad54374a300f000b8bef3b39738768842394de0a3c528a6f77ef3ca053eb89a1", size = 10642, upload-time = "2026-06-09T15:09:49.112Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/fe/3f5a304ddc2b625a6a2fda913b29bc4675c434016557592791cf1aa39a60/asgi_correlation_id-5.0.1-py3-none-any.whl", hash = "sha256:dfea97c1fc41e8eadc3e518a311427cba6e7e79d9ddb6a6c7ea8879f2408d91d", size = 12026, upload-time = "2026-06-09T15:09:48.155Z" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -336,6 +348,80 @@ 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 = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.9" @@ -474,6 +560,62 @@ 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 = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + [[package]] name = "ctranslate2" version = "4.8.1" @@ -538,6 +680,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, ] +[[package]] +name = "fastapi" +version = "0.140.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/cb/7a4d2c2eb5a5d8a91763c05b7383d72917862e32f780daa0e27ffbb34cc6/fastapi-0.140.13.tar.gz", hash = "sha256:500172a08cf1459901f90b05c37d93060dada3b573fec8f0862445db52ba6b4b", size = 424843, upload-time = "2026-07-28T15:37:00.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/4e/f9e8c762ef5e05c40482131e3d5e8b36bca13fa127578261f1d6b35a25d4/fastapi-0.140.13-py3-none-any.whl", hash = "sha256:8b017110e1e9f30a95e8bdb8f71fbe2f0fe3af5717109e5b14f9e069df54f6d4", size = 131222, upload-time = "2026-07-28T15:37:02.124Z" }, +] + [[package]] name = "faster-whisper" version = "1.2.1" @@ -2325,6 +2483,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/2b/e18ee7c5ee508a82897f021c1981533eca2940b5f072fc6ed0906c03a7a7/pybase64-1.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:debf737e09b8bf832ba86f5ecc3d3dbd0e3021d6cd86ba4abe962d6a5a77adb3", size = 36134, upload-time = "2025-12-06T13:26:47.35Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -2479,6 +2646,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pypika" version = "0.51.1" @@ -3582,28 +3763,37 @@ scene = [ { name = "transformers" }, ] server = [ - { name = "chromadb" }, + { name = "asgi-correlation-id" }, + { name = "fastapi" }, { name = "psutil" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, ] storage = [ { name = "chromadb" }, { name = "psutil" }, ] +test = [ + { name = "httpx" }, +] [package.metadata] requires-dist = [ + { name = "asgi-correlation-id", marker = "extra == 'server'", specifier = ">=5.0.1,<6" }, { name = "chromadb", marker = "extra == 'actor'", specifier = ">=1.5.9,<2" }, { name = "chromadb", marker = "extra == 'all'", specifier = ">=1.5.9,<2" }, { name = "chromadb", marker = "extra == 'dialogue'", specifier = ">=1.5.9,<2" }, { name = "chromadb", marker = "extra == 'local-worker'", specifier = ">=1.5.9,<2" }, { name = "chromadb", marker = "extra == 'scene'", specifier = ">=1.5.9,<2" }, - { name = "chromadb", marker = "extra == 'server'", specifier = ">=1.5.9,<2" }, { name = "chromadb", marker = "extra == 'storage'", specifier = ">=1.5.9,<2" }, { name = "dbos", specifier = ">=2.28,<3" }, + { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.140.13,<0.141" }, { name = "faster-whisper", marker = "extra == 'all'", specifier = ">=1.2.1,<2" }, { name = "faster-whisper", marker = "extra == 'dialogue'", specifier = ">=1.2.1,<2" }, { name = "faster-whisper", marker = "extra == 'local-worker'", specifier = ">=1.2.1,<2" }, { name = "filelock", specifier = ">=3.32,<4" }, + { name = "httpx", marker = "extra == 'test'", specifier = ">=0.28.1,<0.29" }, { name = "huggingface-hub", marker = "extra == 'all'", specifier = ">=1.25.1,<2" }, { name = "huggingface-hub", marker = "extra == 'dialogue'", specifier = ">=1.25.1,<2" }, { name = "huggingface-hub", marker = "extra == 'local-worker'", specifier = ">=1.25.1,<2" }, @@ -3633,6 +3823,8 @@ requires-dist = [ { name = "psutil", marker = "extra == 'storage'", specifier = ">=7.2.2,<8" }, { name = "pydantic", specifier = ">=2.13.4,<3" }, { name = "pydantic-settings", specifier = ">=2.14.2,<3" }, + { name = "pyjwt", extras = ["crypto"], marker = "extra == 'server'", specifier = ">=2.13,<3" }, + { name = "python-multipart", marker = "extra == 'server'", specifier = ">=0.0.32,<0.1" }, { name = "rich", specifier = ">=15,<16" }, { name = "sentence-transformers", marker = "extra == 'all'", specifier = ">=5.6.1,<6" }, { name = "sentence-transformers", marker = "extra == 'dialogue'", specifier = ">=5.6.1,<6" }, @@ -3649,8 +3841,9 @@ requires-dist = [ { name = "transformers", marker = "extra == 'local-worker'", specifier = ">=5.14.1,<6" }, { name = "transformers", marker = "extra == 'scene'", specifier = ">=5.14.1,<6" }, { name = "typer", specifier = ">=0.27,<1" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.51,<0.52" }, ] -provides-extras = ["storage", "dialogue", "scene", "actor", "all", "local-worker", "server", "frontend", "benchmarks"] +provides-extras = ["storage", "dialogue", "scene", "actor", "all", "local-worker", "server", "test", "frontend", "benchmarks"] [[package]] name = "watchdog" From 3e4bbad1629f05e43d64688d498137d8fa448fd2 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 06:28:43 +0500 Subject: [PATCH 08/46] fix: harden local execution boundaries --- changes/local-storage-durability.bugfix.md | 3 + changes/local-worker-lifecycle.bugfix.md | 17 ++ changes/oidc-transport.security.md | 4 + src/vidxp/api.py | 2 +- src/vidxp/cli_commands/jobs.py | 9 + src/vidxp/composition.py | 9 +- src/vidxp/core/manifest.py | 4 +- src/vidxp/frontend.py | 8 +- src/vidxp/infrastructure/dbos_jobs.py | 17 +- src/vidxp/infrastructure/dbos_workflows.py | 19 +- src/vidxp/infrastructure/local_files.py | 45 +++- src/vidxp/infrastructure/local_objects.py | 12 +- src/vidxp/infrastructure/local_worker.py | 205 ++++++++++++++- src/vidxp/job_service.py | 8 + src/vidxp/ports.py | 4 + src/vidxp/settings.py | 88 ++++++- src/vidxp/workflow_runtime.py | 84 +++++- src/vidxp/workflow_worker.py | 92 ++++++- tests/test_api.py | 16 ++ tests/test_authentication.py | 58 +++++ tests/test_dbos_jobs.py | 9 + tests/test_frontend.py | 9 +- tests/test_local_worker.py | 286 ++++++++++++++++++++- tests/test_manifest.py | 4 +- tests/test_media_catalog.py | 60 +++++ 25 files changed, 1006 insertions(+), 66 deletions(-) create mode 100644 changes/local-storage-durability.bugfix.md create mode 100644 changes/local-worker-lifecycle.bugfix.md create mode 100644 changes/oidc-transport.security.md diff --git a/changes/local-storage-durability.bugfix.md b/changes/local-storage-durability.bugfix.md new file mode 100644 index 0000000..71ba186 --- /dev/null +++ b/changes/local-storage-durability.bugfix.md @@ -0,0 +1,3 @@ +Managed media and artifact publication now synchronizes each created parent, +both sides of cross-directory replacement, and every managed removal on +platforms that support directory `fsync`. diff --git a/changes/local-worker-lifecycle.bugfix.md b/changes/local-worker-lifecycle.bugfix.md new file mode 100644 index 0000000..fd8933b --- /dev/null +++ b/changes/local-worker-lifecycle.bugfix.md @@ -0,0 +1,17 @@ +Local execution processes now receive an explicit non-secret settings +projection through a one-use, owner-readable bootstrap file. HTTP +credentials are excluded from both inherited environment variables and +process arguments. + +Worker hardening also adds: + +- startup backoff after a failed launch +- live bounded rotation for repository-scoped worker logs +- cleanup of timed-out worker processes +- executable, build, provider and execution-configuration identity checks + before reusing a worker +- child-side identity verification and DBOS environment isolation +- stale credential-bootstrap cleanup and an explicit local-worker stop command +- readiness checks against the owned local executor +- fail-fast API startup when the owned local executor cannot start +- preserved internal workflow tracebacks while public job errors remain typed diff --git a/changes/oidc-transport.security.md b/changes/oidc-transport.security.md new file mode 100644 index 0000000..75d40ba --- /dev/null +++ b/changes/oidc-transport.security.md @@ -0,0 +1,4 @@ +OIDC issuer and JWKS endpoints must use HTTPS outside explicit loopback +development addresses. Unsafe URL characters that different HTTP parsers can +interpret inconsistently are rejected without rewriting the exact issuer +identifier used for token validation. diff --git a/src/vidxp/api.py b/src/vidxp/api.py index 78d67ac..a00431e 100644 --- a/src/vidxp/api.py +++ b/src/vidxp/api.py @@ -21,7 +21,6 @@ from vidxp.composition import HttpApplicationContext, create_http_application from vidxp.settings import VidXPSettings - def create_app( settings: VidXPSettings | None = None, *, @@ -35,6 +34,7 @@ def create_app( @asynccontextmanager async def lifespan(_app: FastAPI) -> AsyncIterator[None]: try: + active_context.jobs.start() yield finally: if owns_context: diff --git a/src/vidxp/cli_commands/jobs.py b/src/vidxp/cli_commands/jobs.py index 6d3b87a..b7181e9 100644 --- a/src/vidxp/cli_commands/jobs.py +++ b/src/vidxp/cli_commands/jobs.py @@ -94,3 +94,12 @@ def retry_job( state = state_from_context(ctx) emit_json(state.jobs.retry(job_id).model_dump(mode="json")) + + +@app.command("stop-worker") +def stop_worker(ctx: typer.Context) -> None: + """Stop the detached local worker; durable jobs remain recoverable.""" + + state = state_from_context(ctx) + stopped = state.jobs.stop_worker() + emit_json({"stopped": stopped}) diff --git a/src/vidxp/composition.py b/src/vidxp/composition.py index 851f5ef..fc86b4e 100644 --- a/src/vidxp/composition.py +++ b/src/vidxp/composition.py @@ -131,14 +131,21 @@ def create_application( def create_job_service(settings: VidXPSettings) -> JobService: settings.layout.ensure_local_directories() before_access = None + health_check = None + stop_executor = None if settings.mode != ApplicationMode.server: - before_access = LocalWorkerSupervisor(settings).ensure_running + supervisor = LocalWorkerSupervisor(settings) + before_access = supervisor.ensure_running + health_check = supervisor.health + stop_executor = supervisor.stop return JobService( settings=settings, backend=DBOSJobBackend( system_database_url=workflow_database_url(settings), application_version=workflow_application_version(), before_access=before_access, + health_check=health_check, + stop_executor=stop_executor, ), ) diff --git a/src/vidxp/core/manifest.py b/src/vidxp/core/manifest.py index 8176e93..47bd765 100644 --- a/src/vidxp/core/manifest.py +++ b/src/vidxp/core/manifest.py @@ -70,13 +70,13 @@ def write_json_atomic(path: Path, payload: Any) -> None: raise time.sleep(0.05 * (attempt + 1)) - _sync_parent_directory(path.parent) + sync_parent_directory(path.parent) finally: if temporary is not None: temporary.unlink(missing_ok=True) -def _sync_parent_directory(path: Path) -> None: +def sync_parent_directory(path: Path) -> None: if os.name == "nt": return descriptor: int | None = None diff --git a/src/vidxp/frontend.py b/src/vidxp/frontend.py index d15d1d6..dd7150a 100644 --- a/src/vidxp/frontend.py +++ b/src/vidxp/frontend.py @@ -22,7 +22,7 @@ from vidxp.composition import create_application, create_job_service from vidxp.index_state import IndexNotReadyError from vidxp.job_service import JobService -from vidxp.settings import VidXPSettings +from vidxp.settings import LocalExecutionSettings, VidXPSettings LOGGER = logging.getLogger(__name__) @@ -50,7 +50,9 @@ def _settings_from_arguments( ) if parsed.vidxp_settings_json is None: return VidXPSettings() - return VidXPSettings.model_validate_json(parsed.vidxp_settings_json) + return LocalExecutionSettings.model_validate_json( + parsed.vidxp_settings_json + ).application_settings() INDEX_REQUESTED_KEY = "_vidxp_index_requested" @@ -579,7 +581,7 @@ def main( application_arguments = [ "--", "--vidxp-settings-json", - settings.model_dump_json(), + LocalExecutionSettings.from_settings(settings).model_dump_json(), ] sys.argv = [ "streamlit", diff --git a/src/vidxp/infrastructure/dbos_jobs.py b/src/vidxp/infrastructure/dbos_jobs.py index 35f2c82..b670254 100644 --- a/src/vidxp/infrastructure/dbos_jobs.py +++ b/src/vidxp/infrastructure/dbos_jobs.py @@ -124,14 +124,21 @@ def __init__( system_database_url: str, application_version: str, before_access: Callable[[], None] | None = None, + health_check: Callable[[], None] | None = None, + stop_executor: Callable[[], bool] | None = None, ) -> None: self.client = DBOSClient(system_database_url=system_database_url) self.application_version = application_version self.before_access = before_access + self.health_check = health_check + self.stop_executor = stop_executor def close(self) -> None: self.client.destroy() + def start(self) -> None: + self._prepare_access() + def submit( self, request: JobRequest, @@ -261,9 +268,17 @@ def retry( return self.get(retried_id) def health(self) -> None: - self._prepare_access() + if self.health_check is not None: + self.health_check() self.client.list_workflows(limit=1, load_input=False, load_output=False) + def stop_worker(self) -> bool: + if self.stop_executor is None: + raise RuntimeError( + "This workflow backend does not own a local worker." + ) + return self.stop_executor() + def _prepare_access(self) -> None: if self.before_access is not None: self.before_access() diff --git a/src/vidxp/infrastructure/dbos_workflows.py b/src/vidxp/infrastructure/dbos_workflows.py index e4df155..41270f6 100644 --- a/src/vidxp/infrastructure/dbos_workflows.py +++ b/src/vidxp/infrastructure/dbos_workflows.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from collections.abc import Callable from datetime import datetime, timezone from time import monotonic @@ -35,6 +36,7 @@ _ARTIFACT_REQUEST = TypeAdapter( ActorOverlayJobRequest | SnippetJobRequest ) +LOGGER = logging.getLogger(__name__) class _DBOSCancellationEvent: @@ -89,7 +91,7 @@ def publish(event: dict[str, Any]) -> None: ) -def _publish_error(exc: BaseException) -> None: +def _publish_error(exc: Exception) -> None: if isinstance(exc, ApplicationError): error = exc.detail else: @@ -108,9 +110,20 @@ def _step_boundary( return operation() except IndexCancelledError: raise - except BaseException as exc: + except Exception as exc: + if isinstance(exc, ApplicationError): + LOGGER.warning( + "VidXP workflow %s failed with %s.", + DBOS.workflow_id, + exc.detail.code, + ) + else: + LOGGER.exception( + "VidXP workflow %s failed unexpectedly.", + DBOS.workflow_id, + ) _publish_error(exc) - raise RuntimeError("VidXP job execution failed.") from None + raise RuntimeError("VidXP job execution failed.") from exc @DBOS.dbos_class(class_name=WORKFLOW_CLASS_NAME) diff --git a/src/vidxp/infrastructure/local_files.py b/src/vidxp/infrastructure/local_files.py index fdc1cb0..66650c9 100644 --- a/src/vidxp/infrastructure/local_files.py +++ b/src/vidxp/infrastructure/local_files.py @@ -2,6 +2,7 @@ from pathlib import Path, PurePosixPath +from vidxp.core.manifest import sync_parent_directory from vidxp.core.storage_keys import validate_storage_key @@ -29,7 +30,7 @@ def prepare_managed_destination(root: Path, storage_key: str) -> Path: """Create a confined parent tree and reject pre-existing links.""" validate_storage_key(storage_key) - root.mkdir(parents=True, exist_ok=True) + _ensure_directory(root) resolved_root = root.resolve(strict=True) relative = PurePosixPath(storage_key) current = root @@ -42,7 +43,7 @@ def prepare_managed_destination(root: Path, storage_key: str) -> Path: "Managed storage parent links are not permitted." ) else: - current.mkdir() + _ensure_directory(current) if not current.resolve(strict=True).is_relative_to(resolved_root): raise PermissionError("Managed storage escaped its configured root.") @@ -52,3 +53,43 @@ def prepare_managed_destination(root: Path, storage_key: str) -> Path: if destination.is_symlink() or is_junction() or not destination.is_file(): raise PermissionError("Managed storage links are not permitted.") return destination + + +def _ensure_directory(path: Path) -> None: + missing = [] + current = path + while not current.exists(): + missing.append(current) + current = current.parent + for directory in reversed(missing): + try: + directory.mkdir() + except FileExistsError: + if not directory.is_dir(): + raise + else: + sync_parent_directory(directory.parent) + + +def durable_replace(source: Path, destination: Path) -> None: + """Replace a file and synchronize both affected directory entries.""" + + source_parent = source.parent + destination_parent = destination.parent + source.replace(destination) + sync_parent_directory(destination_parent) + if source_parent != destination_parent: + sync_parent_directory(source_parent) + + +def durable_unlink(path: Path, *, missing_ok: bool = False) -> bool: + """Remove a file and synchronize the removed directory entry.""" + + try: + path.unlink() + except FileNotFoundError: + if missing_ok: + return False + raise + sync_parent_directory(path.parent) + return True diff --git a/src/vidxp/infrastructure/local_objects.py b/src/vidxp/infrastructure/local_objects.py index 09a5576..7c7bf24 100644 --- a/src/vidxp/infrastructure/local_objects.py +++ b/src/vidxp/infrastructure/local_objects.py @@ -5,6 +5,8 @@ from vidxp.core.manifest import sha256_file from vidxp.infrastructure.local_files import ( + durable_replace, + durable_unlink, prepare_managed_destination, resolve_managed_file, ) @@ -38,23 +40,23 @@ def publish( try: if destination.exists(): if sha256_file(destination) == checksum: - source.unlink() + durable_unlink(source) elif replace_corrupt: - source.replace(destination) + durable_replace(source, destination) published = True else: raise FileExistsError( "The managed object destination already exists." ) else: - source.replace(destination) + durable_replace(source, destination) published = True resolved = destination.resolve(strict=True) byte_size = resolved.stat().st_size return resolved, checksum, byte_size except BaseException: if created and published: - destination.unlink(missing_ok=True) + durable_unlink(destination, missing_ok=True) raise def verify( @@ -72,7 +74,7 @@ def verify( return path def delete(self, storage_key: str) -> None: - self.resolve(storage_key).unlink() + durable_unlink(self.resolve(storage_key)) def resolve(self, storage_key: str) -> Path: return resolve_managed_file(self.root, storage_key) diff --git a/src/vidxp/infrastructure/local_worker.py b/src/vidxp/infrastructure/local_worker.py index e147d40..d15522d 100644 --- a/src/vidxp/infrastructure/local_worker.py +++ b/src/vidxp/infrastructure/local_worker.py @@ -1,18 +1,25 @@ from __future__ import annotations import os +import signal import subprocess import sys from pathlib import Path +from secrets import token_hex +from threading import Lock from time import monotonic, sleep from filelock import FileLock, Timeout +from pydantic import ValidationError +from vidxp.infrastructure.local_files import durable_unlink from vidxp.settings import VidXPSettings from vidxp.workflow_runtime import ( - LOCAL_WORKER_SETTINGS_ENV, + LOCAL_WORKER_BOOTSTRAP_ENV, + LocalWorkerBootstrap, + LocalWorkerReady, + local_worker_bootstrap, local_executor_id, - workflow_database_url, workflow_application_version, ) @@ -20,13 +27,33 @@ class LocalWorkerSupervisor: """Start one detached repository-scoped worker without owning job state.""" + _retry_delay_seconds = 5.0 + def __init__(self, settings: VidXPSettings) -> None: self.settings = settings self.layout = settings.layout + self._startup_lock = Lock() + self._retry_after = 0.0 def ensure_running(self) -> None: + with self._startup_lock: + if monotonic() < self._retry_after: + raise RuntimeError( + "The local background worker is in startup backoff." + ) + try: + self._ensure_running() + except Exception: + self._retry_after = ( + monotonic() + self._retry_delay_seconds + ) + raise + self._retry_after = 0.0 + + def _ensure_running(self) -> None: self.layout.ensure_local_directories() version = workflow_application_version() + bootstrap = local_worker_bootstrap(self.settings) worker_lock_path = ( self.layout.local_workflows / f"worker-{version}.lock" ) @@ -37,17 +64,20 @@ def ensure_running(self) -> None: self.layout.local_workflows / f"worker-{version}-start.lock" ) with start_lock: + self._remove_stale_bootstraps(version) worker_lock = FileLock(worker_lock_path) - if self._wait_for_existing_worker(worker_lock, ready_path): + if self._wait_for_existing_worker( + worker_lock, + ready_path, + fingerprint=bootstrap.fingerprint, + ): return - ready_path.unlink(missing_ok=True) + durable_unlink(ready_path, missing_ok=True) command = [ sys.executable, "-m", "vidxp.workflow_worker", - "--database-url", - workflow_database_url(self.settings), "--executor-id", local_executor_id(self.settings), "--role", @@ -56,10 +86,17 @@ def ensure_running(self) -> None: str(worker_lock_path.resolve()), "--ready-file", str(ready_path.resolve()), + "--log-file", + str(self._worker_log(version).resolve()), ] - environment = os.environ.copy() - environment[LOCAL_WORKER_SETTINGS_ENV] = ( - self.settings.model_dump_json() + bootstrap_path = self._write_bootstrap(bootstrap) + environment = { + key: value + for key, value in os.environ.items() + if not key.upper().startswith(("VIDXP_", "DBOS_")) + } + environment[LOCAL_WORKER_BOOTSTRAP_ENV] = str( + bootstrap_path.resolve() ) options: dict = { "stdin": subprocess.DEVNULL, @@ -76,14 +113,119 @@ def ensure_running(self) -> None: ) else: options["start_new_session"] = True - process = subprocess.Popen(command, **options) - self._wait_for_worker(process, worker_lock, ready_path) + process = None + try: + process = subprocess.Popen( + command, + **options, + ) + self._wait_for_worker( + process, + worker_lock, + ready_path, + fingerprint=bootstrap.fingerprint, + ) + except Exception: + if process is not None: + self._terminate(process) + raise + finally: + durable_unlink(bootstrap_path, missing_ok=True) + + def _worker_log(self, version: str) -> Path: + return self.layout.local_workflows / f"worker-{version}.log" + + def _write_bootstrap( + self, + bootstrap: LocalWorkerBootstrap, + ) -> Path: + path = ( + self.layout.local_workflows + / ( + f".worker-{workflow_application_version()}-" + f"bootstrap-{token_hex(16)}.json" + ) + ) + descriptor = os.open( + path, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as destination: + destination.write(bootstrap.model_dump_json()) + destination.flush() + os.fsync(destination.fileno()) + except BaseException: + durable_unlink(path, missing_ok=True) + raise + return path + + def _remove_stale_bootstraps(self, version: str) -> None: + pattern = f".worker-{version}-bootstrap-*.json" + for path in self.layout.local_workflows.glob(pattern): + if path.is_file(): + durable_unlink(path) + + def health(self) -> None: + version = workflow_application_version() + worker_lock = FileLock( + self.layout.local_workflows / f"worker-{version}.lock" + ) + ready_path = ( + self.layout.local_workflows / f"worker-{version}.ready" + ) + fingerprint = local_worker_bootstrap(self.settings).fingerprint + if not self._wait_for_existing_worker( + worker_lock, + ready_path, + fingerprint=fingerprint, + timeout_seconds=0.1, + ): + raise RuntimeError("The local background worker is not running.") + + def stop(self) -> bool: + version = workflow_application_version() + worker_lock = FileLock( + self.layout.local_workflows / f"worker-{version}.lock" + ) + ready_path = ( + self.layout.local_workflows / f"worker-{version}.ready" + ) + try: + worker_lock.acquire(timeout=0) + except Timeout: + ready = self._load_ready(ready_path) + if ready.application_version != version: + raise RuntimeError( + "The running local worker has an invalid version identity." + ) + os.kill(ready.pid, signal.SIGTERM) + deadline = monotonic() + 5 + while monotonic() < deadline: + try: + worker_lock.acquire(timeout=0) + except Timeout: + sleep(0.05) + continue + else: + worker_lock.release() + durable_unlink(ready_path, missing_ok=True) + return True + raise RuntimeError( + "The local background worker did not stop in time." + ) + else: + worker_lock.release() + durable_unlink(ready_path, missing_ok=True) + return False @staticmethod def _wait_for_existing_worker( worker_lock: FileLock, ready_path: Path, *, + fingerprint: str, timeout_seconds: float = 5, ) -> bool: deadline = monotonic() + timeout_seconds @@ -92,6 +234,10 @@ def _wait_for_existing_worker( worker_lock.acquire(timeout=0) except Timeout: if ready_path.is_file(): + LocalWorkerSupervisor._validate_ready( + ready_path, + fingerprint, + ) return True sleep(0.05) continue @@ -108,6 +254,7 @@ def _wait_for_worker( worker_lock: FileLock, ready_path: Path, *, + fingerprint: str, timeout_seconds: float = 5, ) -> None: deadline = monotonic() + timeout_seconds @@ -120,6 +267,10 @@ def _wait_for_worker( worker_lock.acquire(timeout=0) except Timeout: if ready_path.is_file(): + LocalWorkerSupervisor._validate_ready( + ready_path, + fingerprint, + ) return else: worker_lock.release() @@ -127,3 +278,35 @@ def _wait_for_worker( raise RuntimeError( "The local background worker did not become ready in time." ) + + @staticmethod + def _validate_ready(ready_path: Path, fingerprint: str) -> None: + ready = LocalWorkerSupervisor._load_ready(ready_path) + if ready.fingerprint != fingerprint: + raise RuntimeError( + "The running local worker uses different execution settings " + f"(PID {ready.pid}); run `vidxp jobs stop-worker` before " + "applying the new configuration." + ) + + @staticmethod + def _load_ready(ready_path: Path) -> LocalWorkerReady: + try: + return LocalWorkerReady.model_validate_json( + ready_path.read_text(encoding="utf-8") + ) + except (OSError, ValidationError) as exc: + raise RuntimeError( + "The local background worker published invalid readiness." + ) from exc + + @staticmethod + def _terminate(process: subprocess.Popen) -> None: + if process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) diff --git a/src/vidxp/job_service.py b/src/vidxp/job_service.py index a54aa5e..566b2c3 100644 --- a/src/vidxp/job_service.py +++ b/src/vidxp/job_service.py @@ -73,6 +73,10 @@ def __init__( self.settings = settings self.backend = backend + @job_boundary + def start(self) -> None: + self.backend.start() + @job_boundary def submit_index( self, @@ -241,5 +245,9 @@ def readiness(self) -> ComponentReadiness: message="The durable workflow database is available.", ) + @job_boundary + def stop_worker(self) -> bool: + return self.backend.stop_worker() + def close(self) -> None: self.backend.close() diff --git a/src/vidxp/ports.py b/src/vidxp/ports.py index ff92bad..e03c02b 100644 --- a/src/vidxp/ports.py +++ b/src/vidxp/ports.py @@ -347,6 +347,8 @@ def clear(self, config: IndexConfig) -> bool: ... class JobBackend(Protocol): """Durable lifecycle operations owned by the workflow engine.""" + def start(self) -> None: ... + def submit( self, request: JobRequest, @@ -370,4 +372,6 @@ def retry( def health(self) -> None: ... + def stop_worker(self) -> bool: ... + def close(self) -> None: ... diff --git a/src/vidxp/settings.py b/src/vidxp/settings.py index 92cffb3..2ab14a2 100644 --- a/src/vidxp/settings.py +++ b/src/vidxp/settings.py @@ -4,13 +4,14 @@ import re from enum import StrEnum from pathlib import Path +from urllib.parse import urlsplit from platformdirs import user_cache_path from pydantic import ( + BaseModel, + ConfigDict, Field, - HttpUrl, SecretStr, - TypeAdapter, ValidationInfo, field_validator, model_validator, @@ -32,9 +33,6 @@ class HttpAuthMode(StrEnum): oidc = "oidc" -_HTTP_URL = TypeAdapter(HttpUrl) - - class VidXPSettings(BaseSettings): model_config = SettingsConfigDict( env_prefix="VIDXP_", @@ -214,12 +212,37 @@ def _validate_oidc_url( return None if value != value.strip(): raise ValueError(f"{info.field_name} must not contain whitespace.") - parsed = _HTTP_URL.validate_python(value) + if "\\" in value or any( + character.isspace() + or ord(character) < 32 + or ord(character) == 127 + for character in value + ): + raise ValueError( + f"{info.field_name} contains an unsafe URL character." + ) + parsed = urlsplit(value) + if parsed.scheme not in {"http", "https"} or parsed.hostname is None: + raise ValueError(f"{info.field_name} must be an HTTP URL.") + try: + parsed.port + except ValueError as exc: + raise ValueError( + f"{info.field_name} contains an invalid port." + ) from exc if parsed.username is not None or parsed.password is not None: raise ValueError(f"{info.field_name} must not contain credentials.") - if parsed.fragment is not None: + if "#" in value: raise ValueError(f"{info.field_name} must not contain a fragment.") - if info.field_name == "http_oidc_issuer" and parsed.query is not None: + if parsed.scheme != "https" and parsed.hostname.lower() not in { + "localhost", + "127.0.0.1", + "::1", + }: + raise ValueError( + f"{info.field_name} must use HTTPS outside loopback." + ) + if info.field_name == "http_oidc_issuer" and "?" in value: raise ValueError("http_oidc_issuer must not contain a query.") return value @@ -314,3 +337,52 @@ def validate_http_server(self) -> None: @property def layout(self) -> RepositoryLayout: return RepositoryLayout(root=self.repository_root.expanduser()) + + +class LocalExecutionSettings(BaseModel): + """Non-secret settings allowed to cross into local execution processes.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + repository_root: Path + runtime_backend: str + model_cache: Path + allow_model_downloads: bool + max_loaded_models: int + max_concurrent_indexing: int + max_concurrent_inference: int + workflow_poll_interval_seconds: float + cpu_thread_budget: int + minimum_available_memory_mb: int + max_local_import_bytes: int + max_snippet_duration_seconds: float + trusted_local_import_roots: tuple[Path, ...] + ffprobe_executable: str + ffmpeg_executable: str + external_capabilities: bool + capability_allowlist: tuple[str, ...] + + @classmethod + def from_settings( + cls, + settings: VidXPSettings, + ) -> "LocalExecutionSettings": + return cls( + **settings.model_dump( + include=set(cls.model_fields), + mode="python", + ) + ) + + def application_settings(self) -> VidXPSettings: + defaults = VidXPSettings.model_construct().model_dump(mode="python") + defaults.update(self.model_dump(mode="python")) + defaults["mode"] = ApplicationMode.local + defaults["workflow_database_url"] = None + defaults["http_auth_mode"] = HttpAuthMode.none + defaults["http_static_bearer_token"] = None + defaults["http_oidc_issuer"] = None + defaults["http_oidc_audience"] = None + defaults["http_oidc_jwks_url"] = None + defaults["http_required_scopes"] = () + return VidXPSettings(**defaults) diff --git a/src/vidxp/workflow_runtime.py b/src/vidxp/workflow_runtime.py index 793e749..5130dd3 100644 --- a/src/vidxp/workflow_runtime.py +++ b/src/vidxp/workflow_runtime.py @@ -1,13 +1,45 @@ from __future__ import annotations +import hashlib +import json +import sys +from functools import lru_cache from pathlib import Path from urllib.parse import urlsplit +from pydantic import BaseModel, ConfigDict, Field + from vidxp import __version__ -from vidxp.settings import ApplicationMode, VidXPSettings +from vidxp.capabilities.registry import create_capability_registry +from vidxp.core.manifest import dependency_versions, implementation_digest +from vidxp.settings import ( + ApplicationMode, + LocalExecutionSettings, + VidXPSettings, +) + + +LOCAL_WORKER_BOOTSTRAP_ENV = "VIDXP_LOCAL_WORKER_BOOTSTRAP_FILE" + + +class LocalWorkerBootstrap(BaseModel): + """One-use local worker configuration stored outside process arguments.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + settings: LocalExecutionSettings + database_url: str = Field(min_length=1) + fingerprint: str = Field(pattern=r"^[a-f0-9]{64}$") -LOCAL_WORKER_SETTINGS_ENV = "VIDXP_LOCAL_WORKER_SETTINGS_JSON" +class LocalWorkerReady(BaseModel): + """Non-secret identity published by a ready local worker.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + pid: int = Field(gt=0) + application_version: str = Field(min_length=1) + fingerprint: str = Field(pattern=r"^[a-f0-9]{64}$") def workflow_database_url(settings: VidXPSettings) -> str: @@ -33,6 +65,54 @@ def workflow_application_version() -> str: return __version__ +@lru_cache(maxsize=16) +def _local_execution_provenance( + external_capabilities: bool, + capability_allowlist: tuple[str, ...], +) -> dict: + registry = create_capability_registry( + external=external_capabilities, + allowlist=capability_allowlist, + ) + return { + "executable": str(Path(sys.executable).resolve()), + "implementation_sha256": implementation_digest(), + "python": sys.version, + "dependencies": dependency_versions(registry), + } + + +def local_worker_bootstrap( + settings: VidXPSettings, + *, + database_url: str | None = None, +) -> LocalWorkerBootstrap: + local_settings = LocalExecutionSettings.from_settings(settings) + active_database_url = database_url or workflow_database_url(settings) + identity = { + "application_version": workflow_application_version(), + "database_url": active_database_url, + "execution": _local_execution_provenance( + settings.external_capabilities, + settings.capability_allowlist, + ), + "settings": local_settings.model_dump(mode="json"), + } + fingerprint = hashlib.sha256( + json.dumps( + identity, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + ).hexdigest() + return LocalWorkerBootstrap( + settings=local_settings, + database_url=active_database_url, + fingerprint=fingerprint, + ) + + def local_executor_id(settings: VidXPSettings) -> str: if settings.mode == ApplicationMode.server: raise ValueError("A local executor ID cannot be used in server mode.") diff --git a/src/vidxp/workflow_worker.py b/src/vidxp/workflow_worker.py index 9b84a22..9f71de2 100644 --- a/src/vidxp/workflow_worker.py +++ b/src/vidxp/workflow_worker.py @@ -1,9 +1,11 @@ from __future__ import annotations import argparse +import logging import os -from threading import Event +from logging.handlers import RotatingFileHandler from pathlib import Path +from threading import Event from typing import Callable, Sequence from dbos import DBOS, DBOSConfig @@ -11,16 +13,24 @@ from vidxp.application_models import JobQueue from vidxp.composition import create_application +from vidxp.core.manifest import write_json_atomic from vidxp.infrastructure.dbos_workflows import VidXPWorkerWorkflows +from vidxp.infrastructure.local_files import durable_unlink from vidxp.settings import VidXPSettings from vidxp.workflow_contracts import APPLICATION_NAME, QUEUE_NAMES from vidxp.workflow_runtime import ( - LOCAL_WORKER_SETTINGS_ENV, + LOCAL_WORKER_BOOTSTRAP_ENV, + LocalWorkerBootstrap, + LocalWorkerReady, + local_worker_bootstrap, server_executor_id, workflow_application_version, workflow_database_url, ) +LOGGER = logging.getLogger(__name__) +_MAXIMUM_LOG_BYTES = 5 * 1024 * 1024 + def _arguments(values: Sequence[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description="Run VidXP DBOS workflows.") @@ -30,9 +40,41 @@ def _arguments(values: Sequence[str] | None = None) -> argparse.Namespace: parser.add_argument("--ordinal", type=int, default=0) parser.add_argument("--lock-file", type=Path) parser.add_argument("--ready-file", type=Path) + parser.add_argument("--log-file", type=Path) return parser.parse_args(values) +def _configure_logging(path: Path | None) -> None: + if path is None: + return + path.parent.mkdir(parents=True, exist_ok=True) + handler = RotatingFileHandler( + path, + maxBytes=_MAXIMUM_LOG_BYTES, + backupCount=2, + encoding="utf-8", + ) + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", + handlers=[handler], + force=True, + ) + + +def _local_bootstrap() -> LocalWorkerBootstrap | None: + raw_path = os.environ.pop(LOCAL_WORKER_BOOTSTRAP_ENV, None) + if raw_path is None: + return None + path = Path(raw_path) + try: + return LocalWorkerBootstrap.model_validate_json( + path.read_text(encoding="utf-8") + ) + finally: + durable_unlink(path, missing_ok=True) + + def _resolved_database_url( settings: VidXPSettings, override: str | None, @@ -82,13 +124,28 @@ def run_worker( def main(arguments: Sequence[str] | None = None) -> None: options = _arguments(arguments) - local_settings = os.environ.pop(LOCAL_WORKER_SETTINGS_ENV, None) + _configure_logging(options.log_file) + bootstrap = _local_bootstrap() settings = ( - VidXPSettings.model_validate_json(local_settings) - if local_settings is not None + bootstrap.settings.application_settings() + if bootstrap is not None else VidXPSettings() ) - database_url = _resolved_database_url(settings, options.database_url) + if bootstrap is not None: + expected_bootstrap = local_worker_bootstrap( + settings, + database_url=bootstrap.database_url, + ) + if expected_bootstrap.fingerprint != bootstrap.fingerprint: + raise ValueError( + "The local worker bootstrap identity is invalid." + ) + database_override = ( + bootstrap.database_url + if bootstrap is not None + else options.database_url + ) + database_url = _resolved_database_url(settings, database_override) role = options.role executor_id = options.executor_id if executor_id is None: @@ -114,10 +171,19 @@ def main(arguments: Sequence[str] | None = None) -> None: def mark_ready() -> None: if ready_file is None: return + if bootstrap is None: + raise RuntimeError( + "A supervised local worker requires bootstrap identity." + ) ready_file.parent.mkdir(parents=True, exist_ok=True) - temporary = ready_file.with_suffix(".tmp") - temporary.write_text(str(os.getpid()), encoding="utf-8") - temporary.replace(ready_file) + write_json_atomic( + ready_file, + LocalWorkerReady( + pid=os.getpid(), + application_version=workflow_application_version(), + fingerprint=bootstrap.fingerprint, + ).model_dump(mode="json"), + ) with lock: try: @@ -130,8 +196,12 @@ def mark_ready() -> None: ) finally: if ready_file is not None: - ready_file.unlink(missing_ok=True) + durable_unlink(ready_file, missing_ok=True) if __name__ == "__main__": - main() + try: + main() + except Exception: + LOGGER.exception("The VidXP workflow worker exited unexpectedly.") + raise diff --git a/tests/test_api.py b/tests/test_api.py index 7d2c24d..053ecae 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -643,6 +643,22 @@ def test_owned_context_is_closed_when_lifespan_exits(self): owned.jobs.close.assert_called_once_with() + def test_local_worker_startup_failure_fails_api_startup(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.jobs.start.side_effect = RuntimeError( + "transient worker failure" + ) + + with self.assertRaisesRegex( + RuntimeError, + "transient worker failure", + ): + with TestClient(create_app(context=context)): + pass + + context.jobs.start.assert_called_once_with() + def test_http_composition_does_not_construct_model_runtime(self): with TemporaryDirectory(ignore_cleanup_errors=True) as directory: settings = VidXPSettings( diff --git a/tests/test_authentication.py b/tests/test_authentication.py index e53a166..3aee420 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -219,6 +219,64 @@ def test_oidc_settings_preserve_exact_issuer(self): "https://issuer.example", ) + def test_oidc_settings_preserve_uppercase_scheme_for_exact_issuer(self): + settings = VidXPSettings( + runtime_backend="cpu", + http_auth_mode="oidc", + http_oidc_issuer="HTTPS://issuer.example", + http_oidc_audience="https://api.example", + http_oidc_jwks_url="https://issuer.example/jwks", + http_required_scopes=("vidxp.read",), + ) + + self.assertEqual( + settings.http_oidc_issuer, + "HTTPS://issuer.example", + ) + + def test_oidc_rejects_empty_query_and_fragment_delimiters(self): + for field, value in ( + ("http_oidc_issuer", "https://issuer.example?"), + ("http_oidc_issuer", "https://issuer.example#"), + ("http_oidc_jwks_url", "https://issuer.example/jwks#"), + ): + values = { + "runtime_backend": "cpu", + "http_auth_mode": "oidc", + "http_oidc_issuer": "https://issuer.example", + "http_oidc_audience": "https://api.example", + "http_oidc_jwks_url": "https://issuer.example/jwks", + "http_required_scopes": ("vidxp.read",), + field: value, + } + with self.subTest(field=field, value=value): + with self.assertRaises(ValueError): + VidXPSettings(**values) + + def test_oidc_rejects_cleartext_non_loopback_metadata(self): + with self.assertRaisesRegex(ValueError, "must use HTTPS"): + VidXPSettings( + runtime_backend="cpu", + http_auth_mode="oidc", + http_oidc_issuer="http://issuer.example", + http_oidc_audience="https://api.example", + http_oidc_jwks_url="http://issuer.example/jwks", + http_required_scopes=("vidxp.read",), + ) + + def test_oidc_rejects_url_parser_disagreement_characters(self): + with self.assertRaisesRegex(ValueError, "unsafe URL character"): + VidXPSettings( + runtime_backend="cpu", + http_auth_mode="oidc", + http_oidc_issuer="http://localhost", + http_oidc_audience="https://api.example", + http_oidc_jwks_url=( + "http://localhost\\@evil.example/jwks" + ), + http_required_scopes=("vidxp.read",), + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_dbos_jobs.py b/tests/test_dbos_jobs.py index d0cd243..61837b5 100644 --- a/tests/test_dbos_jobs.py +++ b/tests/test_dbos_jobs.py @@ -122,6 +122,15 @@ def create_index(command, *, execution): self.assertEqual(page.items[0].job_id, submitted.job_id) self.application.create_index.assert_called_once() + def test_health_requires_owned_executor_health(self): + health_check = Mock(side_effect=RuntimeError("worker unavailable")) + self.backend.health_check = health_check + + with self.assertRaisesRegex(RuntimeError, "worker unavailable"): + self.backend.health() + + health_check.assert_called_once_with() + def test_submission_idempotency_replays_only_the_same_request(self): command = CreateIndexCommand( media_id=MEDIA_ID, diff --git a/tests/test_frontend.py b/tests/test_frontend.py index e185b57..8210f57 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -7,7 +7,7 @@ from vidxp.application_models import DependencyCheckResult, IndexStatus from vidxp.capabilities.registry import create_capability_registry from vidxp.capabilities.schemas import SearchHit, SearchResult -from vidxp.settings import VidXPSettings +from vidxp.settings import LocalExecutionSettings, VidXPSettings MEDIA_ID = "123456781234423481234567890abcde" @@ -58,7 +58,12 @@ def test_service_is_composed_lazily_and_cached(self): capability_allowlist=("acme:ocr",), ) settings = frontend._settings_from_arguments( - ("--vidxp-settings-json", expected.model_dump_json()) + ( + "--vidxp-settings-json", + LocalExecutionSettings.from_settings( + expected + ).model_dump_json(), + ) ) first = frontend._configured_service(settings) second = frontend._configured_service(settings) diff --git a/tests/test_local_worker.py b/tests/test_local_worker.py index fe3f78e..90b31b9 100644 --- a/tests/test_local_worker.py +++ b/tests/test_local_worker.py @@ -16,13 +16,18 @@ from vidxp.infrastructure.dbos_jobs import DBOSJobBackend from vidxp.infrastructure.local_worker import LocalWorkerSupervisor from vidxp.job_service import JobService -from vidxp.settings import VidXPSettings +from vidxp.settings import LocalExecutionSettings, VidXPSettings +from vidxp.workflow_worker import _configure_logging from vidxp.workflow_runtime import ( - LOCAL_WORKER_SETTINGS_ENV, + LOCAL_WORKER_BOOTSTRAP_ENV, + LocalWorkerBootstrap, + LocalWorkerReady, + local_worker_bootstrap, local_executor_id, workflow_application_version, workflow_database_url, ) +from vidxp.workflow_worker import main as workflow_worker_main class LocalWorkerSupervisorTests(unittest.TestCase): @@ -31,13 +36,44 @@ def test_worker_is_spawned_detached_without_job_state(self): settings = VidXPSettings( repository_root=Path(directory), runtime_backend="cpu", + http_auth_mode="static", + http_static_bearer_token="s" * 32, ) supervisor = LocalWorkerSupervisor(settings) + captured_bootstrap = None + + def spawn(command, **options): + nonlocal captured_bootstrap + bootstrap_path = Path( + options["env"][LOCAL_WORKER_BOOTSTRAP_ENV] + ) + captured_bootstrap = LocalWorkerBootstrap.model_validate_json( + bootstrap_path.read_text(encoding="utf-8") + ) + process = Mock() + process.poll.return_value = None + return process + with ( patch( - "vidxp.infrastructure.local_worker.subprocess.Popen" + "vidxp.infrastructure.local_worker.subprocess.Popen", + side_effect=spawn, ) as popen, patch.object(supervisor, "_wait_for_worker") as wait, + patch.dict( + os.environ, + { + "VIDXP_HTTP_AUTH_MODE": "static", + "VIDXP_HTTP_STATIC_BEARER_TOKEN": "e" * 32, + "VIDXP_WORKFLOW_DATABASE_URL": ( + "postgresql://user:password@db/vidxp" + ), + "DBOS__CLOUD": "true", + "DBOS_SYSTEM_DATABASE_URL": ( + "postgresql://other:secret@db/other" + ), + }, + ), ): supervisor.ensure_running() @@ -47,16 +83,29 @@ def test_worker_is_spawned_detached_without_job_state(self): self.assertIn("--lock-file", command) lock_file = command[command.index("--lock-file") + 1] self.assertIn(workflow_application_version(), lock_file) - self.assertNotIn("--settings-json", command) + self.assertNotIn("--database-url", command) + self.assertNotIn("password", " ".join(command)) self.assertIs(options["stdin"], subprocess.DEVNULL) self.assertIs(options["stdout"], subprocess.DEVNULL) - worker_settings = VidXPSettings.model_validate_json( - options["env"][LOCAL_WORKER_SETTINGS_ENV] + self.assertIs(options["stderr"], subprocess.DEVNULL) + self.assertFalse( + any( + key.upper().startswith(("VIDXP_", "DBOS_")) + for key in options["env"] + if key != LOCAL_WORKER_BOOTSTRAP_ENV + ) ) + self.assertIsNotNone(captured_bootstrap) + serialized = captured_bootstrap.settings.model_dump_json() + self.assertNotIn("s" * 32, serialized) + self.assertNotIn("e" * 32, serialized) + self.assertNotIn("http_static_bearer_token", serialized) + worker_settings = captured_bootstrap.settings.application_settings() self.assertEqual( worker_settings.repository_root, settings.repository_root, ) + self.assertEqual(worker_settings.http_auth_mode, "none") wait.assert_called_once() if sys.platform == "win32": self.assertTrue( @@ -65,6 +114,26 @@ def test_worker_is_spawned_detached_without_job_state(self): else: self.assertTrue(options["start_new_session"]) + def test_application_settings_ignore_inherited_vidxp_environment(self): + local = LocalExecutionSettings.from_settings( + VidXPSettings(runtime_backend="cpu") + ) + with patch.dict( + os.environ, + { + "VIDXP_HTTP_AUTH_MODE": "static", + "VIDXP_HTTP_STATIC_BEARER_TOKEN": "e" * 32, + "VIDXP_WORKFLOW_DATABASE_URL": ( + "postgresql://user:password@db/vidxp" + ), + }, + ): + settings = local.application_settings() + + self.assertEqual(settings.http_auth_mode, "none") + self.assertIsNone(settings.http_static_bearer_token) + self.assertIsNone(settings.workflow_database_url) + def test_startup_wait_finishes_when_worker_owns_lock(self): with TemporaryDirectory() as directory: ready_path = Path(directory) / "worker.ready" @@ -73,15 +142,201 @@ def test_startup_wait_finishes_when_worker_owns_lock(self): process.poll.return_value = None worker_lock = Mock() worker_lock.acquire.side_effect = Timeout("worker.lock") + fingerprint = "a" * 64 + ready_path.write_text( + LocalWorkerReady( + pid=123, + application_version=workflow_application_version(), + fingerprint=fingerprint, + ).model_dump_json(), + encoding="utf-8", + ) LocalWorkerSupervisor._wait_for_worker( process, worker_lock, ready_path, + fingerprint=fingerprint, ) worker_lock.acquire.assert_called_once_with(timeout=0) + def test_failed_startup_is_backed_off(self): + with TemporaryDirectory() as directory: + supervisor = LocalWorkerSupervisor( + VidXPSettings(repository_root=Path(directory)) + ) + with patch.object( + supervisor, + "_ensure_running", + side_effect=RuntimeError("failed"), + ) as start: + with self.assertRaisesRegex(RuntimeError, "failed"): + supervisor.ensure_running() + with self.assertRaisesRegex(RuntimeError, "backoff"): + supervisor.ensure_running() + + start.assert_called_once_with() + + def test_stale_worker_configuration_is_rejected(self): + with TemporaryDirectory() as directory: + ready_path = Path(directory) / "worker.ready" + ready_path.write_text( + LocalWorkerReady( + pid=123, + application_version=workflow_application_version(), + fingerprint="a" * 64, + ).model_dump_json(), + encoding="utf-8", + ) + worker_lock = Mock() + worker_lock.acquire.side_effect = Timeout("worker.lock") + + with self.assertRaisesRegex( + RuntimeError, + "different execution settings", + ): + LocalWorkerSupervisor._wait_for_existing_worker( + worker_lock, + ready_path, + fingerprint="b" * 64, + timeout_seconds=0.1, + ) + + def test_startup_timeout_terminates_exact_spawned_process(self): + with TemporaryDirectory() as directory: + supervisor = LocalWorkerSupervisor( + VidXPSettings(repository_root=Path(directory)) + ) + process = Mock() + process.poll.return_value = None + with ( + patch( + "vidxp.infrastructure.local_worker.subprocess.Popen", + return_value=process, + ), + patch.object( + supervisor, + "_wait_for_worker", + side_effect=RuntimeError("startup timed out"), + ), + self.assertRaisesRegex(RuntimeError, "startup timed out"), + ): + supervisor.ensure_running() + + process.terminate.assert_called_once_with() + process.wait.assert_called_once_with(timeout=5) + + def test_stale_versioned_bootstrap_is_removed_before_start(self): + with TemporaryDirectory() as directory: + supervisor = LocalWorkerSupervisor( + VidXPSettings(repository_root=Path(directory)) + ) + supervisor.layout.ensure_local_directories() + stale = ( + supervisor.layout.local_workflows + / ( + f".worker-{workflow_application_version()}-" + "bootstrap-stale.json" + ) + ) + stale.write_text("database-secret", encoding="utf-8") + + supervisor._remove_stale_bootstraps( + workflow_application_version() + ) + + self.assertFalse(stale.exists()) + + def test_worker_recomputes_bootstrap_identity(self): + with TemporaryDirectory() as directory: + settings = VidXPSettings( + repository_root=Path(directory), + runtime_backend="cpu", + ) + supervisor = LocalWorkerSupervisor(settings) + supervisor.layout.ensure_local_directories() + bootstrap = local_worker_bootstrap(settings) + tampered = bootstrap.model_copy( + update={ + "settings": bootstrap.settings.model_copy( + update={"runtime_backend": "cuda"} + ) + } + ) + path = supervisor._write_bootstrap(tampered) + with ( + patch.dict( + os.environ, + {LOCAL_WORKER_BOOTSTRAP_ENV: str(path)}, + ), + patch("vidxp.workflow_worker.run_worker") as run, + self.assertRaisesRegex(ValueError, "identity is invalid"), + ): + workflow_worker_main( + ["--executor-id", "test", "--role", "all"] + ) + + run.assert_not_called() + self.assertFalse(path.exists()) + + def test_stop_terminates_only_ready_lock_owner(self): + with TemporaryDirectory() as directory: + settings = VidXPSettings(repository_root=Path(directory)) + supervisor = LocalWorkerSupervisor(settings) + supervisor.layout.ensure_local_directories() + ready_path = ( + supervisor.layout.local_workflows + / f"worker-{workflow_application_version()}.ready" + ) + ready_path.write_text( + LocalWorkerReady( + pid=123, + application_version=workflow_application_version(), + fingerprint="a" * 64, + ).model_dump_json(), + encoding="utf-8", + ) + worker_lock = Mock() + worker_lock.acquire.side_effect = [ + Timeout("worker.lock"), + None, + ] + with ( + patch( + "vidxp.infrastructure.local_worker.FileLock", + return_value=worker_lock, + ), + patch( + "vidxp.infrastructure.local_worker.os.kill" + ) as kill, + ): + stopped = supervisor.stop() + + self.assertTrue(stopped) + kill.assert_called_once() + self.assertEqual(kill.call_args.args[0], 123) + self.assertFalse(ready_path.exists()) + + def test_worker_uses_live_rotating_log_handler(self): + with TemporaryDirectory() as directory: + log_path = Path(directory) / "worker.log" + with ( + patch( + "vidxp.workflow_worker.RotatingFileHandler" + ) as handler, + patch("vidxp.workflow_worker.logging.basicConfig") as config, + ): + _configure_logging(log_path) + + handler.assert_called_once_with( + log_path, + maxBytes=5 * 1024 * 1024, + backupCount=2, + encoding="utf-8", + ) + config.assert_called_once() + def test_fresh_database_runs_a_job_in_a_separate_worker_process(self): with TemporaryDirectory(ignore_cleanup_errors=True) as directory: settings = VidXPSettings( @@ -89,22 +344,27 @@ def test_fresh_database_runs_a_job_in_a_separate_worker_process(self): runtime_backend="cpu", allow_model_downloads=False, workflow_poll_interval_seconds=0.01, + http_auth_mode="static", + http_static_bearer_token="s" * 32, ) settings.layout.ensure_local_directories() database_url = workflow_database_url(settings) ready_path = settings.layout.local_workflows / "test.ready" lock_path = settings.layout.local_workflows / "test.lock" - environment = os.environ.copy() - environment[LOCAL_WORKER_SETTINGS_ENV] = ( - settings.model_dump_json() - ) + supervisor = LocalWorkerSupervisor(settings) + bootstrap = local_worker_bootstrap(settings) + bootstrap_path = supervisor._write_bootstrap(bootstrap) + environment = { + key: value + for key, value in os.environ.items() + if not key.upper().startswith("VIDXP_") + } + environment[LOCAL_WORKER_BOOTSTRAP_ENV] = str(bootstrap_path) process = subprocess.Popen( [ sys.executable, "-m", "vidxp.workflow_worker", - "--database-url", - database_url, "--executor-id", local_executor_id(settings), "--role", @@ -113,6 +373,8 @@ def test_fresh_database_runs_a_job_in_a_separate_worker_process(self): str(lock_path), "--ready-file", str(ready_path), + "--log-file", + str(settings.layout.local_workflows / "test.log"), ], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 18ef357..6313443 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -7,8 +7,8 @@ from vidxp.core.contracts import IndexConfig, VideoSource from vidxp.core.manifest import ( ManifestStore, - _sync_parent_directory, source_checksums, + sync_parent_directory, write_json_atomic, ) from vidxp.capabilities.registry import create_capability_registry @@ -27,7 +27,7 @@ def test_unsupported_directory_fsync_does_not_false_fail_commit(self): ), patch("vidxp.core.manifest.os.close") as close, ): - _sync_parent_directory(Path("repository")) + sync_parent_directory(Path("repository")) close.assert_called_once_with(17) diff --git a/tests/test_media_catalog.py b/tests/test_media_catalog.py index 3d2cf69..427b6d0 100644 --- a/tests/test_media_catalog.py +++ b/tests/test_media_catalog.py @@ -22,6 +22,7 @@ LocalMediaStore, ) from vidxp.infrastructure.local_files import prepare_managed_destination +from vidxp.infrastructure.local_objects import LocalObjectStore MEDIA_ID = "123456781234423481234567890abcde" @@ -174,6 +175,65 @@ def test_destination_rejects_junction_parent(self): "objects/junction/video.mp4", ) + def test_destination_syncs_each_new_directory_entry(self): + with TemporaryDirectory() as directory: + root = Path(directory) / "media" + with patch( + "vidxp.infrastructure.local_files.sync_parent_directory" + ) as sync: + prepare_managed_destination( + root, + "objects/aa/video.mp4", + ) + + self.assertEqual( + [call.args[0] for call in sync.call_args_list], + [ + root.parent, + root, + root / "objects", + ], + ) + + def test_failed_publication_rollback_syncs_parent(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "staged.mp4" + source.write_bytes(b"video") + store = LocalObjectStore(root / "objects") + destination = store.root / "aa" / "video.mp4" + destination.parent.mkdir(parents=True) + original_resolve = Path.resolve + + def resolve(path, *args, **kwargs): + if path == destination: + raise OSError("post-publication validation failed") + return original_resolve(path, *args, **kwargs) + + with ( + patch.object(Path, "resolve", resolve), + patch( + "vidxp.infrastructure.local_files." + "sync_parent_directory" + ) as sync, + self.assertRaisesRegex( + OSError, + "post-publication validation failed", + ), + ): + store.publish( + source, + "aa/video.mp4", + expected_sha256=None, + replace_corrupt=False, + ) + + self.assertFalse(destination.exists()) + self.assertEqual( + [call.args[0] for call in sync.call_args_list], + [destination.parent, root, destination.parent], + ) + def test_publication_rejects_symlinked_parent(self): with TemporaryDirectory() as directory: root = Path(directory) From 6eb7be4d924aadf721a9ca83558410ee6c3ff01f Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 07:12:19 +0500 Subject: [PATCH 09/46] refactor: centralize application entry points --- README.md | 5 +- changes/actor-cluster-pagination.bugfix.md | 2 + changes/actor-detection-pagination.bugfix.md | 2 + changes/actor-jobs.bugfix.md | 2 + changes/api-openapi-auth.bugfix.md | 2 + changes/api-readiness.bugfix.md | 1 + changes/api-schema-validation.bugfix.md | 1 + changes/application-boundary.bugfix.md | 4 + changes/benchmark-cli.bugfix.md | 2 + changes/benchmark-dependencies.bugfix.md | 2 + changes/cursor-pagination.bugfix.md | 2 + changes/durable-read-snapshots.bugfix.md | 3 + changes/durable-search.feature.md | 2 + changes/mcp-reserved-path.security.md | 2 + changes/model-providers.feature.md | 2 + changes/platform.feature.md | 1 - changes/transport-terminals.bugfix.md | 3 + changes/workflow-compatibility.bugfix.md | 2 + docs/architecture/platform.md | 10 +- src/vidxp/api.py | 26 +- src/vidxp/api_middleware.py | 13 - src/vidxp/api_routes/jobs.py | 30 ++ src/vidxp/application.py | 410 ++++++++++--------- src/vidxp/application_models.py | 111 ++++- src/vidxp/benchmarks/cli.py | 61 ++- src/vidxp/capabilities/actor/definition.py | 9 + src/vidxp/capabilities/actor/indexing.py | 59 +++ src/vidxp/capabilities/actor/operations.py | 16 +- src/vidxp/capabilities/actor/results.py | 250 ++++++----- src/vidxp/capabilities/actor/schemas.py | 23 +- src/vidxp/capabilities/registry.py | 30 +- src/vidxp/capabilities/schemas.py | 81 +--- src/vidxp/cli.py | 17 +- src/vidxp/cli_commands/actors.py | 30 +- src/vidxp/cli_commands/artifacts.py | 5 + src/vidxp/cli_commands/index.py | 7 +- src/vidxp/cli_commands/runtime.py | 18 +- src/vidxp/cli_commands/search.py | 8 +- src/vidxp/cli_support.py | 48 ++- src/vidxp/composition.py | 163 +++++--- src/vidxp/control_plane.py | 12 +- src/vidxp/core/cursors.py | 92 +++++ src/vidxp/core/identifiers.py | 11 + src/vidxp/frontend.py | 297 +++++++++++--- src/vidxp/infrastructure/dbos_jobs.py | 114 +++--- src/vidxp/infrastructure/dbos_workflows.py | 35 ++ src/vidxp/infrastructure/local_index.py | 188 +++++---- src/vidxp/infrastructure/local_snapshots.py | 45 +- src/vidxp/job_service.py | 41 +- src/vidxp/media_service.py | 51 +-- src/vidxp/ports.py | 9 + src/vidxp/read_job_planner.py | 65 +++ src/vidxp/workflow_contracts.py | 1 + src/vidxp/workflow_runtime.py | 5 +- tests/test_actor_results.py | 128 +++++- tests/test_api.py | 82 +++- tests/test_application.py | 268 +++++++++++- tests/test_benchmark_cli.py | 181 +++++++- tests/test_capabilities.py | 13 + tests/test_cli.py | 34 +- tests/test_cursors.py | 56 +++ tests/test_dbos_jobs.py | 82 ++++ tests/test_frontend.py | 398 ++++++++++++++++-- tests/test_indexing.py | 25 ++ tests/test_job_contracts.py | 105 +++++ tests/test_local_snapshots.py | 29 +- tests/test_local_worker.py | 10 + tests/test_read_job_planner.py | 62 +++ 68 files changed, 3042 insertions(+), 862 deletions(-) create mode 100644 changes/actor-cluster-pagination.bugfix.md create mode 100644 changes/actor-detection-pagination.bugfix.md create mode 100644 changes/actor-jobs.bugfix.md create mode 100644 changes/api-openapi-auth.bugfix.md create mode 100644 changes/api-readiness.bugfix.md create mode 100644 changes/api-schema-validation.bugfix.md create mode 100644 changes/application-boundary.bugfix.md create mode 100644 changes/benchmark-cli.bugfix.md create mode 100644 changes/benchmark-dependencies.bugfix.md create mode 100644 changes/cursor-pagination.bugfix.md create mode 100644 changes/durable-read-snapshots.bugfix.md create mode 100644 changes/durable-search.feature.md create mode 100644 changes/mcp-reserved-path.security.md create mode 100644 changes/model-providers.feature.md delete mode 100644 changes/platform.feature.md create mode 100644 changes/transport-terminals.bugfix.md create mode 100644 changes/workflow-compatibility.bugfix.md create mode 100644 src/vidxp/core/cursors.py create mode 100644 src/vidxp/read_job_planner.py create mode 100644 tests/test_cursors.py create mode 100644 tests/test_read_job_planner.py diff --git a/README.md b/README.md index 97e89c6..b087e46 100644 --- a/README.md +++ b/README.md @@ -244,9 +244,8 @@ VIDXP_HTTP_TRUSTED_HOSTS=["api.example.com"] Use `Authorization: Bearer ` for every route except `/health` and `/ready`. Configure `VIDXP_HTTP_ALLOWED_ORIGINS` only for browser origins that must call `/api/*`; that CORS policy does not apply to the reserved MCP -namespace. Authenticated profiles do not publish `/docs` or `/openapi.json`; -generate or expose API documentation separately when operating a remote -deployment. +namespace. Authenticated profiles keep the interactive `/docs` UI disabled, while +the protected `/openapi.json` contract remains available to authenticated clients. ## Container diff --git a/changes/actor-cluster-pagination.bugfix.md b/changes/actor-cluster-pagination.bugfix.md new file mode 100644 index 0000000..1e75e3a --- /dev/null +++ b/changes/actor-cluster-pagination.bugfix.md @@ -0,0 +1,2 @@ +Materialize actor-cluster summaries during indexing so cluster pages read only the +requested page instead of rescanning every retained face detection. diff --git a/changes/actor-detection-pagination.bugfix.md b/changes/actor-detection-pagination.bugfix.md new file mode 100644 index 0000000..38d80e8 --- /dev/null +++ b/changes/actor-detection-pagination.bugfix.md @@ -0,0 +1,2 @@ +Advance actor-detection pages with stable cursors instead of rescanning earlier +detections. diff --git a/changes/actor-jobs.bugfix.md b/changes/actor-jobs.bugfix.md new file mode 100644 index 0000000..8d87694 --- /dev/null +++ b/changes/actor-jobs.bugfix.md @@ -0,0 +1,2 @@ +Keep actor-overlay commands limited to the stable composite cluster identifier +and bind durable execution to the immutable snapshot selected at submission. diff --git a/changes/api-openapi-auth.bugfix.md b/changes/api-openapi-auth.bugfix.md new file mode 100644 index 0000000..e8efab1 --- /dev/null +++ b/changes/api-openapi-auth.bugfix.md @@ -0,0 +1,2 @@ +Declare bearer authentication in OpenAPI and expose the schema only to +authenticated clients when HTTP authentication is enabled. diff --git a/changes/api-readiness.bugfix.md b/changes/api-readiness.bugfix.md new file mode 100644 index 0000000..c693946 --- /dev/null +++ b/changes/api-readiness.bugfix.md @@ -0,0 +1 @@ +Include dependency and owned-worker health in runtime readiness checks. diff --git a/changes/api-schema-validation.bugfix.md b/changes/api-schema-validation.bugfix.md new file mode 100644 index 0000000..1e009b8 --- /dev/null +++ b/changes/api-schema-validation.bugfix.md @@ -0,0 +1 @@ +Bound search queries and actor identifiers in the shared request schemas. diff --git a/changes/application-boundary.bugfix.md b/changes/application-boundary.bugfix.md new file mode 100644 index 0000000..f0ecf35 --- /dev/null +++ b/changes/application-boundary.bugfix.md @@ -0,0 +1,4 @@ +Application adapters now share one control-plane implementation for capability, +media, artifact, and shallow index-status operations. Local CLI composition now +loads model and workflow services only when a command uses them and closes opened +workflow clients through the Typer command lifecycle. diff --git a/changes/benchmark-cli.bugfix.md b/changes/benchmark-cli.bugfix.md new file mode 100644 index 0000000..51510ea --- /dev/null +++ b/changes/benchmark-cli.bugfix.md @@ -0,0 +1,2 @@ +Keep the DiDeMo benchmark command available when the optional HiREST subtitle +parser is not installed. diff --git a/changes/benchmark-dependencies.bugfix.md b/changes/benchmark-dependencies.bugfix.md new file mode 100644 index 0000000..6d53c63 --- /dev/null +++ b/changes/benchmark-dependencies.bugfix.md @@ -0,0 +1,2 @@ +Validate benchmark runtime dependencies at invocation and report the exact optional +extras needed for DiDeMo and HiREST. diff --git a/changes/cursor-pagination.bugfix.md b/changes/cursor-pagination.bugfix.md new file mode 100644 index 0000000..f72b79a --- /dev/null +++ b/changes/cursor-pagination.bugfix.md @@ -0,0 +1,2 @@ +Reject malformed, noncanonical, or overflowing opaque cursors and freeze descending +job-list pagination so concurrent submissions do not shift later pages. diff --git a/changes/durable-read-snapshots.bugfix.md b/changes/durable-read-snapshots.bugfix.md new file mode 100644 index 0000000..b4546a0 --- /dev/null +++ b/changes/durable-read-snapshots.bugfix.md @@ -0,0 +1,3 @@ +Pin durable search and actor-overlay jobs to the exact immutable index snapshot +selected when the job is submitted. Actor jobs retain the stable composite +cluster identifier rather than duplicating its media and generation fields. diff --git a/changes/durable-search.feature.md b/changes/durable-search.feature.md new file mode 100644 index 0000000..f4768fb --- /dev/null +++ b/changes/durable-search.feature.md @@ -0,0 +1,2 @@ +Run model-backed scene and dialogue searches through the shared durable worker +boundary for CLI, HTTP, and Streamlit clients. diff --git a/changes/mcp-reserved-path.security.md b/changes/mcp-reserved-path.security.md new file mode 100644 index 0000000..9aded71 --- /dev/null +++ b/changes/mcp-reserved-path.security.md @@ -0,0 +1,2 @@ +Keep reserved MCP paths behind the active HTTP authentication and request-size +policies until the sibling MCP application installs its own validated boundary. diff --git a/changes/model-providers.feature.md b/changes/model-providers.feature.md new file mode 100644 index 0000000..e1bcbd2 --- /dev/null +++ b/changes/model-providers.feature.md @@ -0,0 +1,2 @@ +Replace legacy CPU model providers with pinned SigLIP2, Qwen3 Embedding, +faster-whisper, YuNet, and SFace integrations. diff --git a/changes/platform.feature.md b/changes/platform.feature.md deleted file mode 100644 index da2f35d..0000000 --- a/changes/platform.feature.md +++ /dev/null @@ -1 +0,0 @@ -Centralize CLI and UI behavior behind shared application contracts and replace legacy CPU model providers with pinned SigLIP2, Qwen3 Embedding, faster-whisper, YuNet, and SFace integrations. diff --git a/changes/transport-terminals.bugfix.md b/changes/transport-terminals.bugfix.md new file mode 100644 index 0000000..403ad51 --- /dev/null +++ b/changes/transport-terminals.bugfix.md @@ -0,0 +1,3 @@ +Stop polling background UI jobs after success, failure, cancellation, or +disappearance, persist their terminal result for display, and keep retryable +job-backend outages non-terminal. diff --git a/changes/workflow-compatibility.bugfix.md b/changes/workflow-compatibility.bugfix.md new file mode 100644 index 0000000..9952b44 --- /dev/null +++ b/changes/workflow-compatibility.bugfix.md @@ -0,0 +1,2 @@ +Bind DBOS workflow compatibility to the installed implementation digest as well as +the package release, preventing changed worker code from recovering older jobs. diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md index 552ce2c..9128ea3 100644 --- a/docs/architecture/platform.md +++ b/docs/architecture/platform.md @@ -465,10 +465,12 @@ reviewed dead-executor recovery coordinator. exclusion. Queue-global concurrency is configured only where one job across every worker is intentionally required. -`application_version` is the immutable VidXP release identity. Recovery occurs only -on a worker supporting that workflow version. Deployments drain old-version jobs or -run blue/green old-version workers until they finish; breaking workflow changes -require an explicit DBOS patch/migration strategy. +`application_version` combines the VidXP release with a digest of the installed +workflow implementation. Recovery therefore occurs only on a worker running the +same code, including when a prerelease build changes without a package-version +bump. Deployments drain old-version jobs or run blue/green old-version workers until +they finish; breaking workflow changes require an explicit DBOS patch/migration +strategy. ## 12. Resource scheduling and model runtime diff --git a/src/vidxp/api.py b/src/vidxp/api.py index a00431e..a89a68d 100644 --- a/src/vidxp/api.py +++ b/src/vidxp/api.py @@ -4,7 +4,8 @@ from typing import Annotated, AsyncIterator from asgi_correlation_id import CorrelationIdMiddleware -from fastapi import Depends, FastAPI, Request, Response +from fastapi import Depends, FastAPI, Request, Response, Security +from fastapi.security import HTTPBearer from vidxp import __version__ from vidxp.api_errors import install_exception_handlers from vidxp.api_middleware import ( @@ -21,6 +22,17 @@ from vidxp.composition import HttpApplicationContext, create_http_application from vidxp.settings import VidXPSettings + +_BEARER_SECURITY = HTTPBearer( + auto_error=False, + scheme_name="BearerAuth", + description=( + "Bearer access token. Authentication is enforced once by the " + "server middleware." + ), +) + + def create_app( settings: VidXPSettings | None = None, *, @@ -47,7 +59,7 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: lifespan=lifespan, docs_url="/docs" if publish_docs else None, redoc_url="/redoc" if publish_docs else None, - openapi_url="/openapi.json" if publish_docs else None, + openapi_url="/openapi.json", ) app.state.vidxp = active_context install_exception_handlers(app) @@ -80,7 +92,15 @@ def ready( status="ready" if is_ready else "not_ready", ) - app.include_router(create_api_router()) + api_dependencies = ( + [Security(_BEARER_SECURITY)] + if active_settings.http_auth_mode.value != "none" + else [] + ) + app.include_router( + create_api_router(), + dependencies=api_dependencies, + ) app.add_exception_handler( RequestBodyTooLarge, _request_body_too_large_response, diff --git a/src/vidxp/api_middleware.py b/src/vidxp/api_middleware.py index 23f803a..fdc55d9 100644 --- a/src/vidxp/api_middleware.py +++ b/src/vidxp/api_middleware.py @@ -20,15 +20,6 @@ UPLOAD_PATH = "/api/v1/media" -def _is_reserved_mcp_path(path: str) -> bool: - return ( - path == "/mcp" - or path.startswith("/mcp/") - or path == "/.well-known/oauth-protected-resource" - or path.startswith("/.well-known/oauth-protected-resource/") - ) - - class RequestBodyTooLarge(HTTPException, OSError): """Abort body parsing while preserving Starlette's file cleanup path.""" @@ -210,7 +201,6 @@ async def __call__( if ( scope["type"] != "http" or str(scope.get("path", "")) in PUBLIC_HTTP_PATHS - or _is_reserved_mcp_path(str(scope.get("path", ""))) ): await self.app(scope, receive, send) return @@ -254,9 +244,6 @@ async def __call__( if scope["type"] != "http": await self.app(scope, receive, send) return - if _is_reserved_mcp_path(str(scope.get("path", ""))): - await self.app(scope, receive, send) - return limit = ( self.upload_limit + 1024 * 1024 if scope.get("path") == UPLOAD_PATH diff --git a/src/vidxp/api_routes/jobs.py b/src/vidxp/api_routes/jobs.py index 117e36e..f9e239d 100644 --- a/src/vidxp/api_routes/jobs.py +++ b/src/vidxp/api_routes/jobs.py @@ -20,6 +20,7 @@ ListJobsCommand, Principal, PrepareModelsCommand, + SearchCommand, ) from vidxp.composition import HttpApplicationContext from vidxp.core.identifiers import JobId @@ -57,6 +58,35 @@ def submit_index( ) +@router.post( + "/search", + response_model=Job, + status_code=202, + operation_id="searchMoments", + summary="Search indexed moments", + dependencies=[Depends(read_principal)], +) +def submit_search( + command: SearchCommand, + response: Response, + service: Annotated[HttpApplicationContext, Depends(context)], + actor: Annotated[Principal, Depends(read_principal)], + idempotency_key: HttpIdempotencyKey, +) -> Job: + return accepted( + response, + service.jobs.submit_search( + command, + job_id=scoped_job_id( + service, + actor, + operation="search", + idempotency_key=idempotency_key, + ), + ), + ) + + @router.post( "/snippet", response_model=Job, diff --git a/src/vidxp/application.py b/src/vidxp/application.py index 89a6c34..755567b 100644 --- a/src/vidxp/application.py +++ b/src/vidxp/application.py @@ -2,7 +2,7 @@ from contextlib import contextmanager from pathlib import Path -from typing import Any, Iterator, Mapping, cast +from typing import Any, Callable, Iterator, Mapping, cast from pydantic import BaseModel @@ -10,7 +10,6 @@ from vidxp.application_models import ( ApplicationError, Artifact, - CapabilityInfo, ComponentReadiness, CreateIndexCommand, CreateActorOverlayCommand, @@ -19,13 +18,10 @@ DependencyCheckResult, DependencyUnavailableError, ErrorCategory, - InvalidRequestError, IndexResult, - IndexStatus, ImportMediaCommand, - ListMediaCommand, + IndexSnapshotReference, MediaAsset, - MediaPage, PrepareModelsCommand, PrepareModelsResult, RemoveIndexCommand, @@ -34,6 +30,7 @@ SearchCommand, ) from vidxp.capabilities.actor.schemas import ( + ActorClusterSummary, ActorClustersOutput, ActorDetectionsOutput, ) @@ -50,26 +47,20 @@ IndexConfig, ) from vidxp.execution import ExecutionContext, execution_context -from vidxp.index_state import ( - INDEX_STATUS_SCHEMA, -) from vidxp.ports import IndexBackend, ModelRuntimePort -from vidxp.ports import LocalFileResource from vidxp.model_contracts import ModelArtifactUnavailableError from vidxp.repository_layout import RepositoryLayout from vidxp.settings import VidXPSettings +from vidxp.control_plane import ControlPlaneApplication from vidxp.artifact_service import ( ArtifactService, ) -from vidxp.core.media import ( - QuarantinedMedia, -) from vidxp.media_service import ( MediaService, ) -class VidXPApplication: +class VidXPApplication(ControlPlaneApplication): """The transport-neutral command and query boundary.""" def __init__( @@ -80,17 +71,21 @@ def __init__( registry: CapabilityRegistry, runtime: ModelRuntimePort, index_backend: IndexBackend, - media_service: MediaService, - artifact_service: ArtifactService, + media: MediaService, + artifacts: ArtifactService, + index_status: Callable[[], dict[str, Any] | None], ) -> None: - self.settings = settings - self.layout = layout self.registry = registry - self.capabilities = CapabilityService(registry) self.runtime = runtime self.index_backend = index_backend - self.media_service = media_service - self.artifact_service = artifact_service + super().__init__( + layout=layout, + capabilities=CapabilityService(registry), + media=media, + artifacts=artifacts, + index_status=index_status, + ) + self.settings = settings @contextmanager def _capability_dependencies( @@ -126,21 +121,6 @@ def index_directory(self) -> Path: def device(self) -> str: return self.runtime.backends.torch_device - @application_boundary - def index_status(self) -> IndexStatus: - stored = self.index_backend.status(self.index_directory) - payload = ( - dict(stored) - if stored is not None - else { - "schema_version": INDEX_STATUS_SCHEMA, - "state": "missing", - "stage": "status", - "message": "No local video index was found.", - } - ) - return IndexStatus.model_validate(payload) - @application_boundary def _active_config(self) -> tuple[IndexConfig, dict[str, Any]]: return self.index_backend.active_config( @@ -151,115 +131,41 @@ def _active_config(self) -> tuple[IndexConfig, dict[str, Any]]: @application_boundary def import_media(self, command: ImportMediaCommand) -> MediaAsset: self.layout.ensure_local_directories() - return self.media_service.import_local(command) - - @application_boundary - def import_uploaded_media( - self, - *, - staged_path: Path, - original_filename: str, - declared_mime_type: str | None, - ) -> MediaAsset: - self.layout.ensure_local_directories() - return self.media_service.import_quarantined( - QuarantinedMedia( - path=staged_path, - original_filename=original_filename, - declared_mime_type=declared_mime_type, - ) - ) - - @application_boundary - def list_capabilities(self) -> tuple[CapabilityInfo, ...]: - return self.capabilities.list() - - @application_boundary - def get_capability(self, name: str) -> CapabilityInfo: - try: - return self.capabilities.get(name) - except CapabilityRequestError as exc: - raise ResourceNotFoundError("capability") from exc - - @application_boundary - def control_plane_readiness(self) -> tuple[ComponentReadiness, ...]: - components: list[ComponentReadiness] = [] - try: - self.media_service.list(ListMediaCommand(page_size=1)) - except Exception: - components.append( - ComponentReadiness( - name="catalog", - ready=False, - message="The media catalog is unavailable.", - ) - ) - else: - components.append( - ComponentReadiness( - name="catalog", - ready=True, - message="The media catalog is available.", - ) - ) - try: - self.index_status() - except ApplicationError: - components.append( - ComponentReadiness( - name="index", - ready=False, - message="The index catalog is unavailable.", - ) - ) - else: - components.append( - ComponentReadiness( - name="index", - ready=True, - message="The index catalog is available.", - ) - ) - return tuple(components) + return self.media.import_local(command) @application_boundary def runtime_readiness(self) -> RuntimeReadiness: - components = self.control_plane_readiness() + components = ( + *self.control_plane_readiness(), + self._index_storage_readiness(), + ) dependencies = self.check_dependencies( DependencyCheckCommand(modalities=self.registry.names()) ) return RuntimeReadiness( - ready=all(component.ready for component in components), + ready=( + all(component.ready for component in components) + and dependencies.ok + ), runtime=self.runtime.backends, components=components, dependencies=dependencies, ) - @application_boundary - def get_media(self, media_id: str) -> MediaAsset: - return self.media_service.get(media_id) - - @application_boundary - def list_media( - self, - command: ListMediaCommand, - ) -> MediaPage: + def _index_storage_readiness(self) -> ComponentReadiness: try: - return self.media_service.list(command) - except ValueError as exc: - raise InvalidRequestError() from exc - - @application_boundary - def open_media_content(self, media_id: str) -> LocalFileResource: - return self.media_service.content(media_id) - - @application_boundary - def get_artifact(self, artifact_id: str) -> Artifact: - return self.artifact_service.get(artifact_id) - - @application_boundary - def open_artifact_content(self, artifact_id: str) -> LocalFileResource: - return self.artifact_service.content(artifact_id) + self.index_backend.status(self.index_directory) + except Exception: + return ComponentReadiness( + name="index_storage", + ready=False, + message="The committed index storage failed integrity checks.", + ) + return ComponentReadiness( + name="index_storage", + ready=True, + message="The committed index storage passed integrity checks.", + ) @application_boundary def create_index( @@ -279,8 +185,8 @@ def create_index( raise CapabilityRequestError( "One or more selected capabilities do not support indexing." ) - media = self.media_service.require_record(command.media_id) - content = self.media_service.content(command.media_id) + media = self.media.require_record(command.media_id) + content = self.media.content(command.media_id) self.layout.ensure_local_directories() config = IndexConfig.local( video_id=command.media_id, @@ -319,7 +225,10 @@ def check_dependencies( command: DependencyCheckCommand, ) -> DependencyCheckResult: selected = self.registry.validate_names(command.modalities) - checks = self.registry.dependency_checks(selected) + checks = self.registry.dependency_checks( + selected, + include_runtime_checks=command.include_runtime_checks, + ) return DependencyCheckResult( ok=all(check.ok for check in checks), modalities=selected, @@ -378,25 +287,16 @@ def execute( operation: str, payload: BaseModel | Mapping[str, Any], ) -> BaseModel: - definition = self.registry.get(capability) - try: - contract = definition.operations[operation] - handler = self.registry.executor(capability).operations[operation] - except KeyError as exc: - available = ", ".join(definition.operations) or "none" - raise CapabilityRequestError( - f"Capability {capability!r} has no operation {operation!r}; " - f"available operations: {available}." - ) from exc - + definition, contract, handler = self._operation( + capability, + operation, + ) + request = contract.input_model.model_validate(payload) config = None if contract.requires_index: config, _ = self._active_config() - if capability not in config.enabled_modalities: - raise CapabilityRequestError( - f"The {capability} capability is not present in this index." - ) - request = contract.input_model.model_validate(payload) + self._require_indexed_capability(capability, config) + with self._capability_dependencies((capability,)): if config is None: with self.runtime.scheduler.inference(): @@ -408,28 +308,109 @@ def execute( request, ) return contract.output_model.model_validate(response) + else: + with self.index_backend.open_store(config) as storage: + with self.runtime.scheduler.inference(): + response = handler( + CapabilityContext( + config=config, + runtime=self.runtime, + storage=storage, + ), + request, + ) + return contract.output_model.model_validate(response) + + def _operation(self, capability: str, operation: str): + definition = self.registry.get(capability) + try: + contract = definition.operations[operation] + handler = self.registry.executor(capability).operations[operation] + except KeyError as exc: + available = ", ".join(definition.operations) or "none" + raise CapabilityRequestError( + f"Capability {capability!r} has no operation {operation!r}; " + f"available operations: {available}." + ) from exc + return definition, contract, handler + + @staticmethod + def _require_indexed_capability( + capability: str, + config: IndexConfig, + ) -> None: + if capability not in config.enabled_modalities: + raise CapabilityRequestError( + f"The {capability} capability is not present in this index." + ) + + def _invoke_operation( + self, + capability: str, + operation: str, + payload: BaseModel | Mapping[str, Any], + *, + context: CapabilityContext, + ) -> BaseModel: + _, contract, handler = self._operation(capability, operation) + if contract.requires_index: + if context.config is None or context.storage is None: + raise RuntimeError( + "Indexed capability execution requires a pinned store." + ) + self._require_indexed_capability(capability, context.config) + request = contract.input_model.model_validate(payload) + response = handler(context, request) + return contract.output_model.model_validate(response) + + def _config_for_snapshot( + self, + reference: IndexSnapshotReference, + ) -> IndexConfig: + return self.index_backend.config_for_snapshot( + self.index_directory, + snapshot_id=reference.snapshot_id, + snapshot_sha256=reference.snapshot_sha256, + device=self.device, + ) + + @application_boundary + def search( + self, + command: SearchCommand, + *, + snapshot: IndexSnapshotReference | None = None, + ) -> SearchResult: + if snapshot is None: + return cast( + SearchResult, + self.execute( + command.modality, + "search", + {"query": command.query, "top_k": command.top_k}, + ), + ) + config = self._config_for_snapshot(snapshot) + self._require_indexed_capability(command.modality, config) + with self._capability_dependencies((command.modality,)): with self.index_backend.open_store(config) as storage: with self.runtime.scheduler.inference(): - response = handler( - CapabilityContext( - config=config, - runtime=self.runtime, - storage=storage, + return cast( + SearchResult, + self._invoke_operation( + command.modality, + "search", + { + "query": command.query, + "top_k": command.top_k, + }, + context=CapabilityContext( + config=config, + runtime=self.runtime, + storage=storage, + ), ), - request, ) - return contract.output_model.model_validate(response) - - @application_boundary - def search(self, command: SearchCommand) -> SearchResult: - return cast( - SearchResult, - self.execute( - command.modality, - "search", - {"query": command.query, "top_k": command.top_k}, - ), - ) @application_boundary def actor_clusters( @@ -447,6 +428,17 @@ def actor_clusters( ), ) + @application_boundary + def actor_cluster(self, cluster_id: str) -> ActorClusterSummary: + return cast( + ActorClusterSummary, + self.execute( + "actor", + "cluster", + {"cluster_id": cluster_id}, + ), + ) + @application_boundary def actor_detections( self, @@ -473,41 +465,87 @@ def render_actor( self, command: CreateActorOverlayCommand, *, + snapshot: IndexSnapshotReference | None = None, + media_id: str | None = None, + generation_id: str | None = None, execution: ExecutionContext | None = None, ) -> Artifact: active_execution = execution_context(execution) + if (media_id is None) != (generation_id is None): + raise ValueError( + "Pinned actor media and generation identities must be " + "provided together." + ) active_execution.report( { "stage": "resolving_detections", "message": "Resolving actor detections.", } ) + config = ( + self._config_for_snapshot(snapshot) + if snapshot is not None + else self._active_config()[0] + ) + self._require_indexed_capability("actor", config) detections = [] - cursor = None - while True: - active_execution.checkpoint() - page = self.actor_detections( - command.cluster_id, - page_size=100, - cursor=cursor, - ) - detections.extend(page.detections) - cursor = page.next_cursor - if cursor is None: - break + with self._capability_dependencies(("actor",)): + with self.index_backend.open_store(config) as storage: + context = CapabilityContext( + config=config, + runtime=self.runtime, + storage=storage, + ) + with self.runtime.scheduler.inference(): + cluster = cast( + ActorClusterSummary, + self._invoke_operation( + "actor", + "cluster", + {"cluster_id": command.cluster_id}, + context=context, + ), + ) + cursor = None + while True: + active_execution.checkpoint() + page = cast( + ActorDetectionsOutput, + self._invoke_operation( + "actor", + "detections", + { + "cluster_id": command.cluster_id, + "page_size": 100, + "cursor": cursor, + }, + context=context, + ), + ) + detections.extend(page.detections) + cursor = page.next_cursor + if cursor is None: + break identities = { (detection.media_id, detection.generation_id) for detection in detections } - if identities != {(command.media_id, command.generation_id)}: + expected_identity = (cluster.media_id, cluster.generation_id) + if ( + identities != {expected_identity} + or ( + media_id is not None + and expected_identity != (media_id, generation_id) + ) + ): raise ApplicationError( "actor_cluster_identity_invalid", ErrorCategory.conflict, "The actor cluster does not match the requested index identity.", ) - return self.artifact_service.create_actor_overlay( - media_id=command.media_id, - generation_id=command.generation_id, + return self.artifacts.create_actor_overlay( + media_id=cluster.media_id, + generation_id=cluster.generation_id, cluster_id=command.cluster_id, detections=[ detection.model_dump(mode="python") @@ -526,7 +564,7 @@ def create_snippet( execution: ExecutionContext | None = None, ) -> Artifact: active_execution = execution_context(execution) - return self.artifact_service.create_snippet( + return self.artifacts.create_snippet( command, job_id=active_execution.job_id, execution=active_execution, diff --git a/src/vidxp/application_models.py b/src/vidxp/application_models.py index 06b1a46..54275f1 100644 --- a/src/vidxp/application_models.py +++ b/src/vidxp/application_models.py @@ -2,7 +2,7 @@ from enum import StrEnum from pathlib import Path -from typing import Annotated, Any, Generic, Literal, Mapping, TypeVar +from typing import Annotated, Any, Generic, Literal, Mapping, TypeAlias, TypeVar from pydantic import ( AwareDatetime, @@ -11,11 +11,13 @@ Field, JsonValue, NonNegativeInt, + StringConstraints, field_validator, model_validator, ) from vidxp.core.identifiers import ( + ActorClusterId as ActorClusterId, ArtifactId as ArtifactId, Identifier as Identifier, IndexGenerationId as IndexGenerationId, @@ -32,6 +34,7 @@ ArtifactKind, ArtifactState, ) +from vidxp.core.contracts import INDEX_SCHEMA_VERSION from vidxp.core.media import ( MEDIA_SCHEMA_VERSION, MediaState, @@ -40,6 +43,10 @@ ) T = TypeVar("T") +SearchQuery: TypeAlias = Annotated[ + str, + StringConstraints(strip_whitespace=True, min_length=1, max_length=4096), +] class ApplicationModel(BaseModel): @@ -85,6 +92,7 @@ class ErrorCategory(StrEnum): class JobKind(StrEnum): index = "index" + search = "search" snippet = "snippet" actor_overlay = "actor_overlay" prepare_models = "prepare_models" @@ -327,9 +335,7 @@ class ActorOverlayProfile(StrEnum): class CreateActorOverlayCommand(ApplicationModel): - media_id: MediaId - generation_id: IndexGenerationId - cluster_id: str = Field(min_length=1) + cluster_id: ActorClusterId profile: ActorOverlayProfile = ActorOverlayProfile.default @@ -367,9 +373,80 @@ class IndexStatusSummary(ApplicationModel): class SearchCommand(ApplicationModel): + modality: Identifier + query: SearchQuery + top_k: int = Field(default=10, gt=0, le=100) + + +class SearchHit(ApplicationModel): + rank: int = Field(gt=0) + media_id: MediaId + video_id: VideoId + generation_id: IndexGenerationId + start: float = Field(ge=0) + end: float = Field(gt=0) + score: float + raw_distance: float modality: str = Field(min_length=1) + source_id: str = Field(min_length=1) + metadata: dict[str, JsonValue] = Field(default_factory=dict) + + @field_validator("metadata") + @classmethod + def _reject_internal_metadata( + cls, + value: dict[str, JsonValue], + ) -> dict[str, JsonValue]: + forbidden: set[str] = set() + + def inspect(item: JsonValue) -> None: + if isinstance(item, dict): + for key, nested in item.items(): + if ( + key == "path" + or key == "storage_key" + or key.endswith("_path") + or key.endswith("_directory") + ): + forbidden.add(key) + inspect(nested) + elif isinstance(item, list): + for nested in item: + inspect(nested) + + inspect(value) + if forbidden: + raise ValueError( + "Search metadata contains internal location fields." + ) + return value + + @model_validator(mode="after") + def _validate_interval(self) -> "SearchHit": + if self.end <= self.start: + raise ValueError("end must be greater than start") + return self + + def to_dict(self) -> dict[str, Any]: + return self.model_dump(mode="json") + + +class SearchResult(ApplicationModel): + schema_version: int = INDEX_SCHEMA_VERSION + query_id: str = Field(min_length=1) query: str = Field(min_length=1) - top_k: int = Field(default=10, gt=0, le=100) + modality: str = Field(min_length=1) + hits: tuple[SearchHit, ...] = () + + def to_dict(self) -> dict[str, Any]: + return self.model_dump(mode="json") + + def to_prediction(self) -> dict[str, list[dict[str, Any]]]: + return { + self.query_id: [ + hit.model_dump(mode="json") for hit in self.hits + ] + } class PrepareModelsCommand(ApplicationModel): @@ -381,6 +458,7 @@ class PrepareModelsCommand(ApplicationModel): class DependencyCheckCommand(ApplicationModel): modalities: tuple[str, ...] + include_runtime_checks: bool = True class DependencyCheckResult(ApplicationModel): @@ -398,11 +476,22 @@ class PrepareModelsResult(ApplicationModel): JOB_SCHEMA_VERSION = 1 +class IndexSnapshotReference(ApplicationModel): + snapshot_id: IndexSnapshotId + snapshot_sha256: Sha256 + + class IndexJobRequest(ApplicationModel): kind: Literal[JobKind.index] = JobKind.index command: CreateIndexCommand +class SearchJobRequest(ApplicationModel): + kind: Literal[JobKind.search] = JobKind.search + command: SearchCommand + snapshot: IndexSnapshotReference + + class SnippetJobRequest(ApplicationModel): kind: Literal[JobKind.snippet] = JobKind.snippet command: CreateSnippetCommand @@ -411,6 +500,7 @@ class SnippetJobRequest(ApplicationModel): class ActorOverlayJobRequest(ApplicationModel): kind: Literal[JobKind.actor_overlay] = JobKind.actor_overlay command: CreateActorOverlayCommand + snapshot: IndexSnapshotReference class PrepareModelsJobRequest(ApplicationModel): @@ -420,6 +510,7 @@ class PrepareModelsJobRequest(ApplicationModel): JobRequest = Annotated[ IndexJobRequest + | SearchJobRequest | SnippetJobRequest | ActorOverlayJobRequest | PrepareModelsJobRequest, @@ -432,6 +523,11 @@ class IndexJobResult(ApplicationModel): result: IndexResult +class SearchJobResult(ApplicationModel): + kind: Literal[JobKind.search] = JobKind.search + result: SearchResult + + class ArtifactJobResult(ApplicationModel): kind: Literal[JobKind.snippet, JobKind.actor_overlay] result: Artifact @@ -443,7 +539,10 @@ class PrepareModelsJobResult(ApplicationModel): JobResult = Annotated[ - IndexJobResult | ArtifactJobResult | PrepareModelsJobResult, + IndexJobResult + | SearchJobResult + | ArtifactJobResult + | PrepareModelsJobResult, Field(discriminator="kind"), ] diff --git a/src/vidxp/benchmarks/cli.py b/src/vidxp/benchmarks/cli.py index fd84ca0..b05c884 100644 --- a/src/vidxp/benchmarks/cli.py +++ b/src/vidxp/benchmarks/cli.py @@ -12,17 +12,55 @@ HIREST_DEFAULT_WINDOW_FRACTION, run_hirest, ) +from vidxp.capabilities.registry import create_capability_registry from vidxp.cli_support import ( OutputFormat, effective_output_format, emit_json, state_from_context, ) +from vidxp.dependencies import ( + active_requirements, + inspect_requirement, + packaged_requirements, +) app = typer.Typer(help="Run official benchmark adapters.") +def _require_benchmark_dependencies( + capability: str, + *, + include_benchmark_extra: bool = False, +) -> None: + requirements = list( + create_capability_registry().requirements_for((capability,)) + ) + extras = [capability] + if include_benchmark_extra: + requirements.extend( + active_requirements(packaged_requirements("vidxp.benchmarks")) + ) + extras.append("benchmarks") + failures = [ + check + for requirement in dict.fromkeys(requirements) + if not (check := inspect_requirement(requirement))["ok"] + ] + if not failures: + return + unavailable = ", ".join( + f"{failure['requirement']} ({failure['error']})" + for failure in failures + ) + extra = ",".join(extras) + raise typer.BadParameter( + f"Benchmark dependencies are unavailable: {unavailable}. " + f'Install them with: pip install "vidxp[{extra}]"' + ) + + def _annotation_indices(value: str | None) -> list[int] | None: if value is None: return None @@ -46,7 +84,13 @@ def _annotation_indices(value: str | None) -> list[int] | None: def _pair_file(path: Path | None) -> list[tuple[str, str]] | None: if path is None: return None - payload = json.loads(path.read_text(encoding="utf-8")) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise typer.BadParameter( + "The HiREST pair file must contain valid readable JSON.", + param_hint="--pairs", + ) from exc if not isinstance(payload, list) or not payload: raise typer.BadParameter( "The HiREST pair file must be a non-empty JSON list." @@ -105,6 +149,7 @@ def didemo_command( ) -> None: """Run DiDeMo scene retrieval and its official evaluator.""" + _require_benchmark_dependencies("scene") state = state_from_context(ctx) metrics = run_didemo( annotations_path=annotations, @@ -117,7 +162,7 @@ def didemo_command( split=split, chunk_pooling=chunk_pooling, reset=reset, - device=state.service.device or "cpu", + device=state.settings.runtime_backend, ) if effective_output_format(state, json_output) == OutputFormat.json: emit_json(metrics) @@ -169,6 +214,16 @@ def hirest_command( ) -> None: """Run HiREST released-ASR retrieval; score validation predictions.""" + if not 0 < temporal_window_fraction < 1: + raise typer.BadParameter( + "The temporal window fraction must be greater than zero and " + "less than one.", + param_hint="--temporal-window-fraction", + ) + _require_benchmark_dependencies( + "dialogue", + include_benchmark_extra=True, + ) state = state_from_context(ctx) metrics = run_hirest( ground_truth_path=ground_truth, @@ -182,7 +237,7 @@ def hirest_command( split=split, temporal_window_fraction=temporal_window_fraction, reset=reset, - device=state.service.device or "cpu", + device=state.settings.runtime_backend, ) if effective_output_format(state, json_output) == OutputFormat.json: emit_json(metrics) diff --git a/src/vidxp/capabilities/actor/definition.py b/src/vidxp/capabilities/actor/definition.py index 77154a9..f467b5c 100644 --- a/src/vidxp/capabilities/actor/definition.py +++ b/src/vidxp/capabilities/actor/definition.py @@ -8,6 +8,7 @@ get_actor_models, ) from vidxp.capabilities.actor.operations import ( + cluster_operation, clusters_operation, detections_operation, ) @@ -21,6 +22,8 @@ module_import_check, ) from vidxp.capabilities.actor.schemas import ( + ActorClusterInput, + ActorClusterSummary, ActorClustersInput, ActorClustersOutput, ActorDetectionsInput, @@ -76,6 +79,11 @@ def model_manifest( prepares_models=True, model_specs=(YUNET_MODEL, SFACE_MODEL), operations={ + "cluster": OperationDefinition( + input_model=ActorClusterInput, + output_model=ActorClusterSummary, + public=False, + ), "clusters": OperationDefinition( input_model=ActorClustersInput, output_model=ActorClustersOutput, @@ -93,6 +101,7 @@ def create_executor() -> CapabilityExecutor: indexer=index_capabilities, index_processor=VISUAL_PROCESSOR, operations={ + "cluster": cluster_operation, "clusters": clusters_operation, "detections": detections_operation, }, diff --git a/src/vidxp/capabilities/actor/indexing.py b/src/vidxp/capabilities/actor/indexing.py index 7f0abdc..c8fc3a7 100644 --- a/src/vidxp/capabilities/actor/indexing.py +++ b/src/vidxp/capabilities/actor/indexing.py @@ -23,6 +23,9 @@ class ActorIndexState: known_ids: list[str] = field(default_factory=list) histories: dict[str, list[Any]] = field(default_factory=dict) cluster_sizes: dict[str, int] = field(default_factory=dict) + cluster_ranges: dict[str, tuple[float, float]] = field( + default_factory=dict + ) processed_frames: int = 0 @@ -87,6 +90,39 @@ def _actor_records( return records +def _actor_cluster_records( + cluster_sizes: dict[str, int], + cluster_ranges: dict[str, tuple[float, float]], + config: IndexConfig, +) -> list[StorageRecord]: + records = [] + for cluster_id in sorted(cluster_sizes): + size = cluster_sizes[cluster_id] + first_timestamp, last_timestamp = cluster_ranges[cluster_id] + source_id = stable_source_id( + config.run_id, + str(config.video_id), + "actor-cluster-summary", + cluster_id, + generation_id=config.generation_id, + ) + records.append( + StorageRecord( + source_id=source_id, + embedding=[0.0], + metadata={ + **config.record_identity("actor", source_id), + "record_kind": "cluster_summary", + "summary_cluster_id": cluster_id, + "detection_count": size, + "first_timestamp": first_timestamp, + "last_timestamp": last_timestamp, + }, + ) + ) + return records + + def process_actor_samples( samples, *, @@ -148,6 +184,16 @@ def process_actor_samples( state.cluster_sizes[cluster_id] = ( state.cluster_sizes.get(cluster_id, 0) + 1 ) + previous_range = state.cluster_ranges.get(cluster_id) + timestamp = float(sample.timestamp) + state.cluster_ranges[cluster_id] = ( + timestamp + if previous_range is None + else min(previous_range[0], timestamp), + timestamp + if previous_range is None + else max(previous_range[1], timestamp), + ) detections.append( { "detection_id": ( @@ -196,6 +242,19 @@ def finalize_actor_index( for cluster_id, size in state.cluster_sizes.items() if size >= settings.minimum_detections } + storage.upsert( + "actor", + _actor_cluster_records( + retained, + { + cluster_id: state.cluster_ranges[cluster_id] + for cluster_id in retained + }, + config, + ), + batch_size=config.storage_batch_size, + cancellation=CancellationToken(), + ) return sum(retained.values()), len(retained) diff --git a/src/vidxp/capabilities/actor/operations.py b/src/vidxp/capabilities/actor/operations.py index 9f3f09d..8e96642 100644 --- a/src/vidxp/capabilities/actor/operations.py +++ b/src/vidxp/capabilities/actor/operations.py @@ -1,16 +1,30 @@ from __future__ import annotations from vidxp.capabilities.actor.results import ( + actor_cluster, actor_clusters, actor_detections, ) -from vidxp.capabilities.contracts import CapabilityContext from vidxp.capabilities.actor.schemas import ( + ActorClusterInput, + ActorClusterSummary, ActorClustersInput, ActorClustersOutput, ActorDetectionsInput, ActorDetectionsOutput, ) +from vidxp.capabilities.contracts import CapabilityContext + + +def cluster_operation( + context: CapabilityContext, + request: ActorClusterInput, +) -> ActorClusterSummary: + return actor_cluster( + context.require_config(), + request.cluster_id, + storage=context.require_storage(), + ) def clusters_operation( diff --git a/src/vidxp/capabilities/actor/results.py b/src/vidxp/capabilities/actor/results.py index d16a4a5..114c4a4 100644 --- a/src/vidxp/capabilities/actor/results.py +++ b/src/vidxp/capabilities/actor/results.py @@ -1,16 +1,19 @@ from __future__ import annotations -import base64 -import json - from vidxp.capabilities.actor.schemas import ( ActorClusterSummary, ActorClustersOutput, ActorDetection, ActorDetectionsOutput, ) -from vidxp.core.contracts import IndexConfig from vidxp.capabilities.contracts import CapabilityRequestError +from vidxp.core.contracts import IndexConfig +from vidxp.core.cursors import ( + CursorError, + decode_cursor, + decode_offset_cursor, + encode_offset_cursor, +) from vidxp.ports import IndexReader @@ -22,23 +25,12 @@ def _decode_cursor(cursor: str | None, scope: str) -> int: if cursor is None: return 0 try: - payload = json.loads( - base64.urlsafe_b64decode(cursor.encode()).decode() - ) - if not isinstance(payload, dict): - raise TypeError - offset = int(payload["offset"]) - except ( - KeyError, - TypeError, - UnicodeDecodeError, - ValueError, - json.JSONDecodeError, - ) as exc: + payload = decode_cursor(cursor, scope) + if set(payload) != {"version", "scope", "offset"}: + raise CursorError("The cursor payload is invalid.") + return decode_offset_cursor(cursor, scope=scope) + except CursorError as exc: raise CapabilityRequestError("The actor cursor is invalid.") from exc - if payload.get("version") != 1 or payload.get("scope") != scope or offset < 0: - raise CapabilityRequestError("The actor cursor is invalid.") - return offset def _encode_cursor( @@ -47,65 +39,17 @@ def _encode_cursor( scope: str, has_more: bool, ) -> str | None: - if not has_more: - return None - payload = json.dumps( - {"version": 1, "scope": scope, "offset": offset}, - separators=(",", ":"), - sort_keys=True, - ).encode() - return base64.urlsafe_b64encode(payload).decode() + return encode_offset_cursor( + offset, + scope=scope, + has_more=has_more, + ) def _snapshot_scope(config: IndexConfig) -> str: return str(config.snapshot_id or config.fingerprint()) -def _decode_detection_cursor( - cursor: str | None, - scope: str, -) -> tuple[int, str] | None: - if cursor is None: - return None - try: - payload = json.loads( - base64.urlsafe_b64decode(cursor.encode()).decode() - ) - after = payload["after"] - if ( - not isinstance(payload, dict) - or payload.get("version") != 1 - or payload.get("scope") != scope - or not isinstance(after, list) - or len(after) != 2 - ): - raise ValueError - key = (int(after[0]), str(after[1])) - except ( - KeyError, - TypeError, - UnicodeDecodeError, - ValueError, - json.JSONDecodeError, - ) as exc: - raise CapabilityRequestError("The actor cursor is invalid.") from exc - if key[0] < 0 or not key[1]: - raise CapabilityRequestError("The actor cursor is invalid.") - return key - - -def _encode_detection_cursor( - key: tuple[int, str], - scope: str, -) -> str: - payload = json.dumps( - {"version": 1, "scope": scope, "after": list(key)}, - separators=(",", ":"), - sort_keys=True, - ).encode() - return base64.urlsafe_b64encode(payload).decode() - - def _actor_detection(record: dict) -> ActorDetection: return ActorDetection( **{ @@ -123,6 +67,78 @@ def _actor_detection(record: dict) -> ActorDetection: ) +def _cluster_summary( + cluster_id: str, + records: list[dict], +) -> ActorClusterSummary: + if not records: + raise ActorClusterNotFoundError( + f"Actor cluster {cluster_id} was not found in the completed index." + ) + media_ids = {str(record["video_id"]) for record in records} + generation_ids = { + str(record["generation_id"]) + for record in records + if record.get("generation_id") is not None + } + if len(media_ids) != 1 or len(generation_ids) != 1: + raise RuntimeError( + f"Actor cluster {cluster_id!r} spans multiple index identities." + ) + timestamps = [float(record["timestamp"]) for record in records] + return ActorClusterSummary( + cluster_id=cluster_id, + media_id=next(iter(media_ids)), + generation_id=next(iter(generation_ids)), + detection_count=len(records), + first_timestamp=min(timestamps), + last_timestamp=max(timestamps), + ) + + +def _stored_cluster_summary(record: dict) -> ActorClusterSummary: + if record.get("record_kind") != "cluster_summary": + raise RuntimeError("The actor cluster summary record is invalid.") + return ActorClusterSummary( + cluster_id=str(record["summary_cluster_id"]), + media_id=str(record["video_id"]), + generation_id=str(record["generation_id"]), + detection_count=int(record["detection_count"]), + first_timestamp=float(record["first_timestamp"]), + last_timestamp=float(record["last_timestamp"]), + ) + + +def actor_cluster( + config: IndexConfig, + cluster_id: str, + *, + storage: IndexReader, +) -> ActorClusterSummary: + del config + summaries = storage.records( + "actor", + filters={ + "record_kind": "cluster_summary", + "summary_cluster_id": cluster_id, + }, + limit=2, + ) + if len(summaries) > 1: + raise RuntimeError( + f"Actor cluster {cluster_id!r} spans multiple index identities." + ) + if summaries: + return _stored_cluster_summary(summaries[0]) + return _cluster_summary( + cluster_id, + storage.records( + "actor", + filters={"cluster_id": cluster_id}, + ), + ) + + def actor_clusters( config: IndexConfig, *, @@ -131,6 +147,35 @@ def actor_clusters( cursor: str | None, ) -> ActorClustersOutput: scope = f"actor:clusters:{_snapshot_scope(config)}" + offset = _decode_cursor(cursor, scope) + records = storage.records( + "actor", + filters={"record_kind": "cluster_summary"}, + limit=page_size + 1, + offset=offset, + ) + if records: + has_more = len(records) > page_size + summaries = tuple( + _stored_cluster_summary(record) + for record in records[:page_size] + ) + return ActorClustersOutput( + clusters=summaries, + total=None, + next_cursor=_encode_cursor( + offset + len(summaries), + scope=scope, + has_more=has_more, + ), + ) + if offset: + raise CapabilityRequestError( + "The actor cursor is outside the result set." + ) + + # Indexes created before cluster summaries were materialized remain + # readable, but must use the legacy full-scan path once. grouped: dict[str, dict] = {} storage_offset = 0 while True: @@ -187,7 +232,6 @@ def actor_clusters( ) ) - offset = _decode_cursor(cursor, scope) total = len(summaries) if offset > total: raise CapabilityRequestError( @@ -213,54 +257,34 @@ def actor_detections( page_size: int, cursor: str | None, ) -> ActorDetectionsOutput: - scope = ( - f"actor:detections:{_snapshot_scope(config)}:{cluster_id}" - ) - after = _decode_detection_cursor(cursor, scope) + scope = f"actor:detections:{_snapshot_scope(config)}:{cluster_id}" + storage_offset = _decode_cursor(cursor, scope) filters = {"cluster_id": cluster_id} - candidates: list[ActorDetection] = [] - storage_offset = 0 - found_any = False - while True: - records = storage.records( - "actor", - filters=filters, - limit=1000, - offset=storage_offset, - ) - found_any = found_any or bool(records) - for record in records: - detection = _actor_detection(record) - key = (detection.frame_index, detection.detection_id) - if after is None or key > after: - candidates.append(detection) - candidates = sorted( - candidates, - key=lambda item: (item.frame_index, item.detection_id), - )[: page_size + 1] - if len(records) < 1000: - break - storage_offset += len(records) - if not found_any: + records = storage.records( + "actor", + filters=filters, + limit=page_size + 1, + offset=storage_offset, + ) + if not records and storage_offset == 0: raise ActorClusterNotFoundError( f"Actor cluster {cluster_id} was not found in the completed index." ) - if after is not None and not candidates: + if not records: raise CapabilityRequestError( "The actor cursor is outside the result set." ) - has_more = len(candidates) > page_size - detections = tuple(candidates[:page_size]) - next_cursor = None - if has_more: - last = detections[-1] - next_cursor = _encode_detection_cursor( - (last.frame_index, last.detection_id), - scope, - ) + has_more = len(records) > page_size + detections = tuple( + _actor_detection(record) for record in records[:page_size] + ) return ActorDetectionsOutput( cluster_id=cluster_id, detections=detections, total=None, - next_cursor=next_cursor, + next_cursor=_encode_cursor( + storage_offset + len(detections), + scope=scope, + has_more=has_more, + ), ) diff --git a/src/vidxp/capabilities/actor/schemas.py b/src/vidxp/capabilities/actor/schemas.py index 0366c6a..c59223e 100644 --- a/src/vidxp/capabilities/actor/schemas.py +++ b/src/vidxp/capabilities/actor/schemas.py @@ -3,7 +3,12 @@ from pydantic import Field, model_validator from vidxp.capabilities.contracts import CapabilityInput, CapabilityOutput -from vidxp.core.identifiers import IndexGenerationId, MediaId +from vidxp.core.identifiers import ( + ActorClusterId, + Identifier, + IndexGenerationId, + MediaId, +) class ActorClustersInput(CapabilityInput): @@ -11,8 +16,12 @@ class ActorClustersInput(CapabilityInput): cursor: str | None = Field(default=None, min_length=1, max_length=512) +class ActorClusterInput(CapabilityInput): + cluster_id: ActorClusterId + + class ActorClusterSummary(CapabilityOutput): - cluster_id: str = Field(min_length=1) + cluster_id: ActorClusterId media_id: MediaId generation_id: IndexGenerationId detection_count: int = Field(ge=0) @@ -31,19 +40,19 @@ def to_dict(self) -> dict: class ActorClustersOutput(CapabilityOutput): clusters: tuple[ActorClusterSummary, ...] = () - total: int = Field(ge=0) + total: int | None = Field(default=None, ge=0) next_cursor: str | None = None class ActorDetectionsInput(CapabilityInput): - cluster_id: str = Field(min_length=1) + cluster_id: ActorClusterId page_size: int = Field(default=50, gt=0, le=100) cursor: str | None = Field(default=None, min_length=1, max_length=512) class ActorDetection(CapabilityOutput): - detection_id: str = Field(min_length=1) - cluster_id: str = Field(min_length=1) + detection_id: Identifier + cluster_id: ActorClusterId frame_index: int = Field(ge=0) timestamp: float = Field(ge=0) bbox: tuple[int, int, int, int] @@ -57,7 +66,7 @@ class ActorDetection(CapabilityOutput): class ActorDetectionsOutput(CapabilityOutput): - cluster_id: str = Field(min_length=1) + cluster_id: ActorClusterId detections: tuple[ActorDetection, ...] = () total: int | None = Field(default=None, ge=0) next_cursor: str | None = None diff --git a/src/vidxp/capabilities/registry.py b/src/vidxp/capabilities/registry.py index ded91b5..30fca6f 100644 --- a/src/vidxp/capabilities/registry.py +++ b/src/vidxp/capabilities/registry.py @@ -344,6 +344,7 @@ def dependency_checks( names: Iterable[str], *, source: VideoSource | None = None, + include_runtime_checks: bool = True, ) -> tuple[CapabilityDependencyCheck, ...]: selected = self.validate_names(names) checks = [] @@ -357,21 +358,22 @@ def dependency_checks( **result, ) ) - for binding in self._runtime_check_bindings( - selected, - source=source, - ): - result = binding.check.inspect() - checks.append( - CapabilityDependencyCheck( - capability=binding.capability, - provenance=binding.provenance, - kind=DependencyKind.runtime, - name=result["name"], - ok=result["ok"], - error=result["error"], + if include_runtime_checks: + for binding in self._runtime_check_bindings( + selected, + source=source, + ): + result = binding.check.inspect() + checks.append( + CapabilityDependencyCheck( + capability=binding.capability, + provenance=binding.provenance, + kind=DependencyKind.runtime, + name=result["name"], + ok=result["ok"], + error=result["error"], + ) ) - ) return tuple(checks) def require_dependencies( diff --git a/src/vidxp/capabilities/schemas.py b/src/vidxp/capabilities/schemas.py index 276d13d..953648e 100644 --- a/src/vidxp/capabilities/schemas.py +++ b/src/vidxp/capabilities/schemas.py @@ -1,85 +1,14 @@ from __future__ import annotations -from typing import Any +from pydantic import Field -from pydantic import Field, JsonValue, field_validator, model_validator - -from vidxp.capabilities.contracts import CapabilityInput, CapabilityOutput -from vidxp.core.contracts import INDEX_SCHEMA_VERSION -from vidxp.core.identifiers import IndexGenerationId, MediaId, VideoId +from vidxp.application_models import SearchHit, SearchQuery, SearchResult +from vidxp.capabilities.contracts import CapabilityInput class SearchInput(CapabilityInput): - query: str = Field(min_length=1) + query: SearchQuery top_k: int = Field(default=10, gt=0, le=100) -class SearchHit(CapabilityOutput): - rank: int = Field(gt=0) - media_id: MediaId - video_id: VideoId - generation_id: IndexGenerationId - start: float = Field(ge=0) - end: float = Field(gt=0) - score: float - raw_distance: float - modality: str = Field(min_length=1) - source_id: str = Field(min_length=1) - metadata: dict[str, JsonValue] = Field(default_factory=dict) - - @field_validator("metadata") - @classmethod - def _reject_internal_metadata( - cls, - value: dict[str, JsonValue], - ) -> dict[str, JsonValue]: - forbidden: set[str] = set() - - def inspect(item: JsonValue) -> None: - if isinstance(item, dict): - for key, nested in item.items(): - if ( - key == "path" - or key == "storage_key" - or key.endswith("_path") - or key.endswith("_directory") - ): - forbidden.add(key) - inspect(nested) - elif isinstance(item, list): - for nested in item: - inspect(nested) - - inspect(value) - if forbidden: - raise ValueError( - "Search metadata contains internal location fields." - ) - return value - - @model_validator(mode="after") - def _validate_interval(self) -> "SearchHit": - if self.end <= self.start: - raise ValueError("end must be greater than start") - return self - - def to_dict(self) -> dict[str, Any]: - return self.model_dump(mode="json") - - -class SearchResult(CapabilityOutput): - schema_version: int = INDEX_SCHEMA_VERSION - query_id: str = Field(min_length=1) - query: str = Field(min_length=1) - modality: str = Field(min_length=1) - hits: tuple[SearchHit, ...] = () - - def to_dict(self) -> dict[str, Any]: - return self.model_dump(mode="json") - - def to_prediction(self) -> dict[str, list[dict[str, Any]]]: - return { - self.query_id: [ - hit.model_dump(mode="json") for hit in self.hits - ] - } +__all__ = ["SearchHit", "SearchInput", "SearchResult"] diff --git a/src/vidxp/cli.py b/src/vidxp/cli.py index b4988ac..edb7b0f 100644 --- a/src/vidxp/cli.py +++ b/src/vidxp/cli.py @@ -10,6 +10,7 @@ from vidxp import __version__ from vidxp.application_models import ApplicationError +from vidxp.benchmarks.cli import app as benchmark_app from vidxp.cli_commands.actors import app as actor_app from vidxp.cli_commands.index import app as index_app from vidxp.cli_commands.jobs import app as jobs_app @@ -20,7 +21,6 @@ from vidxp.cli_commands.search import search from vidxp.cli_support import CLIState, OutputFormat from vidxp.composition import create_local_application -from vidxp.dependencies import requirements_available app = typer.Typer( @@ -36,16 +36,7 @@ app.add_typer(actor_app, name="actors") -def _load_benchmark_app(): - if not requirements_available("vidxp.benchmarks"): - return None - from vidxp.benchmarks.cli import app as benchmark_app - - return benchmark_app - - -if _benchmark_app := _load_benchmark_app(): - app.add_typer(_benchmark_app, name="benchmark") +app.add_typer(benchmark_app, name="benchmark") app.command()(doctor) app.command()(prepare) app.command()(ui) @@ -120,9 +111,9 @@ def app_options( index_directory=index_directory, device=device, ) + ctx.call_on_close(local.close) ctx.obj = CLIState( - service=local.application, - jobs=local.jobs, + local=local, registry=local.repositories, repository=local.repository, output_format=output_format, diff --git a/src/vidxp/cli_commands/actors.py b/src/vidxp/cli_commands/actors.py index 102a881..20ace1c 100644 --- a/src/vidxp/cli_commands/actors.py +++ b/src/vidxp/cli_commands/actors.py @@ -6,6 +6,7 @@ from rich.console import Console from rich.table import Table +from vidxp.application_models import CreateActorOverlayCommand from vidxp.cli_support import ( CLIState, OutputFormat, @@ -13,9 +14,6 @@ emit_json, state_from_context, ) -from vidxp.application_models import CreateActorOverlayCommand - - app = typer.Typer( no_args_is_help=True, help="Inspect and render actor clusters.", @@ -178,32 +176,8 @@ def actors_render( """Render one actor cluster as a result video.""" state = state_from_context(ctx) - cursor = None - selected = None - while True: - page = state.service.actor_clusters( - page_size=100, - cursor=cursor, - ) - selected = next( - ( - cluster - for cluster in page.clusters - if cluster.cluster_id == cluster_id - ), - None, - ) - if selected is not None or page.next_cursor is None: - break - cursor = page.next_cursor - if selected is None: - raise typer.BadParameter("Actor cluster was not found.") job = state.jobs.submit_actor_overlay( - CreateActorOverlayCommand( - cluster_id=cluster_id, - media_id=selected.media_id, - generation_id=selected.generation_id, - ) + CreateActorOverlayCommand(cluster_id=cluster_id) ) if not detach: job = state.jobs.wait(job.job_id) diff --git a/src/vidxp/cli_commands/artifacts.py b/src/vidxp/cli_commands/artifacts.py index 8e36fe9..d95aef2 100644 --- a/src/vidxp/cli_commands/artifacts.py +++ b/src/vidxp/cli_commands/artifacts.py @@ -49,6 +49,11 @@ def create_snippet( ) -> None: """Create a managed video snippet artifact.""" + if end_seconds <= start_seconds: + raise typer.BadParameter( + "The snippet end must be greater than its start.", + param_hint="end_seconds", + ) state = state_from_context(ctx) job = state.jobs.submit_snippet( CreateSnippetCommand( diff --git a/src/vidxp/cli_commands/index.py b/src/vidxp/cli_commands/index.py index 743ebf1..7cbf60a 100644 --- a/src/vidxp/cli_commands/index.py +++ b/src/vidxp/cli_commands/index.py @@ -113,12 +113,17 @@ def index_create( """Add media or replace its immutable generation in the active index.""" state = state_from_context(ctx) + indexable = tuple( + capability.name + for capability in state.service.list_capabilities() + if capability.supports_indexing + ) create_index( state, media_id=media_id, modalities=selected_modalities( modalities, - state.service.registry, + indexable, ), frame_stride=frame_stride, capability_options=parse_capability_options(capability_options), diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py index 0e568bc..564e048 100644 --- a/src/vidxp/cli_commands/runtime.py +++ b/src/vidxp/cli_commands/runtime.py @@ -37,10 +37,13 @@ def doctor( """Validate selected indexing dependencies without downloading models.""" state = state_from_context(ctx) + available = tuple( + capability.name for capability in state.service.list_capabilities() + ) selected = ( - state.service.registry.names() + available if modalities is None - else parse_modalities(modalities, state.service.registry) + else parse_modalities(modalities, available) ) result = state.service.check_dependencies( DependencyCheckCommand(modalities=selected) @@ -111,10 +114,15 @@ def prepare( """Download and cache selected runtime models before indexing.""" state = state_from_context(ctx) + preparable = tuple( + capability.name + for capability in state.service.list_capabilities() + if capability.prepares_models + ) selected = ( - state.service.registry.preparable_names() + preparable if modalities is None - else parse_modalities(modalities, state.service.registry) + else parse_modalities(modalities, preparable) ) job = state.jobs.submit_prepare_models( PrepareModelsCommand( @@ -188,5 +196,5 @@ def ui( streamlit_arguments.append(f"--server.port={port}") frontend.main( streamlit_arguments, - settings=state.service.settings, + settings=state.settings, ) diff --git a/src/vidxp/cli_commands/search.py b/src/vidxp/cli_commands/search.py index a0131fb..e30748d 100644 --- a/src/vidxp/cli_commands/search.py +++ b/src/vidxp/cli_commands/search.py @@ -4,7 +4,7 @@ import typer -from vidxp.application_models import SearchCommand +from vidxp.application_models import SearchCommand, SearchJobResult from vidxp.capabilities.schemas import SearchResult from vidxp.cli_support import ( CLIState, @@ -22,13 +22,17 @@ def run_search( top_k: int, json_output: bool, ) -> SearchResult: - result = state.service.search( + job = state.jobs.submit_search( SearchCommand( modality=capability, query=query, top_k=top_k, ) ) + completed = state.jobs.wait(job.job_id) + if not isinstance(completed.result, SearchJobResult): + raise RuntimeError("The completed search job has no search result.") + result = completed.result.result emit_search( result, output_format=effective_output_format(state, json_output), diff --git a/src/vidxp/cli_support.py b/src/vidxp/cli_support.py index bfc9efb..edd63a3 100644 --- a/src/vidxp/cli_support.py +++ b/src/vidxp/cli_support.py @@ -16,11 +16,15 @@ ) from rich.table import Table -from vidxp.application import VidXPApplication -from vidxp.capabilities.registry import CapabilityRegistry from vidxp.capabilities.schemas import SearchResult -from vidxp.job_service import JobService from vidxp.repositories import RepositoryConfig, RepositoryRegistry +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from vidxp.application import VidXPApplication + from vidxp.composition import LocalApplicationContext + from vidxp.job_service import JobService + from vidxp.settings import VidXPSettings class OutputFormat(str, Enum): @@ -30,13 +34,24 @@ class OutputFormat(str, Enum): @dataclass class CLIState: - service: VidXPApplication - jobs: JobService + local: "LocalApplicationContext" registry: RepositoryRegistry repository: RepositoryConfig output_format: OutputFormat = OutputFormat.rich quiet: bool = False + @property + def service(self) -> "VidXPApplication": + return self.local.application + + @property + def jobs(self) -> "JobService": + return self.local.jobs + + @property + def settings(self) -> "VidXPSettings": + return self.local.settings + def state_from_context(ctx: typer.Context) -> CLIState: state = ctx.ensure_object(CLIState) @@ -158,29 +173,30 @@ def update(self, event: dict[str, Any]) -> None: def selected_modalities( values: Iterable[str] | None, - registry: CapabilityRegistry, + available: Iterable[str], ) -> tuple[str, ...]: + supported = tuple(available) if values is None: - return registry.index_names() - try: - return registry.validate_names(values) - except ValueError as exc: - raise typer.BadParameter(str(exc)) from exc + return supported + selected = tuple(dict.fromkeys(values)) + unknown = sorted(set(selected) - set(supported)) + if unknown: + raise typer.BadParameter( + "Unknown or unsupported capabilities: " + ", ".join(unknown) + ) + return selected def parse_modalities( value: str, - registry: CapabilityRegistry, + available: Iterable[str], ) -> tuple[str, ...]: selected = tuple( item.strip().lower() for item in value.split(",") if item.strip() ) - try: - return registry.validate_names(selected) - except ValueError as exc: - raise typer.BadParameter(str(exc)) from exc + return selected_modalities(selected, available) def parse_capability_options( diff --git a/src/vidxp/composition.py b/src/vidxp/composition.py index fc86b4e..d29448a 100644 --- a/src/vidxp/composition.py +++ b/src/vidxp/composition.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from functools import cached_property from pathlib import Path from pydantic import ValidationError @@ -11,7 +12,10 @@ from vidxp.authentication import Authenticator, create_authenticator from vidxp.authorization import AuthorizationPolicy from vidxp.capability_service import CapabilityService -from vidxp.capabilities.registry import create_capability_registry +from vidxp.capabilities.registry import ( + CapabilityRegistry, + create_capability_registry, +) from vidxp.control_plane import ControlPlaneApplication from vidxp.infrastructure.local_index import ( LOCAL_INDEX_RUNTIME_CHECKS, @@ -30,6 +34,7 @@ from vidxp.job_service import JobService from vidxp.media_service import MediaService from vidxp.readiness_service import ReadinessService +from vidxp.read_job_planner import LocalReadJobPlanner from vidxp.runtime import ModelRuntime, RuntimeBackendUnavailableError from vidxp.repositories import ( RepositoryConfig, @@ -44,12 +49,50 @@ ) -@dataclass(frozen=True) class LocalApplicationContext: - application: VidXPApplication - jobs: JobService - repositories: RepositoryRegistry - repository: RepositoryConfig + """Lazy local composition so lightweight CLI commands stay lightweight.""" + + def __init__( + self, + *, + repositories: RepositoryRegistry, + repository: RepositoryConfig, + settings: VidXPSettings | None = None, + application: VidXPApplication | None = None, + jobs: JobService | None = None, + ) -> None: + if settings is None and (application is None or jobs is None): + raise ValueError( + "Lazy local composition requires settings or composed services." + ) + self.repositories = repositories + self.repository = repository + self._settings = settings + if application is not None: + self.__dict__["application"] = application + if jobs is not None: + self.__dict__["jobs"] = jobs + + @cached_property + def application(self) -> VidXPApplication: + assert self._settings is not None + return create_application(self._settings) + + @property + def settings(self) -> VidXPSettings: + if self._settings is not None: + return self._settings + return settings_for_repository(self.repository) + + @cached_property + def jobs(self) -> JobService: + assert self._settings is not None + return create_job_service(self._settings) + + def close(self) -> None: + jobs = self.__dict__.get("jobs") + if jobs is not None: + jobs.close() @dataclass(frozen=True) @@ -65,6 +108,43 @@ def close(self) -> None: self.jobs.close() +@dataclass(frozen=True) +class _ControlPlaneComponents: + registry: CapabilityRegistry + catalog: LocalCatalog + media: MediaService + artifact_store: LocalArtifactStore + probe: FFprobeMediaProbe + + +def _create_control_plane_components( + settings: VidXPSettings, +) -> _ControlPlaneComponents: + settings.layout.ensure_local_directories() + registry = create_capability_registry( + external=settings.external_capabilities, + allowlist=settings.capability_allowlist, + platform_runtime_checks=LOCAL_INDEX_RUNTIME_CHECKS, + ) + catalog = LocalCatalog(settings.layout.catalog) + probe = FFprobeMediaProbe(settings.ffprobe_executable) + return _ControlPlaneComponents( + registry=registry, + catalog=catalog, + media=MediaService( + settings=settings, + catalog=catalog, + store=LocalMediaStore( + settings.layout.media, + max_bytes=settings.max_local_import_bytes, + ), + probe=probe, + ), + artifact_store=LocalArtifactStore(settings.layout.artifacts), + probe=probe, + ) + + def settings_for_repository(repository: RepositoryConfig) -> VidXPSettings: values = {"repository_root": repository.index_directory} if repository.device is not None: @@ -76,15 +156,11 @@ def create_application( settings: VidXPSettings | None = None, ) -> VidXPApplication: active_settings = settings or VidXPSettings() - registry = create_capability_registry( - external=active_settings.external_capabilities, - allowlist=active_settings.capability_allowlist, - platform_runtime_checks=LOCAL_INDEX_RUNTIME_CHECKS, - ) + components = _create_control_plane_components(active_settings) try: runtime = ModelRuntime( active_settings, - allowed_specs=registry.model_specs(), + allowed_specs=components.registry.model_specs(), ) except RuntimeBackendUnavailableError as exc: raise ApplicationError( @@ -92,23 +168,16 @@ def create_application( ErrorCategory.unavailable, "The requested runtime backend is unavailable.", ) from exc - backend = LocalIndexBackend(registry, runtime, active_settings.layout) - active_settings.layout.ensure_local_directories() - catalog = LocalCatalog(active_settings.layout.catalog) - media = MediaService( - settings=active_settings, - catalog=catalog, - store=LocalMediaStore( - active_settings.layout.media, - max_bytes=active_settings.max_local_import_bytes, - ), - probe=FFprobeMediaProbe(active_settings.ffprobe_executable), + backend = LocalIndexBackend( + components.registry, + runtime, + active_settings.layout, ) artifacts = ArtifactService( - catalog=catalog, - store=LocalArtifactStore(active_settings.layout.artifacts), - media=media, - probe=FFprobeMediaProbe(active_settings.ffprobe_executable), + catalog=components.catalog, + store=components.artifact_store, + media=components.media, + probe=components.probe, actor_renderer=LocalActorRenderer(), snippet_renderer=FFmpegSnippetRenderer( active_settings.ffmpeg_executable @@ -120,11 +189,12 @@ def create_application( return VidXPApplication( settings=active_settings, layout=active_settings.layout, - registry=registry, + registry=components.registry, runtime=runtime, index_backend=backend, - media_service=media, - artifact_service=artifacts, + media=components.media, + artifacts=artifacts, + index_status=backend.repository.status, ) @@ -147,6 +217,7 @@ def create_job_service(settings: VidXPSettings) -> JobService: health_check=health_check, stop_executor=stop_executor, ), + read_planner=LocalReadJobPlanner(layout=settings.layout), ) @@ -155,31 +226,18 @@ def create_http_application( ) -> HttpApplicationContext: active_settings = settings or VidXPSettings() active_settings.validate_http_server() - active_settings.layout.ensure_local_directories() - registry = create_capability_registry( - external=active_settings.external_capabilities, - allowlist=active_settings.capability_allowlist, - platform_runtime_checks=LOCAL_INDEX_RUNTIME_CHECKS, - ) - catalog = LocalCatalog(active_settings.layout.catalog) - media = MediaService( - settings=active_settings, - catalog=catalog, - store=LocalMediaStore( - active_settings.layout.media, - max_bytes=active_settings.max_local_import_bytes, - ), - probe=FFprobeMediaProbe(active_settings.ffprobe_executable), - ) + components = _create_control_plane_components(active_settings) application = ControlPlaneApplication( layout=active_settings.layout, - capabilities=CapabilityService(registry), - media=media, + capabilities=CapabilityService(components.registry), + media=components.media, artifacts=ArtifactQueryService( - catalog=catalog, - store=LocalArtifactStore(active_settings.layout.artifacts), + catalog=components.catalog, + store=components.artifact_store, ), - index=LocalSnapshotRepository(active_settings.layout.indexes), + index_status=LocalSnapshotRepository( + active_settings.layout.indexes + ).status, ) jobs = create_job_service(active_settings) authenticator = create_authenticator(active_settings) @@ -219,8 +277,7 @@ def create_local_application( "The repository or runtime configuration is invalid.", ) from exc return LocalApplicationContext( - application=create_application(settings), - jobs=create_job_service(settings), repositories=repositories, repository=repository, + settings=settings, ) diff --git a/src/vidxp/control_plane.py b/src/vidxp/control_plane.py index c9d39d3..4284aeb 100644 --- a/src/vidxp/control_plane.py +++ b/src/vidxp/control_plane.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import Protocol +from typing import Callable from vidxp.application_boundary import application_boundary from vidxp.application_models import ( @@ -26,10 +26,6 @@ from vidxp.repository_layout import RepositoryLayout -class IndexStatusPort(Protocol): - def status(self) -> dict | None: ... - - class ControlPlaneApplication: """Model-free application facade used by HTTP and future remote adapters.""" @@ -40,13 +36,13 @@ def __init__( capabilities: CapabilityService, media: MediaService, artifacts: ArtifactQueryService, - index: IndexStatusPort, + index_status: Callable[[], dict | None], ) -> None: self.layout = layout self.capabilities = capabilities self.media = media self.artifacts = artifacts - self.index = index + self._read_index_status = index_status @application_boundary def import_uploaded_media( @@ -80,7 +76,7 @@ def get_capability(self, name: str) -> CapabilityInfo: @application_boundary def index_status(self) -> IndexStatus: - stored = self.index.status() + stored = self._read_index_status() payload = ( dict(stored) if stored is not None diff --git a/src/vidxp/core/cursors.py b/src/vidxp/core/cursors.py new file mode 100644 index 0000000..518d5e2 --- /dev/null +++ b/src/vidxp/core/cursors.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import base64 +import binascii +import json +from typing import Any, Mapping + + +MAX_CURSOR_OFFSET = (1 << 63) - 1 + + +class CursorError(ValueError): + """Raised when an opaque VidXP cursor cannot be validated.""" + + +def encode_cursor(scope: str, position: Mapping[str, Any]) -> str: + payload = { + "version": 1, + "scope": scope, + **position, + } + encoded = json.dumps( + payload, + separators=(",", ":"), + sort_keys=True, + ).encode() + return base64.urlsafe_b64encode(encoded).decode() + + +def decode_cursor(cursor: str, scope: str) -> dict[str, Any]: + try: + if not isinstance(cursor, str): + raise TypeError + encoded = cursor.encode("ascii") + decoded = base64.b64decode( + encoded, + altchars=b"-_", + validate=True, + ) + if base64.urlsafe_b64encode(decoded) != encoded: + raise CursorError("The cursor is invalid.") + payload = json.loads(decoded.decode()) + except ( + CursorError, + TypeError, + UnicodeEncodeError, + UnicodeDecodeError, + ValueError, + json.JSONDecodeError, + binascii.Error, + ) as exc: + raise CursorError("The cursor is invalid.") from exc + if ( + not isinstance(payload, dict) + or payload.get("version") != 1 + or payload.get("scope") != scope + ): + raise CursorError("The cursor is invalid.") + return payload + + +def encode_offset_cursor( + offset: int, + *, + scope: str, + has_more: bool = True, +) -> str | None: + if not has_more: + return None + if ( + not isinstance(offset, int) + or isinstance(offset, bool) + or offset < 0 + or offset > MAX_CURSOR_OFFSET + ): + raise CursorError("The cursor offset is invalid.") + return encode_cursor(scope, {"offset": offset}) + + +def decode_offset_cursor(cursor: str | None, *, scope: str) -> int: + if cursor is None: + return 0 + payload = decode_cursor(cursor, scope) + offset = payload.get("offset") + if ( + not isinstance(offset, int) + or isinstance(offset, bool) + or offset < 0 + or offset > MAX_CURSOR_OFFSET + ): + raise CursorError("The cursor offset is invalid.") + return offset diff --git a/src/vidxp/core/identifiers.py b/src/vidxp/core/identifiers.py index d488282..779e008 100644 --- a/src/vidxp/core/identifiers.py +++ b/src/vidxp/core/identifiers.py @@ -13,6 +13,17 @@ pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$", ), ] +ActorClusterId: TypeAlias = Annotated[ + str, + StringConstraints( + strip_whitespace=True, + min_length=1, + max_length=512, + pattern=r"^[A-Za-z0-9._~:%-]+$", + ), +] + + def _require_uuid4_hex(value: str) -> str: identifier = UUID(hex=value) if identifier.version != 4 or identifier.hex != value: diff --git a/src/vidxp/frontend.py b/src/vidxp/frontend.py index dd7150a..beae6a5 100644 --- a/src/vidxp/frontend.py +++ b/src/vidxp/frontend.py @@ -15,6 +15,7 @@ CreateActorOverlayCommand, CreateIndexCommand, DependencyCheckCommand, + ErrorCategory, ImportMediaCommand, JobState, SearchCommand, @@ -62,6 +63,41 @@ def _settings_from_arguments( CANCEL_REQUESTED_KEY = "_vidxp_cancel_requested" MEDIA_ID_KEY = "_vidxp_media_id" UPLOAD_TOKEN_KEY = "_vidxp_upload_token" +INDEX_JOB_QUERY_PARAM = "index_job" +SEARCH_JOB_QUERY_PARAM = "search_job" +SEARCH_TYPE_QUERY_PARAM = "search_type" + + +def _remember_job( + *, + job_id: str, + query_param: str, + search_type: str | None = None, +) -> None: + st.query_params[query_param] = job_id + if search_type is not None: + st.query_params[SEARCH_TYPE_QUERY_PARAM] = search_type + + +def _forget_job(query_param: str) -> None: + st.query_params.pop(query_param, None) + if query_param == SEARCH_JOB_QUERY_PARAM: + st.query_params.pop(SEARCH_TYPE_QUERY_PARAM, None) + + +def _restore_durable_jobs() -> None: + if INDEX_JOB_ID_KEY not in st.session_state: + if job_id := st.query_params.get(INDEX_JOB_QUERY_PARAM): + st.session_state[INDEX_JOB_ID_KEY] = str(job_id) + if SEARCH_RESULT_KEY not in st.session_state: + if job_id := st.query_params.get(SEARCH_JOB_QUERY_PARAM): + st.session_state[SEARCH_RESULT_KEY] = { + "type": str( + st.query_params.get(SEARCH_TYPE_QUERY_PARAM) or "search" + ), + "query": "", + "job_id": str(job_id), + } def _upload_token(uploaded_video): @@ -111,9 +147,16 @@ def _get_job(jobs: JobService, job_id: str | None): return None try: return jobs.get(job_id) - except ApplicationError: - LOGGER.warning("Ignoring unavailable background job %s.", job_id) - return None + except ApplicationError as exc: + if exc.category == ErrorCategory.not_found: + LOGGER.info("Background job %s no longer exists.", job_id) + return None + LOGGER.warning( + "Could not query background job %s: %s", + job_id, + exc.code, + ) + raise def _render_index_status(status, active, media_id, request_error=None): @@ -172,14 +215,53 @@ def _request_cancellation(): def _available_index_modalities() -> tuple[str, ...]: service = _configured_service() return tuple( - name - for name in service.registry.index_names() + capability.name + for capability in service.list_capabilities() + if capability.supports_indexing if service.check_dependencies( - DependencyCheckCommand(modalities=(name,)) + DependencyCheckCommand( + modalities=(capability.name,), + include_runtime_checks=False, + ) ).ok ) +def _available_query_modalities( + configured: tuple[str, ...], +) -> tuple[str, ...]: + def supports_query(capability) -> bool: + operations = { + operation.name for operation in capability.operations + } + return ( + "search" in operations + or {"clusters", "detections"}.issubset(operations) + ) + + return tuple( + capability.name + for capability in _configured_service().list_capabilities() + if capability.name in configured + if supports_query(capability) + ) + + +def _finish_index_job(job) -> None: + _forget_job(INDEX_JOB_QUERY_PARAM) + if job.state == JobState.succeeded: + st.session_state.pop(INDEX_ERROR_KEY, None) + elif job.error is not None: + st.session_state[INDEX_ERROR_KEY] = job.error.message + elif job.state == JobState.cancelled: + st.session_state[INDEX_ERROR_KEY] = "Indexing was cancelled." + else: + st.session_state[INDEX_ERROR_KEY] = ( + "Indexing did not complete successfully." + ) + st.session_state.pop(INDEX_JOB_ID_KEY, None) + + def _run_indexing(uploaded_video, status, modalities): service = _configured_service() temporary_path = None @@ -218,6 +300,10 @@ def _run_indexing(uploaded_video, status, modalities): ) ) st.session_state[INDEX_JOB_ID_KEY] = job.job_id + _remember_job( + job_id=job.job_id, + query_param=INDEX_JOB_QUERY_PARAM, + ) except ApplicationError as exc: st.session_state[INDEX_ERROR_KEY] = str(exc) except Exception: @@ -241,50 +327,36 @@ def _run_search(search_type, query): if status.get("state") != "ready": raise IndexNotReadyError("The video index is not ready.") if search_type == "actor": - clusters = service.actor_clusters(page_size=100).clusters - cluster = next( - ( - item - for item in clusters - if item.cluster_id == query - ), - None, - ) - if cluster is None: - return {"error": "No matching actor cluster was found."} job = _configured_jobs().submit_actor_overlay( - CreateActorOverlayCommand( - cluster_id=query, - media_id=cluster.media_id, - generation_id=cluster.generation_id, - ) + CreateActorOverlayCommand(cluster_id=query) + ) + _remember_job( + job_id=job.job_id, + query_param=SEARCH_JOB_QUERY_PARAM, + search_type=search_type, ) - artifact = _configured_jobs().wait(job.job_id).result - if artifact is None: - return {"error": "The actor artifact result is unavailable."} - artifact = artifact.result return { "type": search_type, "query": query, - "artifact_id": artifact.artifact_id, + "job_id": job.job_id, } - result = service.search( + job = _configured_jobs().submit_search( SearchCommand( modality=search_type, query=query, top_k=1, ) ) - if not result.hits: - return {"error": f"No {search_type} match was found."} - hit = result.hits[0] + _remember_job( + job_id=job.job_id, + query_param=SEARCH_JOB_QUERY_PARAM, + search_type=search_type, + ) return { "type": search_type, "query": query, - "timestamp": hit.start, - "hit": hit.to_dict(), - "media_id": hit.media_id, + "job_id": job.job_id, } except IndexNotReadyError as exc: return {"error": str(exc)} @@ -302,6 +374,88 @@ def _render_search_result(result): st.error(error) return + if "job_id" in result: + + @st.fragment(run_every="1s") + def poll_search_job(): + label = ( + "actor overlay" + if result["type"] == "actor" + else f"{result['type']} search" + ) + try: + job = _get_job(_configured_jobs(), result["job_id"]) + except ApplicationError as exc: + message = ( + f"The {label} status is temporarily unavailable. Retrying." + if exc.retryable + else f"The {label} status could not be read: {exc}" + ) + st.warning(message) + return + if job is None: + _forget_job(SEARCH_JOB_QUERY_PARAM) + st.session_state[SEARCH_RESULT_KEY] = { + "type": result["type"], + "query": result["query"], + "error": f"The {label} job is unavailable.", + } + st.rerun() + return + if job.state in {JobState.queued, JobState.running}: + if job.progress is not None: + _render_progress(job.progress.model_dump(mode="json")) + else: + st.markdown(f"⏳ {label.title()} is queued.") + return + if job.state != JobState.succeeded or job.result is None: + _forget_job(SEARCH_JOB_QUERY_PARAM) + message = { + JobState.cancelled: f"The {label} was cancelled.", + }.get( + job.state, + ( + job.error.message + if job.error is not None + else f"The {label} did not complete successfully." + ), + ) + st.session_state[SEARCH_RESULT_KEY] = { + "type": result["type"], + "query": result["query"], + "error": message, + } + st.rerun() + return + completed = job.result.result + _forget_job(SEARCH_JOB_QUERY_PARAM) + if result["type"] == "actor": + resolved = { + "type": "actor", + "query": result["query"], + "artifact_id": completed.artifact_id, + } + elif not completed.hits: + resolved = { + "type": result["type"], + "query": completed.query, + "error": f"No {result['type']} match was found.", + } + else: + hit = completed.hits[0] + resolved = { + "type": result["type"], + "query": completed.query, + "timestamp": hit.start, + "hit": hit.to_dict(), + "media_id": hit.media_id, + } + st.session_state[SEARCH_RESULT_KEY] = resolved + st.rerun() + + poll_search_job() + return + service = _configured_service() try: resource = ( @@ -315,7 +469,13 @@ def _render_search_result(result): search_type = result["type"] if search_type == "actor": - st.success(f"Actor cluster {result['query']}") + st.success( + ( + f"Actor cluster {result['query']}" + if result.get("query") + else "Actor overlay" + ) + ) st.video( str(resource.path), format=resource.mime_type, @@ -406,14 +566,34 @@ def run(): st.caption(f"Index repository: {service.layout.root}") jobs = _configured_jobs() + _restore_durable_jobs() job_id = st.session_state.get(INDEX_JOB_ID_KEY) - current_job = _get_job(jobs, job_id) + job_lookup_error = None + try: + current_job = _get_job(jobs, job_id) + except ApplicationError as exc: + current_job = None + job_lookup_error = exc if job_id is not None and current_job is None: - st.session_state.pop(INDEX_JOB_ID_KEY, None) + if job_lookup_error is None: + st.session_state.pop(INDEX_JOB_ID_KEY, None) + _forget_job(INDEX_JOB_QUERY_PARAM) + job_id = None + elif ( + current_job is not None + and current_job.state not in {JobState.queued, JobState.running} + ): + _finish_index_job(current_job) job_id = None active = ( - current_job is not None - and current_job.state in {JobState.queued, JobState.running} + job_id is not None + and ( + job_lookup_error is not None + or ( + current_job is not None + and current_job.state in {JobState.queued, JobState.running} + ) + ) ) if not active: st.session_state.pop(CANCEL_REQUESTED_KEY, None) @@ -490,9 +670,20 @@ def run(): @st.fragment(run_every="1s") def poll_index_status(): - latest_job = _get_job(jobs, job_id) + try: + latest_job = _get_job(jobs, job_id) + except ApplicationError as exc: + message = ( + "The indexing job status is temporarily unavailable. " + "Retrying." + if exc.retryable + else f"The indexing job status could not be read: {exc}" + ) + st.warning(message) + return if latest_job is None: st.session_state.pop(INDEX_JOB_ID_KEY, None) + _forget_job(INDEX_JOB_QUERY_PARAM) st.error("The background indexing job is unavailable.") st.rerun() return @@ -501,21 +692,7 @@ def poll_index_status(): JobState.running, } if not latest_active: - if latest_job.state == JobState.succeeded: - st.session_state.pop(INDEX_ERROR_KEY, None) - elif latest_job.error is not None: - st.session_state[INDEX_ERROR_KEY] = ( - latest_job.error.message - ) - elif latest_job.state == JobState.cancelled: - st.session_state[INDEX_ERROR_KEY] = ( - "Indexing was cancelled." - ) - else: - st.session_state[INDEX_ERROR_KEY] = ( - "Indexing did not complete successfully." - ) - st.session_state.pop(INDEX_JOB_ID_KEY, None) + _finish_index_job(latest_job) st.rerun() return latest_status = ( @@ -547,15 +724,13 @@ def poll_index_status(): configured_modalities = ( (status.get("summary") or {}).get( "modalities", - ("scene", "dialogue", "actor"), + (), ) if ready - else ("scene", "dialogue", "actor") + else () ) - available_modalities = tuple( - modality - for modality in ("scene", "dialogue", "actor") - if modality in configured_modalities + available_modalities = _available_query_modalities( + tuple(configured_modalities) ) search_clicked, search_type, query = _search_controls( ready, diff --git a/src/vidxp/infrastructure/dbos_jobs.py b/src/vidxp/infrastructure/dbos_jobs.py index b670254..ecf4fd4 100644 --- a/src/vidxp/infrastructure/dbos_jobs.py +++ b/src/vidxp/infrastructure/dbos_jobs.py @@ -1,10 +1,8 @@ from __future__ import annotations -import base64 -import binascii -import json from collections.abc import Callable from datetime import datetime, timezone +from time import time_ns from typing import Any from uuid import uuid4 @@ -28,8 +26,17 @@ ListJobsCommand, PrepareModelsJobResult, PrepareModelsResult, + SearchJobResult, + SearchResult, ) from vidxp.core.identifiers import JobId +from vidxp.core.cursors import ( + MAX_CURSOR_OFFSET, + CursorError, + decode_cursor, + decode_offset_cursor, + encode_cursor, +) from vidxp.ports import ( InvalidJobBackendRequestError, JobIdempotencyConflictError, @@ -49,6 +56,7 @@ _JOB_ID_ADAPTER = TypeAdapter(JobId) _JOB_REQUEST_ADAPTER = TypeAdapter(JobRequest) _LIST_SCOPE = "vidxp:jobs" +_WINDOW_END_FIELD = "window_end_ms" _STATUS_STATES = { "ENQUEUED": JobState.queued, "DELAYED": JobState.queued, @@ -60,46 +68,6 @@ } -def _encode_cursor(offset: int, *, has_more: bool) -> str | None: - if not has_more: - return None - payload = json.dumps( - {"version": 1, "scope": _LIST_SCOPE, "offset": offset}, - sort_keys=True, - separators=(",", ":"), - ).encode() - return base64.urlsafe_b64encode(payload).decode() - - -def _decode_cursor(cursor: str | None) -> int: - if cursor is None: - return 0 - try: - payload = json.loads( - base64.urlsafe_b64decode(cursor.encode()).decode() - ) - offset = int(payload["offset"]) - except ( - KeyError, - TypeError, - UnicodeDecodeError, - ValueError, - json.JSONDecodeError, - binascii.Error, - ) as exc: - raise InvalidJobBackendRequestError( - "The job cursor is invalid." - ) from exc - if ( - not isinstance(payload, dict) - or payload.get("version") != 1 - or payload.get("scope") != _LIST_SCOPE - or offset < 0 - ): - raise InvalidJobBackendRequestError("The job cursor is invalid.") - return offset - - def _job_id(value: str) -> str: try: return _JOB_ID_ADAPTER.validate_python(value) @@ -115,6 +83,16 @@ def _timestamp(value: int | None) -> datetime | None: return datetime.fromtimestamp(value / 1000, timezone.utc) +def _now_ms() -> int: + return time_ns() // 1_000_000 + + +def _window_end_iso(value: int) -> str: + return datetime.fromtimestamp(value / 1000, timezone.utc).isoformat( + timespec="milliseconds" + ) + + class DBOSJobBackend: """Thin projection of DBOS workflow state into VidXP job contracts.""" @@ -206,22 +184,60 @@ def _idempotent_job(self, status: Any, request: JobRequest) -> Job: def list(self, command: ListJobsCommand) -> JobPage: self._prepare_access() - offset = _decode_cursor(command.cursor) + try: + offset = decode_offset_cursor( + command.cursor, + scope=_LIST_SCOPE, + ) + if command.cursor is None: + window_end_ms = _now_ms() + else: + payload = decode_cursor(command.cursor, _LIST_SCOPE) + window_end_ms = payload.get(_WINDOW_END_FIELD) + if window_end_ms is None: + # Legacy v1 offset cursors did not freeze the result + # window. Preserve them on a best-effort basis, then + # upgrade the next cursor to the stable representation. + window_end_ms = _now_ms() + if ( + not isinstance(window_end_ms, int) + or isinstance(window_end_ms, bool) + or window_end_ms < 0 + or window_end_ms > MAX_CURSOR_OFFSET + ): + raise CursorError("The cursor window is invalid.") + except CursorError as exc: + raise InvalidJobBackendRequestError( + "The job cursor is invalid." + ) from exc statuses = self.client.list_workflows( name=list(WORKFLOW_KINDS), limit=command.page_size + 1, offset=offset, sort_desc=True, + end_time=_window_end_iso(window_end_ms), load_input=False, load_output=True, ) has_more = len(statuses) > command.page_size selected = statuses[: command.page_size] + next_offset = offset + len(selected) + if next_offset > MAX_CURSOR_OFFSET: + raise InvalidJobBackendRequestError( + "The job cursor is invalid." + ) return JobPage( items=tuple(self._job(status) for status in selected), - next_cursor=_encode_cursor( - offset + len(selected), - has_more=has_more, + next_cursor=( + encode_cursor( + _LIST_SCOPE, + { + "offset": next_offset, + _WINDOW_END_FIELD: window_end_ms, + }, + ) + if has_more + else None ), ) @@ -314,6 +330,10 @@ def _job(self, status: Any) -> Job: result = IndexJobResult( result=IndexResult.model_validate(status.output) ) + elif kind == JobKind.search: + result = SearchJobResult( + result=SearchResult.model_validate(status.output) + ) elif kind in {JobKind.snippet, JobKind.actor_overlay}: result = ArtifactJobResult( kind=kind, diff --git a/src/vidxp/infrastructure/dbos_workflows.py b/src/vidxp/infrastructure/dbos_workflows.py index 41270f6..4ab3831 100644 --- a/src/vidxp/infrastructure/dbos_workflows.py +++ b/src/vidxp/infrastructure/dbos_workflows.py @@ -20,6 +20,7 @@ JobProgress, SnippetJobRequest, PrepareModelsJobRequest, + SearchJobRequest, ) from vidxp.core.contracts import CancellationToken from vidxp.core.contracts import IndexCancelledError @@ -174,12 +175,42 @@ def execute() -> dict[str, Any]: else: result = self.application.render_actor( request.command, + snapshot=request.snapshot, execution=execution, ) return result.model_dump(mode="json") return _step_boundary(execute) + @DBOS.step(name="vidxp.run_search.v1") + def run_search_step(self, payload: dict[str, Any]) -> dict[str, Any]: + def execute() -> dict[str, Any]: + request = SearchJobRequest.model_validate(payload) + execution = _execution() + _publish_progress( + { + "stage": "searching", + "message": "Searching the committed index.", + } + ) + execution.checkpoint() + result = self.application.search( + request.command, + snapshot=request.snapshot, + ) + execution.checkpoint() + _publish_progress( + { + "stage": "complete", + "message": "Search results are ready.", + "current": 1, + "total": 1, + } + ) + return result.model_dump(mode="json") + + return _step_boundary(execute) + @DBOS.step(name="vidxp.prepare_models_step.v1") def prepare_models_step( self, @@ -218,6 +249,10 @@ def index_workflow(self, payload: dict[str, Any]) -> dict[str, Any]: def snippet_workflow(self, payload: dict[str, Any]) -> dict[str, Any]: return self.run_artifact_step(payload) + @DBOS.workflow(name=WORKFLOW_NAMES[JobKind.search]) + def search_workflow(self, payload: dict[str, Any]) -> dict[str, Any]: + return self.run_search_step(payload) + @DBOS.workflow(name=WORKFLOW_NAMES[JobKind.actor_overlay]) def actor_overlay_workflow( self, diff --git a/src/vidxp/infrastructure/local_index.py b/src/vidxp/infrastructure/local_index.py index e8d0679..5739fad 100644 --- a/src/vidxp/infrastructure/local_index.py +++ b/src/vidxp/infrastructure/local_index.py @@ -46,15 +46,10 @@ ) -class LocalIndexBackend: - def __init__( - self, - registry: CapabilityRegistry, - runtime: ModelRuntime, - layout: RepositoryLayout, - ) -> None: - self.registry = registry - self.runtime = runtime +class LocalIndexReader: + """Model-free access to immutable local index snapshots.""" + + def __init__(self, layout: RepositoryLayout) -> None: self.layout = layout self.repository = LocalSnapshotRepository(layout.indexes) @@ -64,17 +59,6 @@ def _require_index_directory(self, index_directory: str | Path) -> None: "The index operation is outside the configured repository." ) - def status(self, index_directory: Path) -> dict[str, Any] | None: - self._require_index_directory(index_directory) - status = self.repository.status() - if status is not None and status.get("state") == "ready": - config, snapshot = self.repository.active_config( - device=self.runtime.backends.torch_device - ) - with self._open_committed_storage(config) as storage: - self._validate_snapshot_storage(storage, snapshot) - return status - def active_config( self, index_directory: Path, @@ -82,9 +66,7 @@ def active_config( device: str, ) -> tuple[IndexConfig, dict[str, Any]]: self._require_index_directory(index_directory) - config, snapshot = self.repository.active_config( - device=device - ) + config, snapshot = self.repository.active_config(device=device) return config, { "snapshot_id": snapshot.snapshot_id, "generation_ids": [ @@ -94,6 +76,102 @@ def active_config( "media_ids": sorted(snapshot.generations), } + def config_for_snapshot( + self, + index_directory: Path, + *, + snapshot_id: str, + snapshot_sha256: str, + device: str, + ) -> IndexConfig: + self._require_index_directory(index_directory) + return self.repository.config_for_snapshot( + snapshot_id, + snapshot_sha256=snapshot_sha256, + device=device, + ) + + def open_store(self, config: IndexConfig) -> SnapshotScopedIndexStore: + if config.snapshot_id is None: + raise IndexSchemaError("A snapshot ID is required for index reads.") + if config.snapshot_sha256 is None: + raise IndexSchemaError( + "A snapshot checksum is required for index reads." + ) + snapshot = self.repository.read_snapshot( + config.snapshot_id, + expected_sha256=config.snapshot_sha256, + ) + generation_ids = tuple( + reference.generation_id + for reference in snapshot.generations.values() + ) + storage = self._open_committed_storage(config) + return SnapshotScopedIndexStore( + storage, + generation_ids=generation_ids, + ) + + @classmethod + def _validate_snapshot_storage( + cls, + storage: IndexStorage, + snapshot, + base_config: IndexConfig | None = None, + ) -> None: + active_config = base_config or storage.config + for reference in snapshot.generations.values(): + try: + actual = { + modality: storage.count_records( + modality, + video_id=reference.media_id, + generation_ids=(reference.generation_id,), + ) + for modality in active_config.enabled_modalities + } + except FileNotFoundError as exc: + raise IndexSchemaError( + "A committed Chroma collection is missing." + ) from exc + if actual != dict(reference.record_counts): + raise IndexSchemaError( + "Stored generation record counts do not match the " + "authoritative manifest." + ) + + @staticmethod + def _open_committed_storage(config: IndexConfig) -> IndexStorage: + try: + return IndexStorage(config, create=False) + except FileNotFoundError as exc: + raise IndexSchemaError( + "The committed Chroma store is missing." + ) from exc + + +class LocalIndexBackend(LocalIndexReader): + def __init__( + self, + registry: CapabilityRegistry, + runtime: ModelRuntime, + layout: RepositoryLayout, + ) -> None: + super().__init__(layout) + self.registry = registry + self.runtime = runtime + + def status(self, index_directory: Path) -> dict[str, Any] | None: + self._require_index_directory(index_directory) + status = self.repository.status() + if status is not None and status.get("state") == "ready": + config, snapshot = self.repository.active_config( + device=self.runtime.backends.torch_device + ) + with self._open_committed_storage(config) as storage: + self._validate_snapshot_storage(storage, snapshot) + return status + def create( self, path: Path, @@ -319,43 +397,6 @@ def _validate_generation_records( ) return counts - @classmethod - def _validate_snapshot_storage( - cls, - storage: IndexStorage, - snapshot, - base_config: IndexConfig | None = None, - ) -> None: - active_config = base_config or storage.config - for reference in snapshot.generations.values(): - try: - actual = { - modality: storage.count_records( - modality, - video_id=reference.media_id, - generation_ids=(reference.generation_id,), - ) - for modality in active_config.enabled_modalities - } - except FileNotFoundError as exc: - raise IndexSchemaError( - "A committed Chroma collection is missing." - ) from exc - if actual != dict(reference.record_counts): - raise IndexSchemaError( - "Stored generation record counts do not match the " - "authoritative manifest." - ) - - @staticmethod - def _open_committed_storage(config: IndexConfig) -> IndexStorage: - try: - return IndexStorage(config, create=False) - except FileNotFoundError as exc: - raise IndexSchemaError( - "The committed Chroma store is missing." - ) from exc - @classmethod def _cleanup_abandoned_generations( cls, @@ -477,33 +518,6 @@ def indexing_in_progress(self, config: IndexConfig) -> bool: self._require_index_directory(config.index_directory) return self.repository.mutation_in_progress() - def open_store(self, config: IndexConfig) -> SnapshotScopedIndexStore: - repository = self.repository - if config.snapshot_id is None: - raise IndexSchemaError("A snapshot ID is required for index reads.") - if config.snapshot_sha256 is None: - raise IndexSchemaError( - "A snapshot checksum is required for index reads." - ) - snapshot = repository.read_snapshot( - config.snapshot_id, - expected_sha256=config.snapshot_sha256, - ) - generation_ids = tuple( - reference.generation_id - for reference in snapshot.generations.values() - ) - storage = self._open_committed_storage(config) - try: - self._validate_snapshot_storage(storage, snapshot, config) - return SnapshotScopedIndexStore( - storage, - generation_ids=generation_ids, - ) - except BaseException: - storage.close() - raise - def remove(self, config: IndexConfig, media_id: str) -> bool: self._require_index_directory(config.index_directory) repository = self.repository diff --git a/src/vidxp/infrastructure/local_snapshots.py b/src/vidxp/infrastructure/local_snapshots.py index c940b8b..2304724 100644 --- a/src/vidxp/infrastructure/local_snapshots.py +++ b/src/vidxp/infrastructure/local_snapshots.py @@ -412,6 +412,43 @@ def active_config( raise IndexNotReadyError( "The active index snapshot contains no media." ) + return ( + self._config_for_snapshot( + snapshot, + snapshot_sha256=pointer.snapshot_sha256, + device=device, + ), + snapshot, + ) + + def config_for_snapshot( + self, + snapshot_id: str, + *, + snapshot_sha256: str, + device: str, + ) -> IndexConfig: + snapshot = self.read_snapshot( + snapshot_id, + expected_sha256=snapshot_sha256, + ) + if not snapshot.generations: + raise IndexNotReadyError( + "The requested index snapshot contains no media." + ) + return self._config_for_snapshot( + snapshot, + snapshot_sha256=snapshot_sha256, + device=device, + ) + + def _config_for_snapshot( + self, + snapshot: IndexSnapshot, + *, + snapshot_sha256: str, + device: str, + ) -> IndexConfig: stored = dict(snapshot.configuration) stored["enabled_modalities"] = tuple(stored["enabled_modalities"]) stored["collection_names"] = dict(stored["collection_names"]) @@ -419,7 +456,7 @@ def active_config( { "storage_directory": str(self.store), "snapshot_id": snapshot.snapshot_id, - "snapshot_sha256": pointer.snapshot_sha256, + "snapshot_sha256": snapshot_sha256, "device": device, } ) @@ -427,13 +464,13 @@ def active_config( config = IndexConfig(**stored) except (TypeError, ValueError) as exc: raise IndexSchemaError( - "The active snapshot configuration is invalid." + "The snapshot configuration is invalid." ) from exc if config.fingerprint() != snapshot.config_fingerprint: raise IndexSchemaError( - "The active snapshot configuration fingerprint is invalid." + "The snapshot configuration fingerprint is invalid." ) - return config, snapshot + return config def status(self) -> dict[str, Any] | None: resolved = self._read_active() diff --git a/src/vidxp/job_service.py b/src/vidxp/job_service.py index 566b2c3..e81dac4 100644 --- a/src/vidxp/job_service.py +++ b/src/vidxp/job_service.py @@ -2,7 +2,7 @@ from functools import wraps from time import sleep -from typing import Any, Callable +from typing import Any, Callable, Protocol from vidxp.application_models import ( ActorOverlayJobRequest, @@ -23,6 +23,8 @@ PrepareModelsCommand, PrepareModelsJobRequest, ResourceNotFoundError, + SearchCommand, + SearchJobRequest, SnippetJobRequest, ) from vidxp.ports import ( @@ -33,6 +35,17 @@ from vidxp.settings import VidXPSettings +class ReadJobPlanner(Protocol): + """Resolve immutable index identities before durable read jobs enqueue.""" + + def plan_search(self, command: SearchCommand) -> SearchJobRequest: ... + + def plan_actor_overlay( + self, + command: CreateActorOverlayCommand, + ) -> ActorOverlayJobRequest: ... + + def job_boundary(handler: Callable) -> Callable: """Translate job-backend failures once for every adapter.""" @@ -69,9 +82,20 @@ def __init__( *, settings: VidXPSettings, backend: JobBackend, + read_planner: ReadJobPlanner | None = None, ) -> None: self.settings = settings self.backend = backend + self.read_planner = read_planner + + def _read_job_planner(self) -> ReadJobPlanner: + if self.read_planner is None: + raise ApplicationError( + "read_job_planner_unavailable", + ErrorCategory.unavailable, + "Durable index reads are not configured.", + ) + return self.read_planner @job_boundary def start(self) -> None: @@ -90,6 +114,19 @@ def submit_index( job_id=job_id, ) + @job_boundary + def submit_search( + self, + command: SearchCommand, + *, + job_id: str | None = None, + ) -> Job: + return self.backend.submit( + self._read_job_planner().plan_search(command), + queue=self._model_queue(), + job_id=job_id, + ) + @job_boundary def submit_snippet( self, @@ -111,7 +148,7 @@ def submit_actor_overlay( job_id: str | None = None, ) -> Job: return self.backend.submit( - ActorOverlayJobRequest(command=command), + self._read_job_planner().plan_actor_overlay(command), queue=JobQueue.cpu, job_id=job_id, ) diff --git a/src/vidxp/media_service.py b/src/vidxp/media_service.py index 12148d3..97dd12a 100644 --- a/src/vidxp/media_service.py +++ b/src/vidxp/media_service.py @@ -1,9 +1,8 @@ from __future__ import annotations +import json from pathlib import Path -import base64 import hashlib -import json from uuid import uuid4 from vidxp.application_models import ( @@ -20,6 +19,11 @@ MediaUnavailableError, utc_now, ) +from vidxp.core.cursors import ( + CursorError, + decode_offset_cursor, + encode_offset_cursor, +) from vidxp.ports import ( LocalFileResource, MediaCatalogPort, @@ -231,7 +235,10 @@ def list(self, command: ListMediaCommand) -> MediaPage: scope = hashlib.sha256( str(self.settings.repository_root.resolve()).encode() ).hexdigest() - offset = self._decode_cursor(command.cursor, scope) + try: + offset = decode_offset_cursor(command.cursor, scope=scope) + except CursorError as exc: + raise ValueError("The media cursor is invalid.") from exc total = self.catalog.count_media() if offset > total: raise ValueError("The media cursor is outside the result set.") @@ -244,7 +251,7 @@ def list(self, command: ListMediaCommand) -> MediaPage: ) next_offset = offset + len(items) cursor = ( - self._encode_cursor(next_offset, scope) + encode_offset_cursor(next_offset, scope=scope) if next_offset < total else None ) @@ -254,42 +261,6 @@ def list(self, command: ListMediaCommand) -> MediaPage: next_cursor=cursor, ) - @staticmethod - def _encode_cursor(offset: int, scope: str) -> str: - payload = json.dumps( - {"version": 1, "scope": scope, "offset": offset}, - separators=(",", ":"), - sort_keys=True, - ).encode() - return base64.urlsafe_b64encode(payload).decode() - - @staticmethod - def _decode_cursor(cursor: str | None, scope: str) -> int: - if cursor is None: - return 0 - try: - payload = json.loads( - base64.urlsafe_b64decode(cursor.encode()).decode() - ) - if ( - not isinstance(payload, dict) - or payload.get("version") != 1 - or payload.get("scope") != scope - ): - raise ValueError - offset = int(payload["offset"]) - except ( - KeyError, - TypeError, - UnicodeDecodeError, - ValueError, - json.JSONDecodeError, - ) as exc: - raise ValueError("The media cursor is invalid.") from exc - if offset < 0: - raise ValueError("The media cursor is invalid.") - return offset - def require_record(self, media_id: str) -> MediaRecord: record = self.catalog.get_media(media_id) if record is None or record.state != MediaState.ready: diff --git a/src/vidxp/ports.py b/src/vidxp/ports.py index e03c02b..3a48229 100644 --- a/src/vidxp/ports.py +++ b/src/vidxp/ports.py @@ -323,6 +323,15 @@ def active_config( device: str, ) -> tuple[IndexConfig, dict[str, Any]]: ... + def config_for_snapshot( + self, + index_directory: Path, + *, + snapshot_id: str, + snapshot_sha256: str, + device: str, + ) -> IndexConfig: ... + def create( self, path: Path, diff --git a/src/vidxp/read_job_planner.py b/src/vidxp/read_job_planner.py new file mode 100644 index 0000000..02c3f16 --- /dev/null +++ b/src/vidxp/read_job_planner.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from vidxp.application_boundary import application_boundary +from vidxp.application_models import ( + ActorOverlayJobRequest, + CreateActorOverlayCommand, + IndexSnapshotReference, + SearchCommand, + SearchJobRequest, +) +from vidxp.capabilities.contracts import CapabilityRequestError +from vidxp.infrastructure.local_index import LocalIndexReader +from vidxp.repository_layout import RepositoryLayout + + +class LocalReadJobPlanner: + """Bind durable reads to one immutable snapshot without loading models.""" + + def __init__( + self, + *, + layout: RepositoryLayout, + index: LocalIndexReader | None = None, + ) -> None: + self.layout = layout + self.index = index or LocalIndexReader(layout) + + def _active(self): + config, _ = self.index.active_config( + self.layout.indexes, + device="cpu", + ) + if config.snapshot_id is None or config.snapshot_sha256 is None: + raise RuntimeError( + "The active index did not provide an immutable reference." + ) + return config, IndexSnapshotReference( + snapshot_id=config.snapshot_id, + snapshot_sha256=config.snapshot_sha256, + ) + + @staticmethod + def _require_capability(capability: str, config) -> None: + if capability not in config.enabled_modalities: + raise CapabilityRequestError( + f"The {capability} capability is not present in this index." + ) + + @application_boundary + def plan_search(self, command: SearchCommand) -> SearchJobRequest: + config, reference = self._active() + self._require_capability(command.modality, config) + return SearchJobRequest(command=command, snapshot=reference) + + @application_boundary + def plan_actor_overlay( + self, + command: CreateActorOverlayCommand, + ) -> ActorOverlayJobRequest: + config, reference = self._active() + self._require_capability("actor", config) + return ActorOverlayJobRequest( + command=command, + snapshot=reference, + ) diff --git a/src/vidxp/workflow_contracts.py b/src/vidxp/workflow_contracts.py index e8d60d0..ef656a4 100644 --- a/src/vidxp/workflow_contracts.py +++ b/src/vidxp/workflow_contracts.py @@ -11,6 +11,7 @@ WORKFLOW_NAMES: dict[JobKind, str] = { JobKind.index: "vidxp.index.v1", + JobKind.search: "vidxp.search.v1", JobKind.snippet: "vidxp.snippet.v1", JobKind.actor_overlay: "vidxp.actor_overlay.v1", JobKind.prepare_models: "vidxp.prepare_models.v1", diff --git a/src/vidxp/workflow_runtime.py b/src/vidxp/workflow_runtime.py index 5130dd3..cc20246 100644 --- a/src/vidxp/workflow_runtime.py +++ b/src/vidxp/workflow_runtime.py @@ -61,8 +61,11 @@ def workflow_database_url(settings: VidXPSettings) -> str: return f"sqlite:///{database.as_posix()}" +@lru_cache(maxsize=1) def workflow_application_version() -> str: - return __version__ + """Identify the exact workflow implementation, not only the release.""" + + return f"{__version__}+{implementation_digest()[:16]}" @lru_cache(maxsize=16) diff --git a/tests/test_actor_results.py b/tests/test_actor_results.py index 2b6ec60..71d9570 100644 --- a/tests/test_actor_results.py +++ b/tests/test_actor_results.py @@ -3,11 +3,13 @@ from vidxp.capabilities.actor.results import ( ActorClusterNotFoundError, + actor_cluster, actor_clusters, actor_detections, ) from vidxp.capabilities.actor.schemas import ActorDetectionsInput from vidxp.core.contracts import IndexConfig +from vidxp.core.cursors import encode_cursor from vidxp.capabilities.contracts import CapabilityRequestError @@ -54,13 +56,13 @@ def test_actor_detection_metadata_is_converted_once(self): storage.records.assert_called_once_with( "actor", filters={"cluster_id": "3"}, - limit=1000, + limit=51, offset=0, ) def test_actor_clusters_summarize_detection_ranges(self): storage = Mock() - storage.records.return_value = [ + legacy_records = [ { "cluster_id": "cluster-1", "video_id": MEDIA_ID, @@ -80,6 +82,7 @@ def test_actor_clusters_summarize_detection_ranges(self): "timestamp": 9.0, }, ] + storage.records.side_effect = [[], legacy_records] page = actor_clusters( self.config, @@ -97,15 +100,76 @@ def test_actor_clusters_summarize_detection_ranges(self): self.assertEqual(clusters[0].first_timestamp, 1.5) self.assertEqual(clusters[0].last_timestamp, 4.5) self.assertEqual(clusters[1].media_id, OTHER_MEDIA_ID) - storage.records.assert_called_once_with( - "actor", - limit=1000, - offset=0, + self.assertEqual( + storage.records.call_args_list[0].kwargs, + { + "filters": {"record_kind": "cluster_summary"}, + "limit": 51, + "offset": 0, + }, + ) + self.assertEqual( + storage.records.call_args_list[1].kwargs, + {"limit": 1000, "offset": 0}, ) + def test_actor_cluster_reads_only_the_selected_cluster_once(self): + storage = Mock() + detection_records = [ + { + "cluster_id": "cluster-1", + "video_id": MEDIA_ID, + "generation_id": GENERATION_ID, + "timestamp": 4.5, + }, + { + "cluster_id": "cluster-1", + "video_id": MEDIA_ID, + "generation_id": GENERATION_ID, + "timestamp": 1.5, + }, + ] + storage.records.side_effect = [[], detection_records] + + summary = actor_cluster( + self.config, + "cluster-1", + storage=storage, + ) + + self.assertEqual(summary.cluster_id, "cluster-1") + self.assertEqual(summary.detection_count, 2) + self.assertEqual(summary.first_timestamp, 1.5) + self.assertEqual(summary.last_timestamp, 4.5) + self.assertEqual( + storage.records.call_args_list[0].kwargs, + { + "filters": { + "record_kind": "cluster_summary", + "summary_cluster_id": "cluster-1", + }, + "limit": 2, + }, + ) + self.assertEqual( + storage.records.call_args_list[1].kwargs, + {"filters": {"cluster_id": "cluster-1"}}, + ) + + def test_actor_cluster_uses_the_typed_not_found_error(self): + storage = Mock() + storage.records.return_value = [] + + with self.assertRaises(ActorClusterNotFoundError): + actor_cluster( + self.config, + "missing", + storage=storage, + ) + def test_actor_clusters_reject_cross_media_identity_collision(self): storage = Mock() - storage.records.return_value = [ + legacy_records = [ { "cluster_id": "legacy-cluster", "video_id": MEDIA_ID, @@ -119,6 +183,7 @@ def test_actor_clusters_reject_cross_media_identity_collision(self): "timestamp": 2.0, }, ] + storage.records.side_effect = [[], legacy_records] with self.assertRaisesRegex(RuntimeError, "multiple index identities"): actor_clusters( @@ -131,7 +196,7 @@ def test_actor_clusters_reject_cross_media_identity_collision(self): def test_actor_detection_cursor_round_trips_through_public_input(self): cluster_id = "cluster-" + ("a" * 64) storage = Mock() - storage.records.return_value = [ + records = [ { "detection_id": f"detection-{index}", "cluster_id": cluster_id, @@ -151,6 +216,9 @@ def test_actor_detection_cursor_round_trips_through_public_input(self): } for index in range(3) ] + storage.records.side_effect = lambda *args, **options: records[ + options["offset"] : options["offset"] + options["limit"] + ] first = actor_detections( self.config, @@ -180,18 +248,54 @@ def test_actor_detection_cursor_round_trips_through_public_input(self): ["detection-2"], ) self.assertIsNone(second.next_cursor) + self.assertEqual( + [call.kwargs["offset"] for call in storage.records.call_args_list], + [0, 2], + ) + self.assertEqual( + [call.kwargs["limit"] for call in storage.records.call_args_list], + [3, 3], + ) + + def test_actor_detection_cursor_rejects_noncanonical_payloads(self): + cluster_id = "cluster-1" + scope = ( + f"actor:detections:{self.config.fingerprint()}:{cluster_id}" + ) + storage = Mock() + + for position in ( + {"offset": True}, + {"offset": 0, "after": [0, "detection-1"]}, + ): + with self.subTest(position=position): + with self.assertRaises(CapabilityRequestError): + actor_detections( + self.config, + cluster_id, + storage=storage, + page_size=2, + cursor=encode_cursor(scope, position), + ) + storage.records.assert_not_called() def test_actor_cluster_pages_use_opaque_cursors(self): storage = Mock() - storage.records.return_value = [ + records = [ { - "cluster_id": f"cluster-{index}", + "record_kind": "cluster_summary", + "summary_cluster_id": f"cluster-{index}", "video_id": MEDIA_ID, "generation_id": GENERATION_ID, - "timestamp": float(index), + "detection_count": index + 1, + "first_timestamp": float(index), + "last_timestamp": float(index) + 0.5, } for index in range(3) ] + storage.records.side_effect = lambda *args, **options: records[ + options["offset"] : options["offset"] + options["limit"] + ] first = actor_clusters( self.config, storage=storage, @@ -205,7 +309,7 @@ def test_actor_cluster_pages_use_opaque_cursors(self): cursor=first.next_cursor, ) - self.assertEqual(first.total, 3) + self.assertIsNone(first.total) self.assertEqual(len(first.clusters), 2) self.assertIsNotNone(first.next_cursor) self.assertEqual( diff --git a/tests/test_api.py b/tests/test_api.py index 053ecae..69327ad 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -24,6 +24,7 @@ JobState, MediaAsset, Principal, + SearchCommand, ) from vidxp.composition import ( HttpApplicationContext, @@ -312,6 +313,35 @@ def test_job_submission_requires_an_idempotency_key(self): self.assertEqual(response.status_code, 422) context.jobs.submit_index.assert_not_called() + def test_search_submission_uses_the_durable_query_boundary(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.jobs.submit_search.return_value = queued_job().model_copy( + update={"kind": JobKind.search} + ) + with TestClient(create_app(context=context)) as client: + response = client.post( + "/api/v1/jobs/search", + headers={"Idempotency-Key": IDEMPOTENCY_KEY}, + json={ + "modality": "scene", + "query": "yellow taxi", + "top_k": 3, + }, + ) + + self.assertEqual(response.status_code, 202) + context.jobs.submit_search.assert_called_once() + command = context.jobs.submit_search.call_args.args[0] + self.assertEqual( + command, + SearchCommand( + modality="scene", + query="yellow taxi", + top_k=3, + ), + ) + def test_idempotency_payload_mismatch_returns_422(self): with TemporaryDirectory() as directory: context = self.context(Path(directory)) @@ -512,6 +542,25 @@ def test_openapi_is_curated_and_has_unique_operation_ids(self): self.assertIn("multipart/form-data", upload_schema["requestBody"]["content"]) self.assertNotIn('"format": "path"', str(upload_schema)) + def test_openapi_declares_bearer_security_without_securing_health(self): + with TemporaryDirectory() as directory: + app = create_app( + context=self.context( + Path(directory), + auth=HttpAuthMode.static, + ) + ) + schema = app.openapi() + + bearer = schema["components"]["securitySchemes"]["BearerAuth"] + self.assertEqual(bearer["type"], "http") + self.assertEqual(bearer["scheme"], "bearer") + self.assertEqual( + schema["paths"]["/api/v1/capabilities"]["get"]["security"], + [{"BearerAuth": []}], + ) + self.assertNotIn("security", schema["paths"]["/health"]["get"]) + def test_cors_is_scoped_to_the_rest_namespace(self): with TemporaryDirectory() as directory: context = self.context( @@ -577,7 +626,7 @@ def test_security_middleware_rejections_use_typed_envelopes(self): "cors_preflight_invalid", ) - def test_authenticated_profiles_do_not_publish_interactive_docs(self): + def test_authenticated_profiles_protect_schema_and_hide_interactive_docs(self): with TemporaryDirectory() as directory: context = self.context( Path(directory), @@ -589,6 +638,7 @@ def test_authenticated_profiles_do_not_publish_interactive_docs(self): "/docs", headers=self.auth(), ) + schema_without_token = client.get("/openapi.json") schema = client.get( "/openapi.json", headers=self.auth(), @@ -596,7 +646,9 @@ def test_authenticated_profiles_do_not_publish_interactive_docs(self): self.assertEqual(unauthenticated.status_code, 401) self.assertEqual(authenticated.status_code, 404) - self.assertEqual(schema.status_code, 404) + self.assertEqual(schema_without_token.status_code, 401) + self.assertEqual(schema.status_code, 200) + self.assertIn("/api/v1/capabilities", schema.json()["paths"]) def test_readiness_masks_component_probe_failures(self): with TemporaryDirectory() as directory: @@ -680,7 +732,7 @@ def test_http_composition_does_not_construct_model_runtime(self): finally: context.close() - def test_reserved_mcp_namespace_bypasses_api_auth_and_body_policy(self): + def test_reserved_mcp_namespace_uses_api_auth_and_body_policy(self): with TemporaryDirectory() as directory: context = self.context( Path(directory), @@ -688,21 +740,31 @@ def test_reserved_mcp_namespace_bypasses_api_auth_and_body_policy(self): json_limit=32, ) with TestClient(create_app(context=context)) as client: - endpoint = client.post("/mcp", content=b"x" * 64) + unauthorized = client.post("/mcp", content=b"x" * 64) + oversized = client.post( + "/mcp", + headers=self.auth(), + content=b"x" * 64, + ) metadata = client.get( "/.well-known/oauth-protected-resource/mcp" ) - self.assertEqual(endpoint.status_code, 404) - self.assertEqual(metadata.status_code, 404) - self.assertNotEqual( - endpoint.json()["error"]["code"], + self.assertEqual(unauthorized.status_code, 401) + self.assertEqual( + unauthorized.json()["error"]["code"], "authentication_required", ) - self.assertNotEqual( - endpoint.json()["error"]["code"], + self.assertEqual(oversized.status_code, 413) + self.assertEqual( + oversized.json()["error"]["code"], "request_body_too_large", ) + self.assertEqual(metadata.status_code, 401) + self.assertEqual( + metadata.json()["error"]["code"], + "authentication_required", + ) def test_typed_application_error_status_mapping(self): with TemporaryDirectory() as directory: diff --git a/tests/test_application.py b/tests/test_application.py index e9960d7..16e9576 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -10,11 +10,15 @@ from vidxp.application_models import ( ApplicationError, CapabilityDependencyCheck, + ComponentReadiness, + CreateActorOverlayCommand, CreateIndexCommand, DependencyKind, DependencyCheckCommand, + DependencyCheckResult, DependencyUnavailableError, IndexResult, + IndexSnapshotReference, PrepareModelsCommand, RemoveIndexCommand, SearchCommand, @@ -33,6 +37,14 @@ create_capability_registry, ) from vidxp.capabilities.schemas import SearchInput, SearchResult +from vidxp.capabilities.actor.schemas import ( + ActorClusterInput, + ActorClusterSummary, + ActorDetection, + ActorDetectionsInput, + ActorDetectionsOutput, +) +from vidxp.control_plane import ControlPlaneApplication from vidxp.composition import create_local_application from vidxp.repository_layout import RepositoryLayout from vidxp.repositories import RepositoryConfigError @@ -44,6 +56,7 @@ MEDIA_ID = "123456781234423481234567890abcde" GENERATION_ID = "223456781234423481234567890abcde" SNAPSHOT_ID = "323456781234423481234567890abcde" +SNAPSHOT_SHA256 = "a" * 64 class ApplicationTests(unittest.TestCase): @@ -70,8 +83,9 @@ def application( registry=registry or create_capability_registry(), runtime=runtime, index_backend=active_backend, - media_service=media_service, - artifact_service=artifact_service, + media=media_service, + artifacts=artifact_service, + index_status=lambda: active_backend.status(Path(root)), ), active_backend, ) @@ -147,6 +161,37 @@ def test_local_composition_does_not_reclassify_unexpected_value_errors(self): ): create_local_application() + def test_local_composition_opens_and_closes_services_lazily(self): + application = Mock() + jobs = Mock() + with ( + TemporaryDirectory() as directory, + patch( + "vidxp.composition.create_application", + return_value=application, + ) as create_application, + patch( + "vidxp.composition.create_job_service", + return_value=jobs, + ) as create_jobs, + ): + local = create_local_application(index_directory=directory) + self.assertEqual(local.repository.index_directory, Path(directory)) + create_application.assert_not_called() + create_jobs.assert_not_called() + + self.assertIs(local.application, application) + self.assertIs(local.application, application) + create_application.assert_called_once() + create_jobs.assert_not_called() + local.close() + jobs.close.assert_not_called() + + self.assertIs(local.jobs, jobs) + create_jobs.assert_called_once() + local.close() + jobs.close.assert_called_once() + def test_missing_index_has_shared_status_model(self): application, backend = self.application("missing") backend.status.return_value = None @@ -155,15 +200,193 @@ def test_missing_index_has_shared_status_model(self): self.assertEqual(status.state, "missing") self.assertEqual(status.schema_version, 1) + self.assertIsInstance(application, ControlPlaneApplication) + + def test_actor_cluster_is_a_typed_projection_over_execute(self): + application, _ = self.application("repository") + expected = ActorClusterSummary( + cluster_id="actor-1", + media_id=MEDIA_ID, + generation_id=GENERATION_ID, + detection_count=2, + first_timestamp=1, + last_timestamp=2, + ) + with patch.object( + application, + "execute", + return_value=expected, + ) as execute: + cluster = application.actor_cluster("actor-1") + + self.assertEqual(cluster, expected) + execute.assert_called_once_with( + "actor", + "cluster", + {"cluster_id": "actor-1"}, + ) + + def test_pinned_search_reopens_the_requested_snapshot(self): + contexts = [] + + def handler(context, request): + contexts.append(context) + return SearchResult( + query_id="indexed:query", + query=request.query, + modality="indexed", + ) + + manager = MagicMock() + manager.__enter__.return_value = Mock(spec=IndexStore) + application = self.indexed_application(handler, manager) + backend = application.index_backend + pinned = IndexConfig.local( + enabled_modalities=("indexed",), + collection_names={"indexed": "indexed"}, + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ) + backend.config_for_snapshot.return_value = pinned + + result = application.search( + SearchCommand(modality="indexed", query="query"), + snapshot=IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ), + ) + + self.assertEqual(result.query, "query") + backend.active_config.assert_not_called() + backend.config_for_snapshot.assert_called_once_with( + application.index_directory, + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + device="cpu", + ) + backend.open_store.assert_called_once_with(pinned) + self.assertIs(contexts[0].storage, manager.__enter__.return_value) + + def test_actor_render_reuses_one_pinned_store_and_context(self): + contexts = [] + calls = [] + + def cluster_handler(context, request): + contexts.append(context) + calls.append(("cluster", request.cluster_id)) + return ActorClusterSummary( + cluster_id=request.cluster_id, + media_id=MEDIA_ID, + generation_id=GENERATION_ID, + detection_count=1, + first_timestamp=1, + last_timestamp=1, + ) + + def detections_handler(context, request): + contexts.append(context) + calls.append(("detections", request.cursor)) + return ActorDetectionsOutput( + cluster_id=request.cluster_id, + detections=( + ActorDetection( + detection_id="detection-1", + cluster_id=request.cluster_id, + frame_index=1, + timestamp=1, + bbox=(0, 0, 10, 10), + dataset="local", + split="local", + run_id="default", + media_id=MEDIA_ID, + generation_id=GENERATION_ID, + modality="actor", + source_id="frame-1", + ), + ), + ) + + definition = CapabilityDefinition( + name="actor", + description="Actor provider.", + extra="actor", + collection_name="actor", + index_stage="actor", + execution_group="actor", + operations={ + "cluster": OperationDefinition( + input_model=ActorClusterInput, + output_model=ActorClusterSummary, + ), + "detections": OperationDefinition( + input_model=ActorDetectionsInput, + output_model=ActorDetectionsOutput, + ), + }, + ) + registry = CapabilityRegistry( + ( + CapabilityPlugin( + definition=definition, + executor_factory=lambda: CapabilityExecutor( + indexer=Mock(), + operations={ + "cluster": cluster_handler, + "detections": detections_handler, + }, + ), + ), + ) + ) + manager = MagicMock() + manager.__enter__.return_value = Mock(spec=IndexStore) + application, backend = self.application( + "repository", + registry=registry, + ) + pinned = IndexConfig.local( + enabled_modalities=("actor",), + collection_names={"actor": "actor"}, + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ) + backend.config_for_snapshot.return_value = pinned + backend.open_store.return_value = manager + application.artifacts.create_actor_overlay.return_value = Mock() + + application.render_actor( + CreateActorOverlayCommand(cluster_id="actor-1"), + snapshot=IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ), + media_id=MEDIA_ID, + generation_id=GENERATION_ID, + ) + + backend.active_config.assert_not_called() + backend.open_store.assert_called_once_with(pinned) + self.assertEqual( + calls, + [("cluster", "actor-1"), ("detections", None)], + ) + self.assertEqual(len({id(context) for context in contexts}), 1) + application.artifacts.create_actor_overlay.assert_called_once() + artifact_call = ( + application.artifacts.create_actor_overlay.call_args.kwargs + ) + self.assertEqual(artifact_call["media_id"], MEDIA_ID) + self.assertEqual(artifact_call["generation_id"], GENERATION_ID) def test_create_index_builds_one_central_config(self): with TemporaryDirectory() as directory: application, backend = self.application(directory) - application.media_service.require_record.return_value = Mock( + application.media.require_record.return_value = Mock( original_filename="video.mp4", sha256="1" * 64, ) - application.media_service.content.return_value = Mock( + application.media.content.return_value = Mock( path=Path("managed.mp4") ) backend.create.return_value = { @@ -324,7 +547,7 @@ def test_validation_error_does_not_expose_input(self): def test_missing_media_error_does_not_expose_path(self): application, _ = self.application("unused") secret_path = Path("private/customer/video.mp4") - application.media_service.require_record.side_effect = ( + application.media.require_record.side_effect = ( MediaUnavailableError("secret") ) @@ -347,11 +570,11 @@ def test_downstream_missing_file_is_not_misclassified_as_media(self): path = Path(directory) / "video.mp4" path.write_bytes(b"video") application, backend = self.application(directory) - application.media_service.require_record.return_value = Mock( + application.media.require_record.return_value = Mock( original_filename="video.mp4", sha256="1" * 64, ) - application.media_service.content.return_value = Mock(path=path) + application.media.content.return_value = Mock(path=path) backend.create.side_effect = FileNotFoundError("ffmpeg") with self.assertRaises(DependencyUnavailableError) as raised: @@ -557,6 +780,37 @@ def test_dependency_check_returns_shared_result_model(self): self.assertFalse(result.ok) self.assertEqual(result.modalities, ("scene",)) + def test_runtime_readiness_includes_dependency_failures(self): + application, _ = self.application("unused") + application.control_plane_readiness = Mock( + return_value=( + ComponentReadiness( + name="catalog", + ready=True, + message="available", + ), + ) + ) + application._index_storage_readiness = Mock( + return_value=ComponentReadiness( + name="index_storage", + ready=True, + message="available", + ) + ) + application.check_dependencies = Mock( + return_value=DependencyCheckResult( + ok=False, + modalities=("scene",), + checks=(), + ) + ) + + readiness = application.runtime_readiness() + + self.assertFalse(readiness.ready) + self.assertFalse(readiness.dependencies.ok) + def test_prepare_uses_injected_runtime_and_executor(self): definition = CapabilityDefinition( name="prepare-only", diff --git a/tests/test_benchmark_cli.py b/tests/test_benchmark_cli.py index 01a0646..2d72e20 100644 --- a/tests/test_benchmark_cli.py +++ b/tests/test_benchmark_cli.py @@ -1,20 +1,50 @@ import json +import os +import subprocess +import sys import unittest from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import Mock, patch +from packaging.requirements import Requirement +import typer from typer.testing import CliRunner from vidxp import cli +from vidxp.benchmarks import cli as benchmark_cli from vidxp.composition import LocalApplicationContext from vidxp.repositories import RepositoryConfig, RepositoryRegistry class BenchmarkCliTests(unittest.TestCase): - def test_benchmark_app_is_not_loaded_without_its_extra(self): - with patch.object(cli, "requirements_available", return_value=False): - self.assertIsNone(cli._load_benchmark_app()) + def test_benchmark_group_is_available_without_srt_parser(self): + source = Path(__file__).resolve().parents[1] / "src" + code = """ +import importlib.abc +import sys + +class RejectSrt(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "srt" or fullname.startswith("srt."): + raise ModuleNotFoundError("blocked optional srt dependency") + return None + +sys.meta_path.insert(0, RejectSrt()) +from vidxp import cli +assert cli.benchmark_app is not None +""" + environment = dict(os.environ) + environment["PYTHONPATH"] = str(source) + completed = subprocess.run( + [sys.executable, "-c", code], + env=environment, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) def test_benchmark_uses_global_device_and_json_output(self): runner = CliRunner() @@ -77,6 +107,151 @@ def test_benchmark_uses_global_device_and_json_output(self): self.assertEqual(json.loads(response.stdout)["rank_at_1"], 0.5) self.assertEqual(run.call_args.kwargs["device"], "cuda") + def test_missing_adapter_dependencies_fail_with_exact_extra_hint(self): + registry = Mock() + registry.requirements_for.return_value = ( + Requirement("missing-runtime>=1"), + ) + failure = { + "name": "missing-runtime", + "requirement": "missing-runtime>=1", + "installed_version": None, + "ok": False, + "error": "distribution is not installed", + } + with ( + patch.object( + benchmark_cli, + "create_capability_registry", + return_value=registry, + ), + patch.object( + benchmark_cli, + "inspect_requirement", + return_value=failure, + ), + self.assertRaises(typer.BadParameter) as raised, + ): + benchmark_cli._require_benchmark_dependencies("scene") + + self.assertIn( + 'pip install "vidxp[scene]"', + str(raised.exception), + ) + + def test_hirest_dependency_hint_includes_benchmark_parser(self): + registry = Mock() + registry.requirements_for.return_value = () + failure = { + "name": "srt", + "requirement": "srt<4,>=3.5", + "installed_version": None, + "ok": False, + "error": "distribution is not installed", + } + with ( + patch.object( + benchmark_cli, + "create_capability_registry", + return_value=registry, + ), + patch.object( + benchmark_cli, + "inspect_requirement", + return_value=failure, + ), + self.assertRaises(typer.BadParameter) as raised, + ): + benchmark_cli._require_benchmark_dependencies( + "dialogue", + include_benchmark_extra=True, + ) + + self.assertIn( + 'pip install "vidxp[dialogue,benchmarks]"', + str(raised.exception), + ) + + def test_invalid_hirest_pair_json_is_reported_as_input_error(self): + with TemporaryDirectory() as directory: + pairs = Path(directory) / "pairs.json" + pairs.write_text("{", encoding="utf-8") + + with self.assertRaises(typer.BadParameter) as raised: + benchmark_cli._pair_file(pairs) + + self.assertIn("valid readable JSON", str(raised.exception)) + self.assertEqual(raised.exception.param_hint, "--pairs") + + def test_hirest_rejects_closed_temporal_fraction_before_dependencies(self): + runner = CliRunner() + service = Mock() + service.index_directory = Path("chroma_data") + with TemporaryDirectory() as directory: + root = Path(directory) + config = root / "repositories.json" + required_files = { + name: root / name + for name in ( + "ground-truth.json", + "categories.json", + "evaluator.py", + "asr.zip", + ) + } + for path in required_files.values(): + path.write_text("", encoding="utf-8") + asr_directory = root / "asr" + asr_directory.mkdir() + with ( + patch.object( + cli, + "create_local_application", + return_value=LocalApplicationContext( + application=service, + jobs=Mock(), + repositories=RepositoryRegistry(config), + repository=RepositoryConfig( + "default", + Path("chroma_data"), + device="cpu", + configured=False, + ), + ), + ), + patch.object( + benchmark_cli, + "_require_benchmark_dependencies", + ) as dependencies, + ): + response = runner.invoke( + cli.app, + [ + "--config", + str(config), + "benchmark", + "hirest", + "--ground-truth", + str(required_files["ground-truth.json"]), + "--categories", + str(required_files["categories.json"]), + "--evaluator", + str(required_files["evaluator.py"]), + "--asr-archive", + str(required_files["asr.zip"]), + "--asr-directory", + str(asr_directory), + "--run-id", + "run-1", + "--temporal-window-fraction", + "1", + ], + ) + + self.assertEqual(response.exit_code, 2, response.output) + self.assertIn("less than one", response.output) + dependencies.assert_not_called() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index cb7cd80..474ef75 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -22,6 +22,7 @@ CapabilityRegistry, create_capability_registry, ) +from vidxp.capability_service import CapabilityService from vidxp.capabilities.scene.config import SceneConfig from vidxp.core.contracts import IndexConfig from vidxp.core.runner import _index_groups @@ -66,6 +67,18 @@ def test_registered_operations_are_schema_only_metadata(self): self.assertTrue(issubclass(operation.input_model, BaseModel)) self.assertTrue(issubclass(operation.output_model, BaseModel)) + def test_internal_actor_cluster_lookup_is_not_advertised(self): + operations = { + operation.name + for operation in CapabilityService(self.registry) + .get("actor") + .operations + } + + self.assertNotIn("cluster", operations) + self.assertIn("clusters", operations) + self.assertIn("detections", operations) + def test_executor_handlers_match_declared_operations(self): for name, definition in self.registry.definitions.items(): self.assertEqual( diff --git a/tests/test_cli.py b/tests/test_cli.py index 551f255..009d560 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -25,9 +25,11 @@ PrepareModelsJobResult, RemoveIndexCommand, SearchCommand, + SearchJobResult, ) from vidxp.core.media import MediaState, MediaStream from vidxp.capabilities.registry import create_capability_registry +from vidxp.capability_service import CapabilityService from vidxp.capabilities.schemas import SearchResult from vidxp.repositories import RepositoryConfig, RepositoryRegistry @@ -43,6 +45,9 @@ def setUp(self): self.runner = CliRunner() self.service = Mock() self.service.registry = create_capability_registry() + self.service.list_capabilities.return_value = CapabilityService( + self.service.registry + ).list() self.service.index_directory = Path("repo/indexes") self.service.layout.root = Path("repo") self.service.runtime.backends.requested = "cpu" @@ -84,19 +89,42 @@ def test_grouped_commands_are_exposed(self): ): self.assertIn(command, result.output) + def test_snippet_rejects_an_inverted_time_range_before_submission(self): + result = self.invoke( + ["artifacts", "snippet", MEDIA_ID, "3", "2"] + ) + + self.assertEqual(result.exit_code, 2, result.output) + self.assertIn("snippet end must be greater than its", result.output) + self.assertIn("start.", result.output) + self.jobs.submit_snippet.assert_not_called() + def test_search_constructs_shared_command(self): - self.service.search.return_value = SearchResult( + search_result = SearchResult( query_id="scene:1", query="yellow taxi", modality="scene", ) + self.jobs.submit_search.return_value = Job( + job_id=JOB_ID, + kind=JobKind.search, + state=JobState.queued, + queue=JobQueue.cpu, + ) + self.jobs.wait.return_value = Job( + job_id=JOB_ID, + kind=JobKind.search, + state=JobState.succeeded, + queue=JobQueue.cpu, + result=SearchJobResult(result=search_result), + ) result = self.invoke( ["search", "scene", "yellow taxi", "--top-k", "7", "--json"] ) self.assertEqual(result.exit_code, 0, result.output) - self.service.search.assert_called_once_with( + self.jobs.submit_search.assert_called_once_with( SearchCommand( modality="scene", query="yellow taxi", @@ -256,7 +284,7 @@ def test_invalid_capability_is_a_cli_parameter_error(self): ["doctor", "--modalities", "unknown", "--json"] ) self.assertNotEqual(result.exit_code, 0) - self.assertIn("Unknown capability", result.output) + self.assertIn("Unknown or unsupported capabilities", result.output) def test_repository_without_device_preserves_runtime_environment(self): repository = RepositoryConfig( diff --git a/tests/test_cursors.py b/tests/test_cursors.py new file mode 100644 index 0000000..5b86e81 --- /dev/null +++ b/tests/test_cursors.py @@ -0,0 +1,56 @@ +import base64 +import unittest + +from vidxp.core.cursors import ( + MAX_CURSOR_OFFSET, + CursorError, + decode_cursor, + decode_offset_cursor, + encode_cursor, + encode_offset_cursor, +) + + +class CursorTests(unittest.TestCase): + def test_cursor_rejects_noncanonical_or_malformed_base64(self): + cursor = encode_cursor("test", {"offset": 1}) + + for invalid in ( + cursor.rstrip("="), + f"{cursor}=", + f" {cursor}", + cursor.replace("-", "+").replace("_", "/"), + "not-base64!", + ): + if invalid == cursor: + continue + with self.subTest(cursor=invalid): + with self.assertRaises(CursorError): + decode_cursor(invalid, "test") + + decoded = base64.urlsafe_b64decode(cursor) + noncanonical = base64.b64encode(decoded).decode() + if noncanonical != cursor: + with self.assertRaises(CursorError): + decode_cursor(noncanonical, "test") + + def test_offset_cursor_is_bounded_to_signed_database_bigint(self): + cursor = encode_offset_cursor(MAX_CURSOR_OFFSET, scope="test") + self.assertEqual( + decode_offset_cursor(cursor, scope="test"), + MAX_CURSOR_OFFSET, + ) + + with self.assertRaises(CursorError): + encode_offset_cursor(MAX_CURSOR_OFFSET + 1, scope="test") + + oversized = encode_cursor( + "test", + {"offset": MAX_CURSOR_OFFSET + 1}, + ) + with self.assertRaises(CursorError): + decode_offset_cursor(oversized, scope="test") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_dbos_jobs.py b/tests/test_dbos_jobs.py index 61837b5..20fc1cf 100644 --- a/tests/test_dbos_jobs.py +++ b/tests/test_dbos_jobs.py @@ -11,14 +11,20 @@ ApplicationError, CreateIndexCommand, ErrorCategory, + IndexSnapshotReference, IndexResult, JobKind, JobQueue, JobState, ListJobsCommand, + SearchCommand, + SearchJobRequest, + SearchResult, ) +from vidxp.core.cursors import MAX_CURSOR_OFFSET, encode_cursor from vidxp.infrastructure.dbos_jobs import DBOSJobBackend from vidxp.job_service import JobService +from vidxp.ports import InvalidJobBackendRequestError from vidxp.settings import VidXPSettings from vidxp.workflow_contracts import QUEUE_NAMES @@ -27,6 +33,7 @@ GENERATION_ID = "223456781234423481234567890abcde" SNAPSHOT_ID = "323456781234423481234567890abcde" IDEMPOTENCY_KEY = "423456781234423481234567890abcde" +SNAPSHOT_SHA256 = "a" * 64 class DBOSJobIntegrationTests(unittest.TestCase): @@ -58,6 +65,11 @@ def setUp(self): active_media_count=1, record_counts={"scene": 1}, ) + self.application.search.return_value = SearchResult( + query_id="scene:taxi", + query="taxi", + modality="scene", + ) self.worker.application = self.application DBOS.reset_system_database() DBOS.listen_queues([QUEUE_NAMES[JobQueue.cpu]]) @@ -77,6 +89,17 @@ def setUp(self): workflow_poll_interval_seconds=0.01, ), backend=self.backend, + read_planner=Mock( + plan_search=Mock( + side_effect=lambda command: SearchJobRequest( + command=command, + snapshot=IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ), + ) + ) + ), ) def tearDown(self): @@ -131,6 +154,37 @@ def test_health_requires_owned_executor_health(self): health_check.assert_called_once_with() + def test_model_search_runs_in_worker_and_returns_typed_result(self): + submitted = self.jobs.submit_search( + SearchCommand(modality="scene", query="taxi", top_k=1) + ) + completed = self.jobs.wait(submitted.job_id) + + self.assertEqual(completed.state, JobState.succeeded) + self.assertEqual(completed.kind, JobKind.search) + self.assertEqual(completed.result.result.query, "taxi") + self.application.search.assert_called_once_with( + SearchCommand(modality="scene", query="taxi", top_k=1), + snapshot=IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ), + ) + + def test_huge_cursor_offset_is_rejected_before_database_access(self): + cursor = encode_cursor( + "vidxp:jobs", + {"offset": MAX_CURSOR_OFFSET + 1}, + ) + self.backend.client = Mock(wraps=self.backend.client) + + with self.assertRaises(InvalidJobBackendRequestError): + self.backend.list( + ListJobsCommand(page_size=1, cursor=cursor) + ) + + self.backend.client.list_workflows.assert_not_called() + def test_submission_idempotency_replays_only_the_same_request(self): command = CreateIndexCommand( media_id=MEDIA_ID, @@ -157,6 +211,34 @@ def test_submission_idempotency_replays_only_the_same_request(self): ErrorCategory.validation, ) + def test_descending_pages_are_frozen_against_new_submissions(self): + original_ids = ( + "523456781234423481234567890abcde", + "623456781234423481234567890abcde", + "723456781234423481234567890abcde", + ) + command = CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + ) + for job_id in original_ids: + self.jobs.submit_index(command, job_id=job_id) + sleep(0.01) + + first = self.jobs.list(ListJobsCommand(page_size=2)) + self.assertIsNotNone(first.next_cursor) + + later_id = "823456781234423481234567890abcde" + sleep(0.01) + self.jobs.submit_index(command, job_id=later_id) + second = self.jobs.list( + ListJobsCommand(page_size=2, cursor=first.next_cursor) + ) + + paged_ids = {job.job_id for job in (*first.items, *second.items)} + self.assertEqual(paged_ids, set(original_ids)) + self.assertNotIn(later_id, paged_ids) + def test_cancellation_reaches_the_running_application_operation(self): started = Event() stopped = Event() diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 8210f57..9752ae1 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -1,12 +1,22 @@ import unittest +from types import SimpleNamespace from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import Mock, patch from vidxp import frontend -from vidxp.application_models import DependencyCheckResult, IndexStatus +from vidxp.application_models import ( + ApplicationError, + DependencyCheckResult, + CreateActorOverlayCommand, + ErrorCategory, + IndexStatus, + JobState, + SearchHit, + SearchResult, +) from vidxp.capabilities.registry import create_capability_registry -from vidxp.capabilities.schemas import SearchHit, SearchResult +from vidxp.capability_service import CapabilityService from vidxp.settings import LocalExecutionSettings, VidXPSettings @@ -25,6 +35,62 @@ def getvalue(self): class FrontendTests(unittest.TestCase): + def test_durable_job_ids_restore_from_query_parameters(self): + session_state = {} + query_params = { + frontend.INDEX_JOB_QUERY_PARAM: "index-job", + frontend.SEARCH_JOB_QUERY_PARAM: "search-job", + frontend.SEARCH_TYPE_QUERY_PARAM: "scene", + } + with ( + patch.object(frontend.st, "session_state", session_state), + patch.object(frontend.st, "query_params", query_params), + ): + frontend._restore_durable_jobs() + + self.assertEqual( + session_state[frontend.INDEX_JOB_ID_KEY], + "index-job", + ) + self.assertEqual( + session_state[frontend.SEARCH_RESULT_KEY], + { + "type": "scene", + "query": "", + "job_id": "search-job", + }, + ) + + def test_query_modalities_derive_from_capability_operations(self): + service = Mock() + service.list_capabilities.return_value = ( + SimpleNamespace( + name="visual-plugin", + operations=(SimpleNamespace(name="search"),), + ), + SimpleNamespace( + name="people-plugin", + operations=( + SimpleNamespace(name="clusters"), + SimpleNamespace(name="detections"), + ), + ), + SimpleNamespace(name="index-only", operations=()), + ) + with patch.object( + frontend, + "_configured_service", + return_value=service, + ): + available = frontend._available_query_modalities( + ("visual-plugin", "people-plugin", "index-only"), + ) + + self.assertEqual( + available, + ("visual-plugin", "people-plugin"), + ) + def tearDown(self): frontend._configured_service.cache_clear() frontend._configured_jobs.cache_clear() @@ -35,6 +101,9 @@ def service(self, root: Path) -> Mock: service.layout.artifacts = root / "artifacts" service.layout.root = root service.registry = create_capability_registry() + service.list_capabilities.return_value = CapabilityService( + service.registry + ).list() return service def test_service_is_composed_lazily_and_cached(self): @@ -112,8 +181,14 @@ def check(command): available = frontend._available_index_modalities() self.assertEqual(available, ("dialogue", "scene")) + self.assertTrue( + all( + not call.args[0].include_runtime_checks + for call in service.check_dependencies.call_args_list + ) + ) - def test_search_uses_shared_command_and_returns_timestamp(self): + def test_search_queues_shared_command_without_loading_models(self): with TemporaryDirectory() as directory: root = Path(directory) service = self.service(root) @@ -123,36 +198,305 @@ def test_search_uses_shared_command_and_returns_timestamp(self): stage="complete", message="ready", ) - service.search.return_value = SearchResult( - query_id="scene:1", - query="taxi", - modality="scene", - hits=( - SearchHit( - rank=1, - media_id=MEDIA_ID, - video_id=MEDIA_ID, - generation_id=GENERATION_ID, - start=12.5, - end=13.0, - score=-0.1, - raw_distance=0.1, - modality="scene", - source_id="scene:1", - ), + jobs = Mock() + jobs.submit_search.return_value = Mock(job_id="job-1") + with ( + patch.object( + frontend, + "_configured_service", + return_value=service, + ), + patch.object( + frontend, + "_configured_jobs", + return_value=jobs, ), - ) - with patch.object( - frontend, - "_configured_service", - return_value=service, ): result = frontend._run_search("scene", "taxi") - self.assertEqual(result["timestamp"], 12.5) - command = service.search.call_args.args[0] + self.assertEqual(result["job_id"], "job-1") + command = jobs.submit_search.call_args.args[0] self.assertEqual(command.modality, "scene") self.assertEqual(command.query, "taxi") + service.search.assert_not_called() + + def test_actor_search_queues_without_blocking_ui(self): + with TemporaryDirectory() as directory: + service = self.service(Path(directory)) + service.index_status.return_value = IndexStatus( + schema_version=1, + state="ready", + stage="complete", + message="ready", + ) + jobs = Mock() + jobs.submit_actor_overlay.return_value = Mock(job_id="job-1") + with ( + patch.object( + frontend, + "_configured_service", + return_value=service, + ), + patch.object( + frontend, + "_configured_jobs", + return_value=jobs, + ), + ): + result = frontend._run_search("actor", "actor-1") + + self.assertEqual( + result, + { + "type": "actor", + "query": "actor-1", + "job_id": "job-1", + }, + ) + jobs.submit_actor_overlay.assert_called_once_with( + CreateActorOverlayCommand(cluster_id="actor-1") + ) + jobs.wait.assert_not_called() + + @staticmethod + def _render_job(job, result=None): + jobs = Mock() + jobs.get.return_value = job + session_state = {} + query_params = { + frontend.SEARCH_JOB_QUERY_PARAM: "job-1", + frontend.SEARCH_TYPE_QUERY_PARAM: "actor", + } + + def fragment(*, run_every): + assert run_every == "1s" + return lambda function: function + + with ( + patch.object( + frontend, + "_configured_jobs", + return_value=jobs, + ), + patch.object(frontend.st, "fragment", side_effect=fragment), + patch.object(frontend.st, "session_state", session_state), + patch.object(frontend.st, "query_params", query_params), + patch.object(frontend.st, "rerun") as rerun, + ): + frontend._render_search_result( + result or { + "type": "actor", + "query": "actor-1", + "job_id": "job-1", + } + ) + return session_state, rerun + + def test_terminal_index_failure_survives_browser_refresh(self): + session_state = { + frontend.INDEX_JOB_ID_KEY: "job-1", + } + query_params = { + frontend.INDEX_JOB_QUERY_PARAM: "job-1", + } + error = Mock(message="Indexing failed.") + job = Mock(state=JobState.failed, error=error) + + with ( + patch.object(frontend.st, "session_state", session_state), + patch.object(frontend.st, "query_params", query_params), + ): + frontend._finish_index_job(job) + + self.assertNotIn(frontend.INDEX_JOB_ID_KEY, session_state) + self.assertNotIn(frontend.INDEX_JOB_QUERY_PARAM, query_params) + self.assertEqual( + session_state[frontend.INDEX_ERROR_KEY], + "Indexing failed.", + ) + + def test_actor_poll_persists_success_before_stopping(self): + job = Mock(state=JobState.succeeded, error=None) + job.result.result.artifact_id = "artifact-1" + + session_state, rerun = self._render_job(job) + + self.assertEqual( + session_state[frontend.SEARCH_RESULT_KEY], + { + "type": "actor", + "query": "actor-1", + "artifact_id": "artifact-1", + }, + ) + rerun.assert_called_once_with() + + def test_actor_poll_persists_unavailable_job_error(self): + session_state, rerun = self._render_job(None) + + self.assertEqual( + session_state[frontend.SEARCH_RESULT_KEY], + { + "type": "actor", + "query": "actor-1", + "error": "The actor overlay job is unavailable.", + }, + ) + rerun.assert_called_once_with() + + def test_search_poll_keeps_durable_job_on_retryable_backend_failure(self): + jobs = Mock() + jobs.get.side_effect = ApplicationError( + "job_backend_unavailable", + ErrorCategory.unavailable, + "The durable job backend is unavailable.", + retryable=True, + ) + result = { + "type": "scene", + "query": "taxi", + "job_id": "job-1", + } + session_state = {frontend.SEARCH_RESULT_KEY: result} + + def fragment(*, run_every): + self.assertEqual(run_every, "1s") + return lambda function: function + + with ( + patch.object( + frontend, + "_configured_jobs", + return_value=jobs, + ), + patch.object(frontend.st, "fragment", side_effect=fragment), + patch.object(frontend.st, "session_state", session_state), + patch.object(frontend.st, "warning") as warning, + patch.object(frontend.st, "rerun") as rerun, + ): + frontend._render_search_result(result) + + self.assertEqual(session_state[frontend.SEARCH_RESULT_KEY], result) + warning.assert_called_once_with( + "The scene search status is temporarily unavailable. Retrying." + ) + rerun.assert_not_called() + + def test_job_lookup_only_suppresses_not_found(self): + jobs = Mock() + jobs.get.side_effect = ApplicationError( + "resource_not_found", + ErrorCategory.not_found, + "The requested job was not found.", + ) + self.assertIsNone(frontend._get_job(jobs, "job-1")) + + outage = ApplicationError( + "job_backend_unavailable", + ErrorCategory.unavailable, + "The durable job backend is unavailable.", + retryable=True, + ) + jobs.get.side_effect = outage + with self.assertRaises(ApplicationError) as raised: + frontend._get_job(jobs, "job-1") + self.assertIs(raised.exception, outage) + + def test_actor_poll_persists_failed_job_error(self): + error = Mock(message="Overlay rendering failed.") + job = Mock(state=JobState.failed, error=error, result=None) + + session_state, rerun = self._render_job(job) + + self.assertEqual( + session_state[frontend.SEARCH_RESULT_KEY], + { + "type": "actor", + "query": "actor-1", + "error": "Overlay rendering failed.", + }, + ) + rerun.assert_called_once_with() + + def test_actor_poll_persists_cancelled_job_error(self): + job = Mock(state=JobState.cancelled, error=None, result=None) + + session_state, rerun = self._render_job(job) + + self.assertEqual( + session_state[frontend.SEARCH_RESULT_KEY], + { + "type": "actor", + "query": "actor-1", + "error": "The actor overlay was cancelled.", + }, + ) + rerun.assert_called_once_with() + + def test_search_poll_persists_the_best_typed_hit(self): + completed = SearchResult( + query_id="scene:taxi", + query="taxi", + modality="scene", + hits=( + SearchHit( + rank=1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, + start=12.5, + end=13.0, + score=-0.1, + raw_distance=0.1, + modality="scene", + source_id="scene:1", + ), + ), + ) + job = Mock(state=JobState.succeeded, error=None) + job.result.result = completed + + session_state, rerun = self._render_job( + job, + { + "type": "scene", + "query": "taxi", + "job_id": "job-1", + }, + ) + + persisted = session_state[frontend.SEARCH_RESULT_KEY] + self.assertEqual(persisted["media_id"], MEDIA_ID) + self.assertEqual(persisted["timestamp"], 12.5) + rerun.assert_called_once_with() + + def test_restored_search_uses_completed_query_when_no_hit_exists(self): + completed = SearchResult( + query_id="scene:taxi", + query="yellow taxi", + modality="scene", + ) + job = Mock(state=JobState.succeeded, error=None) + job.result.result = completed + + session_state, rerun = self._render_job( + job, + { + "type": "scene", + "query": "", + "job_id": "job-1", + }, + ) + + self.assertEqual( + session_state[frontend.SEARCH_RESULT_KEY], + { + "type": "scene", + "query": "yellow taxi", + "error": "No scene match was found.", + }, + ) + rerun.assert_called_once_with() if __name__ == "__main__": diff --git a/tests/test_indexing.py b/tests/test_indexing.py index 5079366..2a94aa1 100644 --- a/tests/test_indexing.py +++ b/tests/test_indexing.py @@ -6,6 +6,7 @@ from vidxp.capabilities.actor.indexing import ( _actor_cluster_id, + _actor_cluster_records, _actor_records, ) from vidxp.capabilities.contracts import ( @@ -331,6 +332,30 @@ def config(video_id, generation_id): self.assertEqual(len(identities), 3) + def test_actor_cluster_summaries_are_materialized_for_bounded_paging(self): + config = IndexConfig( + run_id="actors", + video_id="video-1", + generation_id="generation-1", + enabled_modalities=("actor",), + ) + + records = _actor_cluster_records( + {"cluster-1": 3}, + {"cluster-1": (1.25, 4.5)}, + config, + ) + + self.assertEqual(len(records), 1) + self.assertEqual(records[0].metadata["record_kind"], "cluster_summary") + self.assertEqual( + records[0].metadata["summary_cluster_id"], + "cluster-1", + ) + self.assertEqual(records[0].metadata["detection_count"], 3) + self.assertEqual(records[0].metadata["first_timestamp"], 1.25) + self.assertEqual(records[0].metadata["last_timestamp"], 4.5) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_job_contracts.py b/tests/test_job_contracts.py index 0482bca..59fbd4c 100644 --- a/tests/test_job_contracts.py +++ b/tests/test_job_contracts.py @@ -7,7 +7,9 @@ from pydantic import ValidationError from vidxp.application_models import ( + ActorOverlayJobRequest, ApplicationError, + CreateActorOverlayCommand, CreateIndexCommand, Job, JobKind, @@ -15,7 +17,10 @@ JobQueue, JobState, InvalidRequestError, + IndexSnapshotReference, ListJobsCommand, + SearchCommand, + SearchJobRequest, ) from vidxp.job_service import JobService from vidxp.ports import InvalidJobBackendRequestError @@ -27,6 +32,8 @@ MEDIA_ID = "123456781234423481234567890abcde" JOB_ID = "223456781234423481234567890abcde" +SNAPSHOT_ID = "323456781234423481234567890abcde" +SNAPSHOT_SHA256 = "a" * 64 class JobContractTests(unittest.TestCase): @@ -91,6 +98,104 @@ def test_job_list_cursor_is_bounded(self): with self.assertRaises(ValidationError): ListJobsCommand(cursor="x" * 513) + def test_search_uses_the_same_model_worker_queue(self): + backend = Mock() + planner = Mock() + command = SearchCommand( + modality="scene", + query="taxi", + top_k=2, + ) + planner.plan_search.return_value = SearchJobRequest( + command=command, + snapshot=IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ), + ) + backend.submit.return_value = Job( + job_id=JOB_ID, + kind=JobKind.search, + state=JobState.queued, + queue=JobQueue.gpu, + ) + service = JobService( + settings=VidXPSettings( + repository_root=Path("repository"), + runtime_backend="cuda:0", + ), + backend=backend, + read_planner=planner, + ) + + service.submit_search(command) + + request = backend.submit.call_args.args[0] + self.assertEqual(request.kind, JobKind.search) + self.assertEqual(request.snapshot.snapshot_id, SNAPSHOT_ID) + planner.plan_search.assert_called_once_with(command) + self.assertEqual( + backend.submit.call_args.kwargs["queue"], + JobQueue.gpu, + ) + + def test_actor_job_retains_pinned_snapshot_identity(self): + backend = Mock() + planner = Mock() + command = CreateActorOverlayCommand(cluster_id="actor-1") + planner.plan_actor_overlay.return_value = ActorOverlayJobRequest( + command=command, + snapshot=IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ), + ) + backend.submit.return_value = Job( + job_id=JOB_ID, + kind=JobKind.actor_overlay, + state=JobState.queued, + queue=JobQueue.cpu, + ) + service = JobService( + settings=VidXPSettings( + repository_root=Path("repository"), + runtime_backend="cpu", + ), + backend=backend, + read_planner=planner, + ) + + service.submit_actor_overlay(command) + + request = backend.submit.call_args.args[0] + self.assertEqual(request.snapshot.snapshot_id, SNAPSHOT_ID) + planner.plan_actor_overlay.assert_called_once_with(command) + + def test_search_and_actor_identifiers_are_bounded(self): + command = SearchCommand( + modality=" scene ", + query=" a chef prepares pizza ", + ) + self.assertEqual(command.modality, "scene") + self.assertEqual(command.query, "a chef prepares pizza") + + for values in ( + {"modality": "scene/video", "query": "taxi"}, + {"modality": "scene", "query": "x" * 4097}, + {"modality": "scene", "query": " "}, + ): + with self.assertRaises(ValidationError): + SearchCommand(**values) + + with self.assertRaises(ValidationError): + CreateActorOverlayCommand(cluster_id="../../video") + encoded_cluster = CreateActorOverlayCommand( + cluster_id=( + "generation:run%20name:media:actor-cluster:1" + ) + ) + self.assertIn("%20", encoded_cluster.cluster_id) + def test_canonical_dbos_job_id_has_a_hex_operation_identity(self): execution = ExecutionContext( job_id="22345678-1234-4234-8123-4567890abcde" diff --git a/tests/test_local_snapshots.py b/tests/test_local_snapshots.py index 70fccaa..2a08ac1 100644 --- a/tests/test_local_snapshots.py +++ b/tests/test_local_snapshots.py @@ -363,6 +363,31 @@ def test_snapshot_configuration_moves_between_runtime_devices(self): snapshot.config_fingerprint, ) + def test_historical_snapshot_configuration_reopens_by_checksum(self): + config_a, reference_a = self.generation("a", input_sha="a" * 64) + snapshot_a = self.repository.publish_generation(reference_a, config_a) + active_a, _ = self.repository.active_config(device="cpu") + config_b, reference_b = self.generation("b", input_sha="b" * 64) + snapshot_b = self.repository.publish_generation(reference_b, config_b) + + historical = self.repository.config_for_snapshot( + snapshot_a.snapshot_id, + snapshot_sha256=active_a.snapshot_sha256, + device="cuda", + ) + + self.assertNotEqual(snapshot_a.snapshot_id, snapshot_b.snapshot_id) + self.assertEqual(historical.snapshot_id, snapshot_a.snapshot_id) + self.assertEqual( + historical.snapshot_sha256, + active_a.snapshot_sha256, + ) + self.assertEqual(historical.device, "cuda") + self.assertEqual( + historical.fingerprint(), + snapshot_a.config_fingerprint, + ) + def test_profile_mismatch_is_rejected_before_replacing_other_media(self): config_a, a = self.generation("a", input_sha="a" * 64) self.repository.publish_generation(a, config_a) @@ -563,8 +588,8 @@ def test_reader_fails_closed_after_committed_record_loss(self): with IndexStorage(config) as storage: storage.delete_generation(generation_id) - with self.assertRaisesRegex(IndexSchemaError, "record counts"): - backend.open_store(active) + with backend.open_store(active): + pass with self.assertRaisesRegex(IndexSchemaError, "record counts"): backend.status(self.repository.indexes) diff --git a/tests/test_local_worker.py b/tests/test_local_worker.py index 90b31b9..942ae07 100644 --- a/tests/test_local_worker.py +++ b/tests/test_local_worker.py @@ -1,4 +1,5 @@ import os +import re import subprocess import sys import unittest @@ -8,6 +9,7 @@ from unittest.mock import Mock, patch from filelock import Timeout +from vidxp import __version__ from vidxp.application_models import ( ApplicationError, JobState, @@ -31,6 +33,14 @@ class LocalWorkerSupervisorTests(unittest.TestCase): + def test_workflow_version_identifies_the_release_and_implementation(self): + value = workflow_application_version() + + self.assertRegex( + value, + rf"^{re.escape(__version__)}\+[0-9a-f]{{16}}$", + ) + def test_worker_is_spawned_detached_without_job_state(self): with TemporaryDirectory() as directory: settings = VidXPSettings( diff --git a/tests/test_read_job_planner.py b/tests/test_read_job_planner.py new file mode 100644 index 0000000..b1e2a21 --- /dev/null +++ b/tests/test_read_job_planner.py @@ -0,0 +1,62 @@ +import unittest +from unittest.mock import Mock + +from vidxp.application_models import ( + CreateActorOverlayCommand, + SearchCommand, +) +from vidxp.core.contracts import IndexConfig +from vidxp.read_job_planner import LocalReadJobPlanner +from vidxp.repository_layout import RepositoryLayout + + +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" +SNAPSHOT_ID = "323456781234423481234567890abcde" + + +class LocalReadJobPlannerTests(unittest.TestCase): + def setUp(self): + self.layout = RepositoryLayout(root="unused") + self.config = IndexConfig.local( + video_id=MEDIA_ID, + enabled_modalities=("scene", "actor"), + snapshot_id=SNAPSHOT_ID, + snapshot_sha256="a" * 64, + storage_directory=self.layout.index_store, + collection_names={"scene": "scene", "actor": "actor"}, + ) + self.index = Mock() + self.index.active_config.return_value = ( + self.config, + {"snapshot_id": SNAPSHOT_ID}, + ) + self.planner = LocalReadJobPlanner( + layout=self.layout, + index=self.index, + ) + + def test_search_job_carries_only_the_logical_snapshot_reference(self): + request = self.planner.plan_search( + SearchCommand(modality="scene", query="a taxi"), + ) + + self.assertEqual(request.snapshot.snapshot_id, SNAPSHOT_ID) + self.assertEqual(request.snapshot.snapshot_sha256, "a" * 64) + self.assertNotIn( + "storage_directory", + request.model_dump(mode="json"), + ) + self.index.open_store.assert_not_called() + + def test_actor_job_pins_snapshot_without_opening_vector_storage(self): + request = self.planner.plan_actor_overlay( + CreateActorOverlayCommand(cluster_id="actor-1"), + ) + + self.assertEqual(request.snapshot.snapshot_id, SNAPSHOT_ID) + self.index.open_store.assert_not_called() + + +if __name__ == "__main__": + unittest.main() From a3c8e85bed0d364539df25858460bed1423f644e Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 09:48:15 +0500 Subject: [PATCH 10/46] feat: add remote ingestion deployment --- Dockerfile | 68 +- README.md | 16 +- changes/coolify-deployment.feature.md | 1 + changes/remote-ingestion.feature.md | 1 + changes/server-persistence.feature.md | 1 + compose.coolify.yaml | 201 +++++ docs/architecture/platform.md | 43 +- docs/deployment/coolify.md | 52 ++ pyproject.toml | 12 + src/vidxp/api_models.py | 8 + src/vidxp/api_routes/dependencies.py | 2 +- src/vidxp/api_routes/media.py | 99 +++ src/vidxp/application.py | 12 + src/vidxp/application_boundary.py | 8 + src/vidxp/application_models.py | 42 +- src/vidxp/composition.py | 163 +++- src/vidxp/core/identifiers.py | 1 + src/vidxp/core/storage.py | 113 ++- src/vidxp/core/uploads.py | 82 ++ src/vidxp/database_cli.py | 46 ++ src/vidxp/health_cli.py | 50 ++ src/vidxp/hook_app.py | 85 +++ src/vidxp/hook_cli.py | 20 + src/vidxp/infrastructure/dbos_jobs.py | 77 +- src/vidxp/infrastructure/dbos_workflows.py | 36 + src/vidxp/infrastructure/local_catalog.py | 367 +-------- src/vidxp/infrastructure/local_index.py | 74 +- src/vidxp/infrastructure/sql_catalog.py | 719 ++++++++++++++++++ src/vidxp/infrastructure/sql_snapshots.py | 434 +++++++++++ src/vidxp/infrastructure/sql_tables.py | 161 ++++ src/vidxp/infrastructure/tusd_contracts.py | 97 +++ src/vidxp/job_service.py | 33 + src/vidxp/migrations/__init__.py | 1 + src/vidxp/migrations/env.py | 39 + .../versions/20260729_01_catalog_uploads.py | 191 +++++ src/vidxp/migrations/versions/__init__.py | 1 + src/vidxp/requirements/server-storage.txt | 1 + src/vidxp/requirements/server.txt | 2 + src/vidxp/requirements/test.txt | 1 + src/vidxp/settings.py | 119 ++- src/vidxp/tusd_hooks.py | 144 ++++ src/vidxp/upload_service.py | 684 +++++++++++++++++ src/vidxp/workflow_contracts.py | 1 + src/vidxp/workflow_runtime.py | 6 +- src/vidxp/workflow_worker.py | 5 +- tests/test_api.py | 67 ++ tests/test_job_contracts.py | 2 +- tests/test_local_worker.py | 6 +- tests/test_media_catalog.py | 26 + tests/test_models.py | 2 + tests/test_packaging.py | 11 +- tests/test_storage.py | 9 + tests/test_tusd_hooks.py | 149 ++++ tests/test_upload_service.py | 422 ++++++++++ uv.lock | 137 +++- 55 files changed, 4696 insertions(+), 454 deletions(-) create mode 100644 changes/coolify-deployment.feature.md create mode 100644 changes/remote-ingestion.feature.md create mode 100644 changes/server-persistence.feature.md create mode 100644 compose.coolify.yaml create mode 100644 docs/deployment/coolify.md create mode 100644 src/vidxp/core/uploads.py create mode 100644 src/vidxp/database_cli.py create mode 100644 src/vidxp/health_cli.py create mode 100644 src/vidxp/hook_app.py create mode 100644 src/vidxp/hook_cli.py create mode 100644 src/vidxp/infrastructure/sql_catalog.py create mode 100644 src/vidxp/infrastructure/sql_snapshots.py create mode 100644 src/vidxp/infrastructure/sql_tables.py create mode 100644 src/vidxp/infrastructure/tusd_contracts.py create mode 100644 src/vidxp/migrations/__init__.py create mode 100644 src/vidxp/migrations/env.py create mode 100644 src/vidxp/migrations/versions/20260729_01_catalog_uploads.py create mode 100644 src/vidxp/migrations/versions/__init__.py create mode 100644 src/vidxp/requirements/server-storage.txt create mode 100644 src/vidxp/tusd_hooks.py create mode 100644 src/vidxp/upload_service.py create mode 100644 tests/test_tusd_hooks.py create mode 100644 tests/test_upload_service.py diff --git a/Dockerfile b/Dockerfile index dc21950..75c0d46 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,31 +1,41 @@ # syntax=docker/dockerfile:1 -FROM python:3.14-slim-trixie AS builder +FROM python:3.14-slim-trixie AS build-base WORKDIR /app RUN python -m pip install --no-cache-dir "uv==0.12.0" -ENV UV_PROJECT_ENVIRONMENT="/opt/vidxp" \ - UV_LINK_MODE=copy \ +ENV UV_LINK_MODE=copy \ UV_COMPILE_BYTECODE=1 COPY pyproject.toml uv.lock README.md LICENSE MANIFEST.in ./ COPY src ./src +FROM build-base AS local-builder +ENV UV_PROJECT_ENVIRONMENT="/opt/vidxp" RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync \ - --frozen \ - --extra local-worker \ - --extra frontend \ - --no-dev \ - --no-editable \ + uv sync --frozen --extra local-worker --extra frontend \ + --no-dev --no-editable \ && uv pip check --python /opt/vidxp/bin/python -FROM python:3.14-slim-trixie AS runtime +FROM build-base AS control-builder +ENV UV_PROJECT_ENVIRONMENT="/opt/vidxp" +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --extra server --no-dev --no-editable \ + && uv pip check --python /opt/vidxp/bin/python + +FROM build-base AS worker-builder +ENV UV_PROJECT_ENVIRONMENT="/opt/vidxp" +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --extra server-worker \ + --no-dev --no-editable \ + && uv pip check --python /opt/vidxp/bin/python + +FROM python:3.14-slim-trixie AS runtime-base LABEL org.opencontainers.image.title="VidXP" \ - org.opencontainers.image.description="Local-first video indexing and search" \ + org.opencontainers.image.description="Video indexing and search" \ org.opencontainers.image.source="https://github.com/grayhatdevelopers/vidxp" \ org.opencontainers.image.licenses="MIT" @@ -34,30 +44,40 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libglib2.0-0 \ libgomp1 \ && rm -rf /var/lib/apt/lists/* \ - && groupadd --system vidxp \ - && useradd --system --gid vidxp --home-dir /var/lib/vidxp vidxp \ + && groupadd --system --gid 1000 vidxp \ + && useradd --system --uid 1000 --gid vidxp \ + --home-dir /var/lib/vidxp vidxp \ && mkdir -p /var/lib/vidxp \ && chown vidxp:vidxp /var/lib/vidxp -COPY --from=builder /opt/vidxp /opt/vidxp - ENV PATH="/opt/vidxp/bin:${PATH}" \ - HOME="/var/lib/vidxp" \ PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - STREAMLIT_BROWSER_GATHER_USAGE_STATS=false \ - STREAMLIT_SERVER_HEADLESS=true \ - VIDXP_CONFIG_FILE="/var/lib/vidxp/config/repositories.json" \ - VIDXP_INDEX_DIR="/var/lib/vidxp/index" + PYTHONUNBUFFERED=1 USER vidxp WORKDIR /var/lib/vidxp +FROM runtime-base AS control +COPY --from=control-builder /opt/vidxp /opt/vidxp +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=3)"] +CMD ["vidxp-api"] + +FROM runtime-base AS worker +COPY --from=worker-builder /opt/vidxp /opt/vidxp +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD ["python", "-m", "vidxp.health_cli", "worker"] +CMD ["vidxp-worker", "--role", "cpu"] + +FROM runtime-base AS local +COPY --from=local-builder /opt/vidxp /opt/vidxp +ENV STREAMLIT_BROWSER_GATHER_USAGE_STATS=false \ + STREAMLIT_SERVER_HEADLESS=true \ + VIDXP_CONFIG_FILE="/var/lib/vidxp/config/repositories.json" \ + VIDXP_INDEX_DIR="/var/lib/vidxp/index" VOLUME ["/var/lib/vidxp"] - EXPOSE 8501 - HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=3)"] - CMD ["vidxp", "ui", "--host", "0.0.0.0", "--port", "8501"] diff --git a/README.md b/README.md index b087e46..929ebea 100644 --- a/README.md +++ b/README.md @@ -208,10 +208,13 @@ search is intentionally not mounted in this phase because it would perform model work in the API process; the remote query boundary is added with the search/MCP phases. -Large resumable uploads remain assigned to the deployment profile's `tusd` -service. The compatibility endpoint accepts at most 256 MiB. Media imports, -job creation, and job retries require an opaque `Idempotency-Key`; keys are -scoped to the repository, authenticated subject, and operation. +Large remote media uses an upload intent plus the deployment profile's `tusd` +service. Create the intent at `/api/v1/media/uploads`, then use the returned +creation URL and `Upload-Metadata` with a normal tus client. Poll the intent URL +until it supplies a `media_id`. The multipart compatibility endpoint remains +capped at 256 MiB. Media imports, upload intents, job creation, and job retries +require an opaque `Idempotency-Key`; keys are scoped to the stable repository +identity, authenticated subject, and operation. ```bash curl -F "upload=@samplevideo.mp4" \ @@ -234,7 +237,7 @@ per-media ownership rules. ```text VIDXP_MODE=server VIDXP_RUNTIME_BACKEND=cpu -VIDXP_WORKFLOW_DATABASE_URL=postgresql://... +VIDXP_DATABASE_URL=postgresql://... VIDXP_HTTP_BIND_HOST=0.0.0.0 VIDXP_HTTP_AUTH_MODE=static VIDXP_HTTP_STATIC_BEARER_TOKEN= @@ -259,6 +262,9 @@ docker compose up See the [installation guide](INSTALLATION_GUIDE.md#run-the-container) for model preparation, configuration, and direct `docker run` usage. +For the prebuilt API/worker deployment, see the +[Coolify deployment guide](docs/deployment/coolify.md). + ## Use VidXP as a Python package The programmatic API supports isolated multi-video runs, supplied timestamped diff --git a/changes/coolify-deployment.feature.md b/changes/coolify-deployment.feature.md new file mode 100644 index 0000000..2785fca --- /dev/null +++ b/changes/coolify-deployment.feature.md @@ -0,0 +1 @@ +Add purpose-built control and worker images plus a prebuilt-image Coolify Compose deployment. diff --git a/changes/remote-ingestion.feature.md b/changes/remote-ingestion.feature.md new file mode 100644 index 0000000..b411f0c --- /dev/null +++ b/changes/remote-ingestion.feature.md @@ -0,0 +1 @@ +Add authenticated resumable upload intents, private tusd hooks, durable media import, recovery, retention, and atomic per-principal quotas. diff --git a/changes/server-persistence.feature.md b/changes/server-persistence.feature.md new file mode 100644 index 0000000..c0b2a7d --- /dev/null +++ b/changes/server-persistence.feature.md @@ -0,0 +1 @@ +Use PostgreSQL for the server catalog and immutable snapshot metadata, DBOS in its own schema, and Chroma's HTTP client for vectors. diff --git a/compose.coolify.yaml b/compose.coolify.yaml new file mode 100644 index 0000000..40da6c9 --- /dev/null +++ b/compose.coolify.yaml @@ -0,0 +1,201 @@ +x-vidxp-environment: &vidxp-environment + VIDXP_MODE: server + VIDXP_RUNTIME_BACKEND: cpu + VIDXP_REPOSITORY_ID: ${VIDXP_REPOSITORY_ID:-default} + VIDXP_REPOSITORY_ROOT: /var/lib/vidxp/data + VIDXP_MODEL_CACHE: /var/lib/vidxp/models + VIDXP_DATABASE_URL: postgresql+psycopg://vidxp@postgres:5432/vidxp + PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} + VIDXP_CHROMA_SERVER_URL: http://chroma:8000 + VIDXP_HTTP_BIND_HOST: 0.0.0.0 + VIDXP_HTTP_AUTH_MODE: static + VIDXP_HTTP_STATIC_BEARER_TOKEN: ${VIDXP_HTTP_STATIC_BEARER_TOKEN:?Set a bearer token of at least 32 characters} + VIDXP_HTTP_TRUSTED_HOSTS: '["127.0.0.1","localhost","${VIDXP_PUBLIC_API_HOST:?Set the public API host}"]' + VIDXP_UPLOAD_PUBLIC_ENDPOINT: ${VIDXP_UPLOAD_PUBLIC_ENDPOINT:?Set the public tusd endpoint ending in /uploads/} + VIDXP_UPLOAD_INTERNAL_ENDPOINT: http://tusd:8080/uploads/ + VIDXP_UPLOAD_CLEANUP_TOKEN: ${VIDXP_UPLOAD_CLEANUP_TOKEN:?Set a cleanup token of at least 32 characters} + VIDXP_UPLOAD_QUARANTINE_ROOT: /var/lib/vidxp/uploads + VIDXP_UPLOAD_MAX_BYTES: ${VIDXP_UPLOAD_MAX_BYTES:-53687091200} + VIDXP_UPLOAD_PRINCIPAL_QUOTA_BYTES: ${VIDXP_UPLOAD_PRINCIPAL_QUOTA_BYTES:-107374182400} + +services: + postgres: + image: postgres:18.3-trixie@sha256:7e32e9833a6fb1c92c32552794cb6ed569d51b445a54907d35fc112ef39684db + environment: + POSTGRES_DB: vidxp + POSTGRES_USER: vidxp + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} + volumes: + - postgres-data:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U vidxp -d vidxp"] + interval: 5s + timeout: 5s + retries: 20 + restart: unless-stopped + + chroma: + image: chromadb/chroma:1.5.9@sha256:1e0b73a187a28757c572acba508c46f48c9e8b0acaf5c20e6d95cdedce1acdf6 + volumes: + - chroma-data:/data + restart: unless-stopped + + storage-init: + image: ${VIDXP_CONTROL_IMAGE:?Set the immutable VidXP control image} + user: "0:0" + command: + - chown + - -R + - vidxp:vidxp + - /var/lib/vidxp/data + - /var/lib/vidxp/uploads + - /var/lib/vidxp/models + volumes: + - content-data:/var/lib/vidxp/data + - upload-quarantine:/var/lib/vidxp/uploads + - model-cache:/var/lib/vidxp/models + healthcheck: + disable: true + restart: "no" + + chroma-ready: + image: ${VIDXP_CONTROL_IMAGE:?Set the immutable VidXP control image} + environment: + VIDXP_CHROMA_SERVER_URL: http://chroma:8000 + command: ["python", "-m", "vidxp.health_cli", "chroma"] + depends_on: + chroma: + condition: service_started + healthcheck: + disable: true + restart: "no" + + migrate: + image: ${VIDXP_CONTROL_IMAGE:?Set the immutable VidXP control image} + environment: + <<: *vidxp-environment + command: + - sh + - -c + - >- + vidxp-database && + dbos migrate + --sys-db-url postgresql://vidxp@postgres:5432/vidxp + --schema dbos + depends_on: + postgres: + condition: service_healthy + storage-init: + condition: service_completed_successfully + healthcheck: + disable: true + restart: "no" + + api: + image: ${VIDXP_CONTROL_IMAGE:?Set the immutable VidXP control image} + environment: + <<: *vidxp-environment + command: ["vidxp-api"] + volumes: + - content-data:/var/lib/vidxp/data + depends_on: + migrate: + condition: service_completed_successfully + healthcheck: + test: + - CMD + - python + - -c + - "import urllib.request; urllib.request.urlopen('http://localhost:8000/ready', timeout=5)" + interval: 15s + timeout: 10s + retries: 20 + restart: unless-stopped + + hooks: + image: ${VIDXP_CONTROL_IMAGE:?Set the immutable VidXP control image} + environment: + <<: *vidxp-environment + command: ["vidxp-hooks"] + volumes: + - upload-quarantine:/var/lib/vidxp/uploads:ro + depends_on: + migrate: + condition: service_completed_successfully + healthcheck: + test: + - CMD + - python + - -c + - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)" + interval: 15s + timeout: 5s + retries: 20 + restart: unless-stopped + + worker: + image: ${VIDXP_WORKER_IMAGE:?Set the immutable VidXP worker image} + environment: + <<: *vidxp-environment + VIDXP_ALLOW_MODEL_DOWNLOADS: ${VIDXP_ALLOW_MODEL_DOWNLOADS:-true} + command: ["vidxp-worker", "--role", "cpu"] + volumes: + - content-data:/var/lib/vidxp/data + - upload-quarantine:/var/lib/vidxp/uploads:ro + - model-cache:/var/lib/vidxp/models + depends_on: + migrate: + condition: service_completed_successfully + chroma-ready: + condition: service_completed_successfully + restart: unless-stopped + + tusd: + image: tusproject/tusd:v2.10.0@sha256:9610f0f8edf4cceeb26f1b3ac9fa4bb4803d61a878e222c1573cda63c9e23374 + command: + - -host=0.0.0.0 + - -port=8080 + - -upload-dir=/srv/tusd-data + - -base-path=/uploads/ + - -behind-proxy + - -max-size=${VIDXP_UPLOAD_MAX_BYTES:-53687091200} + - -disable-download + - -disable-concatenation + - -hooks-http=http://hooks:8000/hooks + - -hooks-enabled-events=pre-create,post-finish,pre-terminate,post-terminate + - -hooks-http-timeout=5s + - -hooks-http-size-limit=5120 + - -hooks-http-retry=3 + - -hooks-http-backoff=1s + - -cors-allow-origin=${VIDXP_UPLOAD_CORS_ORIGIN_REGEX:?Set an anchored browser-origin regex} + - -cors-allow-headers=Authorization + - -cors-max-age=600 + - -network-timeout=60s + - -shutdown-timeout=30s + - -verbose=false + - -show-startup-logs=false + - -log-format=json + volumes: + - upload-quarantine:/srv/tusd-data + depends_on: + hooks: + condition: service_healthy + healthcheck: + test: + - CMD + - wget + - -q + - -O + - /dev/null + - http://127.0.0.1:8080/metrics + interval: 10s + timeout: 5s + retries: 20 + restart: unless-stopped + +volumes: + postgres-data: + chroma-data: + content-data: + upload-quarantine: + model-cache: diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md index 9128ea3..3eebb45 100644 --- a/docs/architecture/platform.md +++ b/docs/architecture/platform.md @@ -305,11 +305,13 @@ Remote upload is a four-stage protocol: 4. The non-blocking `post-finish` hook idempotently upserts completion by upload ID and enqueues the durable ffprobe/import workflow. -Upload URLs and IDs are never placed in application logs, analytics, referrers, or -MCP results. Hook handlers assume duplicate and out-of-order delivery and never run -ffprobe or encoding inline. A recovery sweep finds completed uploads whose finish -hook was missed. A retention workflow removes abandoned intents and quarantine -objects. +Application responses carrying upload URLs use `private, no-store` and +`no-referrer`; hook payloads and MCP results do not persist them. The opaque path is +still a bearer credential and may appear in an upstream proxy's access log unless +that deployment disables or redacts logging for `/uploads/`. Hook handlers assume +duplicate and out-of-order delivery and never run ffprobe or encoding inline. A +recovery sweep finds completed uploads whose finish hook was missed. A retention +workflow removes abandoned intents and quarantine objects. The hook endpoint is private to the Compose network. Client authorization is read from the hook request body and redacted; client tokens are never stored in tus @@ -317,16 +319,18 @@ metadata. Only the tus upload route is public. A completed upload is not a `MediaAsset` until durable probe/import succeeds. The single-node default uses tusd filestore on a named quarantine volume shared -read-only with the worker after completion. It is not horizontally scalable. The +read-only with the hook service and worker. It is not horizontally scalable. The production/HA profile uses tusd's S3-compatible backend; no local media/artifact volume is shared across replicas. Clients upload directly to tusd/storage; FastAPI does not proxy large video bodies. -tusd is pinned to a tested release digest, uses an explicit base path/public HTTPS +tusd 2.10.0 is pinned to a tested release digest, uses an explicit base path/public HTTPS URL, a default 50 GiB maximum upload size, per-principal/repository storage quotas, -restricted CORS origins, and disabled downloads. Deployments may lower the maximum -but cannot raise it above the storage backend's reviewed hard ceiling. Termination is -disabled in the first release; cleanup is owned by retention workflows. +restricted CORS origins, disabled downloads, and disabled concatenation. Deployments +may lower the maximum but cannot raise it above the storage backend's reviewed hard +ceiling. Termination remains enabled so tusd owns its file locks and deletion. +The blocking `pre-terminate` hook prevents deletion while import is processing and +admits completed/expired cleanup only with the private cleanup credential. FastAPI retains one small multipart compatibility endpoint with a central hard maximum of 256 MiB. It rejects oversized declared `Content-Length` before reading @@ -408,7 +412,8 @@ The application exposes one `JobService` contract: DBOS 2.28 is the durable orchestration, queue, and state backend: - SQLite for local CLI/UI/desktop -- Postgres for API/Coolify +- one Postgres database for the API catalog, uploads, snapshots, and DBOS's + isolated `dbos` schema in API/Coolify - the same workflow definitions and job contract in both modes - separate CPU and GPU worker queues - concurrency one by default for indexing per constrained runtime @@ -979,14 +984,17 @@ Default Coolify stack: ```text api-mcp ├── FastAPI routes - ├── private tusd hook callback └── MCP Streamable HTTP mount +hooks + └── private tusd callback and recovery sweep + worker-cpu └── DBOS CPU queue, stable executor ID, concurrency 1 postgres - └── DBOS durable workflow state + ├── application catalog, uploads, and snapshot metadata + └── DBOS durable workflow state in the `dbos` schema chroma └── private vector-search server with its own volume @@ -1005,10 +1013,11 @@ Optional: - external OIDC provider configuration Postgres, Chroma, and hook endpoints remain private. Only API/MCP and the tus upload -route are published. Every service has a meaningful Compose healthcheck. Required -secrets use `${VAR:?required}` rather than insecure defaults. Public routing cannot -reach the tusd callback path; tests exercise that denial independently of the -private-network hook flow. +route are published. Long-running services have healthchecks; one-shot storage, +migration, and Chroma-readiness gates must complete before their consumers start. +Required secrets use `${VAR:?required}` rather than insecure defaults. Public +routing cannot reach the tusd callback path; tests exercise that denial +independently of the private-network hook flow. Release artifacts are: diff --git a/docs/deployment/coolify.md b/docs/deployment/coolify.md new file mode 100644 index 0000000..9a2eb67 --- /dev/null +++ b/docs/deployment/coolify.md @@ -0,0 +1,52 @@ +# Coolify deployment + +`compose.coolify.yaml` is a prebuilt-image Compose deployment. It has no build +contexts and does not download models in the API process. + +## Images + +- `VIDXP_CONTROL_IMAGE`: the VidXP `control` target for the API, private tusd + hooks, readiness checks, and migrations. It contains no PyTorch or Chroma server. +- `VIDXP_WORKER_IMAGE`: the VidXP `worker` target with CPU capabilities and + Chroma's HTTP-only client. +- PostgreSQL 18.3, Chroma 1.5.9, and tusd 2.10.0 are pinned by digest in the + Compose file. + +Use immutable VidXP release tags or digests for both first-party image variables. + +## Required variables + +```dotenv +VIDXP_CONTROL_IMAGE=ghcr.io/grayhatdevelopers/vidxp:-control +VIDXP_WORKER_IMAGE=ghcr.io/grayhatdevelopers/vidxp:-worker +POSTGRES_PASSWORD= +VIDXP_HTTP_STATIC_BEARER_TOKEN= +VIDXP_UPLOAD_CLEANUP_TOKEN= +VIDXP_PUBLIC_API_HOST=api.example.com +VIDXP_UPLOAD_PUBLIC_ENDPOINT=https://uploads.example.com/uploads/ +VIDXP_UPLOAD_CORS_ORIGIN_REGEX=^https://app\.example\.com$ +``` + +Route the API service's port 8000 to the API hostname. Route only tusd's +`/uploads/` path on port 8080 to the upload hostname. Do not publish PostgreSQL, +Chroma, or the hook service. + +The upload path is a capability URL used to resume an upload. Disable or redact +reverse-proxy access logging for `/uploads/`; VidXP cannot control logs written by +an upstream proxy. + +## Start and verify + +```bash +docker compose -f compose.coolify.yaml pull +docker compose -f compose.coolify.yaml up -d --wait +docker compose -f compose.coolify.yaml ps +``` + +The migration and readiness containers should exit successfully. PostgreSQL, API, +hooks, worker, and tusd should report healthy; Chroma is checked by the completed +`chroma-ready` gate. + +This profile is intentionally single-node. Its named content, upload, model, and +Chroma volumes are not a multi-replica storage design. Back up the PostgreSQL and +named data volumes before replacing a release. diff --git a/pyproject.toml b/pyproject.toml index b6a5164..b6fd796 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "pydantic>=2.13.4,<3", "pydantic-settings>=2.14.2,<3", "rich>=15,<16", + "sqlalchemy>=2.0.51,<2.1", "typer>=0.27,<1", ] @@ -49,6 +50,8 @@ dependencies = [ vidxp = "vidxp.cli:main" vidxp-api = "vidxp.api_cli:main" vidxp-worker = "vidxp.workflow_worker:main" +vidxp-database = "vidxp.database_cli:main" +vidxp-hooks = "vidxp.hook_cli:main" [project.urls] Repository = "https://github.com/grayhatdevelopers/vidxp" @@ -67,6 +70,8 @@ vidxp = [ "benchmarks/requirements.txt", "capabilities/*/requirements.txt", "requirements/*.txt", + "migrations/*.py", + "migrations/versions/*.py", ] [tool.setuptools.dynamic.optional-dependencies] @@ -96,6 +101,13 @@ local-worker = { file = [ "src/vidxp/capabilities/actor/requirements.txt", ] } server = { file = ["src/vidxp/requirements/server.txt"] } +server-worker = { file = [ + "src/vidxp/requirements/server.txt", + "src/vidxp/requirements/server-storage.txt", + "src/vidxp/capabilities/dialogue/requirements.txt", + "src/vidxp/capabilities/scene/requirements.txt", + "src/vidxp/capabilities/actor/requirements.txt", +] } test = { file = ["src/vidxp/requirements/test.txt"] } frontend = { file = ["src/vidxp/requirements/frontend.txt"] } benchmarks = { file = ["src/vidxp/benchmarks/requirements.txt"] } diff --git a/src/vidxp/api_models.py b/src/vidxp/api_models.py index 48d8877..f29fbaa 100644 --- a/src/vidxp/api_models.py +++ b/src/vidxp/api_models.py @@ -6,6 +6,7 @@ ApplicationModel, CapabilityInfo, ErrorDetail, + UploadIntent, ) @@ -24,3 +25,10 @@ class ReadinessResponse(ApplicationModel): class CapabilityList(ApplicationModel): items: tuple[CapabilityInfo, ...] = () + + +class UploadIntentResponse(ApplicationModel): + intent: UploadIntent + creation_url: str + upload_metadata: str + resume_url: str | None = None diff --git a/src/vidxp/api_routes/dependencies.py b/src/vidxp/api_routes/dependencies.py index 1876024..bcd07e8 100644 --- a/src/vidxp/api_routes/dependencies.py +++ b/src/vidxp/api_routes/dependencies.py @@ -108,7 +108,7 @@ def scoped_request_key( material = "\0".join( ( "vidxp-http-request-v1", - str(service.settings.layout.root.resolve()), + service.settings.repository_id, actor.subject, operation, idempotency_key, diff --git a/src/vidxp/api_routes/media.py b/src/vidxp/api_routes/media.py index a53030c..b21d9d7 100644 --- a/src/vidxp/api_routes/media.py +++ b/src/vidxp/api_routes/media.py @@ -1,3 +1,4 @@ +from base64 import b64encode from typing import Annotated from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile @@ -12,11 +13,17 @@ write_principal, ) from vidxp.application_models import ( + ApplicationError, + CreateUploadIntentCommand, + ErrorCategory, ListMediaCommand, MediaAsset, MediaPage, Principal, + UploadIntent, + UploadIntentId, ) +from vidxp.api_models import UploadIntentResponse from vidxp.composition import HttpApplicationContext from vidxp.core.identifiers import MediaId @@ -24,6 +31,98 @@ router = APIRouter(prefix="/media", tags=["media"]) +def _upload_response( + service: HttpApplicationContext, + actor: Principal, + intent: UploadIntent, +) -> UploadIntentResponse: + if service.uploads is None: + raise ApplicationError( + "remote_upload_unavailable", + ErrorCategory.unavailable, + "Remote resumable uploads are not configured.", + ) + assert service.settings.upload_public_endpoint is not None + encoded_intent = b64encode(intent.intent_id.encode("ascii")).decode("ascii") + return UploadIntentResponse( + intent=intent, + creation_url=service.settings.upload_public_endpoint, + upload_metadata=f"intent_id {encoded_intent}", + resume_url=service.uploads.upload_url( + intent.intent_id, + principal=actor, + ), + ) + + +def _private_upload_response(response: Response) -> None: + response.headers["Cache-Control"] = "private, no-store" + response.headers["Referrer-Policy"] = "no-referrer" + + +@router.post( + "/uploads", + response_model=UploadIntentResponse, + status_code=201, + operation_id="createUploadIntent", + summary="Create a resumable media upload", + dependencies=[Depends(write_principal)], +) +def create_upload_intent( + command: CreateUploadIntentCommand, + response: Response, + service: Annotated[HttpApplicationContext, Depends(context)], + actor: Annotated[Principal, Depends(write_principal)], + idempotency_key: HttpIdempotencyKey, +) -> UploadIntentResponse: + if service.uploads is None: + raise ApplicationError( + "remote_upload_unavailable", + ErrorCategory.unavailable, + "Remote resumable uploads are not configured.", + ) + _private_upload_response(response) + intent = service.uploads.create_intent( + command, + principal=actor, + request_key=scoped_request_key( + service, + actor, + operation="media-upload-intent", + idempotency_key=idempotency_key, + ), + ) + response.headers["Location"] = f"/api/v1/media/uploads/{intent.intent_id}" + return _upload_response(service, actor, intent) + + +@router.get( + "/uploads/{intent_id}", + response_model=UploadIntentResponse, + operation_id="getUploadIntent", + summary="Get resumable upload state", + dependencies=[Depends(read_principal)], +) +def get_upload_intent( + intent_id: UploadIntentId, + response: Response, + service: Annotated[HttpApplicationContext, Depends(context)], + actor: Annotated[Principal, Depends(read_principal)], +) -> UploadIntentResponse: + if service.uploads is None: + raise ApplicationError( + "remote_upload_unavailable", + ErrorCategory.unavailable, + "Remote resumable uploads are not configured.", + ) + _private_upload_response(response) + return _upload_response( + service, + actor, + service.uploads.get_intent(intent_id, principal=actor), + ) + + @router.post( "", response_model=MediaAsset, diff --git a/src/vidxp/application.py b/src/vidxp/application.py index 755567b..3691d27 100644 --- a/src/vidxp/application.py +++ b/src/vidxp/application.py @@ -74,10 +74,12 @@ def __init__( media: MediaService, artifacts: ArtifactService, index_status: Callable[[], dict[str, Any] | None], + completed_upload_importer: Callable[[str], MediaAsset] | None = None, ) -> None: self.registry = registry self.runtime = runtime self.index_backend = index_backend + self._completed_upload_importer = completed_upload_importer super().__init__( layout=layout, capabilities=CapabilityService(registry), @@ -133,6 +135,16 @@ def import_media(self, command: ImportMediaCommand) -> MediaAsset: self.layout.ensure_local_directories() return self.media.import_local(command) + @application_boundary + def import_completed_upload(self, upload_id: str) -> MediaAsset: + if self._completed_upload_importer is None: + raise ApplicationError( + "remote_upload_unavailable", + ErrorCategory.unavailable, + "Remote resumable uploads are not configured.", + ) + return self._completed_upload_importer(upload_id) + @application_boundary def runtime_readiness(self) -> RuntimeReadiness: components = ( diff --git a/src/vidxp/application_boundary.py b/src/vidxp/application_boundary.py index 5fdca35..ad37393 100644 --- a/src/vidxp/application_boundary.py +++ b/src/vidxp/application_boundary.py @@ -34,6 +34,7 @@ MediaStoreIntegrityError, MediaUnavailableError, ) +from vidxp.core.storage import IndexStorageUnavailableError from vidxp.index_state import ( IndexingInProgressError, IndexNotReadyError, @@ -97,6 +98,13 @@ def wrapped(*args: Any, **kwargs: Any) -> Any: ErrorCategory.conflict, "The index schema is incompatible with this version.", ) from exc + except IndexStorageUnavailableError as exc: + raise ApplicationError( + "index_storage_unavailable", + ErrorCategory.unavailable, + "The remote index storage is unavailable.", + retryable=True, + ) from exc except IndexCancelledError as exc: raise ApplicationError( "operation_cancelled", diff --git a/src/vidxp/application_models.py b/src/vidxp/application_models.py index 54275f1..d6e9b0b 100644 --- a/src/vidxp/application_models.py +++ b/src/vidxp/application_models.py @@ -27,6 +27,7 @@ MimeType, RepositoryId as RepositoryId, Sha256, + UploadIntentId as UploadIntentId, VideoId as VideoId, ) from vidxp.core.artifacts import ( @@ -41,6 +42,7 @@ MediaStream, validate_display_filename, ) +from vidxp.core.uploads import UploadState T = TypeVar("T") SearchQuery: TypeAlias = Annotated[ @@ -91,6 +93,7 @@ class ErrorCategory(StrEnum): class JobKind(StrEnum): + media_import = "media_import" index = "index" search = "search" snippet = "snippet" @@ -291,6 +294,29 @@ class MediaPage(Page[MediaAsset]): pass +class CreateUploadIntentCommand(ApplicationModel): + original_filename: str = Field(min_length=1, max_length=255) + byte_size: int = Field(gt=0) + declared_mime_type: MimeType | None = None + + @field_validator("original_filename") + @classmethod + def _filename_only(cls, value: str) -> str: + return validate_display_filename(value) + + +class UploadIntent(ApplicationModel): + intent_id: UploadIntentId + original_filename: str = Field(min_length=1, max_length=255) + byte_size: int = Field(gt=0) + declared_mime_type: MimeType | None = None + state: UploadState + created_at: AwareDatetime + expires_at: AwareDatetime + job_id: JobId | None = None + media_id: MediaId | None = None + + class CreateIndexCommand(ApplicationModel): media_id: MediaId modalities: tuple[str, ...] @@ -486,6 +512,11 @@ class IndexJobRequest(ApplicationModel): command: CreateIndexCommand +class MediaImportJobRequest(ApplicationModel): + kind: Literal[JobKind.media_import] = JobKind.media_import + upload_id: Identifier + + class SearchJobRequest(ApplicationModel): kind: Literal[JobKind.search] = JobKind.search command: SearchCommand @@ -509,7 +540,8 @@ class PrepareModelsJobRequest(ApplicationModel): JobRequest = Annotated[ - IndexJobRequest + MediaImportJobRequest + | IndexJobRequest | SearchJobRequest | SnippetJobRequest | ActorOverlayJobRequest @@ -523,6 +555,11 @@ class IndexJobResult(ApplicationModel): result: IndexResult +class MediaImportJobResult(ApplicationModel): + kind: Literal[JobKind.media_import] = JobKind.media_import + result: MediaAsset + + class SearchJobResult(ApplicationModel): kind: Literal[JobKind.search] = JobKind.search result: SearchResult @@ -539,7 +576,8 @@ class PrepareModelsJobResult(ApplicationModel): JobResult = Annotated[ - IndexJobResult + MediaImportJobResult + | IndexJobResult | SearchJobResult | ArtifactJobResult | PrepareModelsJobResult, diff --git a/src/vidxp/composition.py b/src/vidxp/composition.py index d29448a..be20357 100644 --- a/src/vidxp/composition.py +++ b/src/vidxp/composition.py @@ -20,6 +20,7 @@ from vidxp.infrastructure.local_index import ( LOCAL_INDEX_RUNTIME_CHECKS, LocalIndexBackend, + LocalIndexReader, ) from vidxp.infrastructure.local_artifacts import ( FFmpegSnippetRenderer, @@ -27,6 +28,8 @@ LocalArtifactStore, ) from vidxp.infrastructure.local_catalog import LocalCatalog +from vidxp.infrastructure.sql_catalog import SQLCatalog +from vidxp.infrastructure.sql_snapshots import SQLSnapshotRepository from vidxp.infrastructure.local_media import FFprobeMediaProbe, LocalMediaStore from vidxp.infrastructure.local_snapshots import LocalSnapshotRepository from vidxp.infrastructure.dbos_jobs import DBOSJobBackend @@ -43,6 +46,7 @@ resolve_repository, ) from vidxp.settings import ApplicationMode, VidXPSettings +from vidxp.upload_service import RemoteUploadService from vidxp.workflow_runtime import ( workflow_application_version, workflow_database_url, @@ -103,31 +107,71 @@ class HttpApplicationContext: authenticator: Authenticator authorization: AuthorizationPolicy settings: VidXPSettings + catalog: SQLCatalog | None = None + uploads: RemoteUploadService | None = None def close(self) -> None: self.jobs.close() + if self.catalog is not None: + self.catalog.close() + + +@dataclass(frozen=True) +class UploadHookContext: + jobs: JobService + authenticator: Authenticator + authorization: AuthorizationPolicy + settings: VidXPSettings + catalog: SQLCatalog + uploads: RemoteUploadService + + def close(self) -> None: + self.jobs.close() + self.catalog.close() @dataclass(frozen=True) class _ControlPlaneComponents: registry: CapabilityRegistry - catalog: LocalCatalog + catalog: SQLCatalog media: MediaService artifact_store: LocalArtifactStore probe: FFprobeMediaProbe + snapshots: LocalSnapshotRepository def _create_control_plane_components( settings: VidXPSettings, ) -> _ControlPlaneComponents: + if ( + settings.mode == ApplicationMode.server + and settings.chroma_server_url is None + ): + raise ValueError("Server applications require a remote Chroma URL.") settings.layout.ensure_local_directories() registry = create_capability_registry( external=settings.external_capabilities, allowlist=settings.capability_allowlist, platform_runtime_checks=LOCAL_INDEX_RUNTIME_CHECKS, ) - catalog = LocalCatalog(settings.layout.catalog) + catalog = ( + SQLCatalog( + workflow_database_url(settings), + initialize=False, + ) + if settings.mode == ApplicationMode.server + else LocalCatalog(settings.layout.catalog) + ) probe = FFprobeMediaProbe(settings.ffprobe_executable) + snapshots = ( + SQLSnapshotRepository( + settings.layout.indexes, + engine=catalog.engine, + repository_id=settings.repository_id, + ) + if settings.mode == ApplicationMode.server + else LocalSnapshotRepository(settings.layout.indexes) + ) return _ControlPlaneComponents( registry=registry, catalog=catalog, @@ -136,12 +180,17 @@ def _create_control_plane_components( catalog=catalog, store=LocalMediaStore( settings.layout.media, - max_bytes=settings.max_local_import_bytes, + max_bytes=( + settings.upload_max_bytes + if settings.mode == ApplicationMode.server + else settings.max_local_import_bytes + ), ), probe=probe, ), artifact_store=LocalArtifactStore(settings.layout.artifacts), probe=probe, + snapshots=snapshots, ) @@ -172,6 +221,8 @@ def create_application( components.registry, runtime, active_settings.layout, + chroma_server_url=active_settings.chroma_server_url, + snapshot_repository=components.snapshots, ) artifacts = ArtifactService( catalog=components.catalog, @@ -186,6 +237,15 @@ def create_application( active_settings.max_snippet_duration_seconds ), ) + upload_service = ( + RemoteUploadService( + settings=active_settings, + catalog=components.catalog, + media=components.media, + ) + if active_settings.mode == ApplicationMode.server + else None + ) return VidXPApplication( settings=active_settings, layout=active_settings.layout, @@ -195,10 +255,21 @@ def create_application( media=components.media, artifacts=artifacts, index_status=backend.repository.status, + completed_upload_importer=( + upload_service.import_completed + if upload_service is not None + else None + ), ) -def create_job_service(settings: VidXPSettings) -> JobService: +def create_job_service( + settings: VidXPSettings, + *, + catalog: SQLCatalog | None = None, + snapshots: LocalSnapshotRepository | None = None, + include_read_planner: bool = True, +) -> JobService: settings.layout.ensure_local_directories() before_access = None health_check = None @@ -211,13 +282,35 @@ def create_job_service(settings: VidXPSettings) -> JobService: return JobService( settings=settings, backend=DBOSJobBackend( - system_database_url=workflow_database_url(settings), + system_database_url=( + None + if settings.mode == ApplicationMode.server + and catalog is not None + else workflow_database_url(settings) + ), + system_database_engine=( + catalog.engine + if settings.mode == ApplicationMode.server + and catalog is not None + else None + ), application_version=workflow_application_version(), before_access=before_access, health_check=health_check, stop_executor=stop_executor, ), - read_planner=LocalReadJobPlanner(layout=settings.layout), + read_planner=( + LocalReadJobPlanner( + layout=settings.layout, + index=LocalIndexReader( + settings.layout, + chroma_server_url=settings.chroma_server_url, + snapshot_repository=snapshots, + ), + ) + if include_read_planner + else None + ), ) @@ -235,11 +328,23 @@ def create_http_application( catalog=components.catalog, store=components.artifact_store, ), - index_status=LocalSnapshotRepository( - active_settings.layout.indexes - ).status, + index_status=components.snapshots.status, + ) + jobs = create_job_service( + active_settings, + catalog=components.catalog, + snapshots=components.snapshots, + ) + uploads = ( + RemoteUploadService( + settings=active_settings, + catalog=components.catalog, + media=components.media, + jobs=jobs, + ) + if active_settings.mode == ApplicationMode.server + else None ) - jobs = create_job_service(active_settings) authenticator = create_authenticator(active_settings) return HttpApplicationContext( application=application, @@ -252,9 +357,47 @@ def create_http_application( authenticator=authenticator, authorization=AuthorizationPolicy(), settings=active_settings, + catalog=components.catalog, + uploads=uploads, ) +def create_upload_hook_context( + settings: VidXPSettings | None = None, +) -> UploadHookContext: + active_settings = settings or VidXPSettings() + active_settings.validate_http_server() + if active_settings.mode != ApplicationMode.server: + raise ValueError("The tusd hook service requires server mode.") + catalog = SQLCatalog( + workflow_database_url(active_settings), + initialize=False, + ) + try: + jobs = create_job_service( + active_settings, + catalog=catalog, + include_read_planner=False, + ) + uploads = RemoteUploadService( + settings=active_settings, + catalog=catalog, + media=None, + jobs=jobs, + ) + return UploadHookContext( + jobs=jobs, + authenticator=create_authenticator(active_settings), + authorization=AuthorizationPolicy(), + settings=active_settings, + catalog=catalog, + uploads=uploads, + ) + except Exception: + catalog.close() + raise + + def create_local_application( *, registry_path: str | Path | None = None, diff --git a/src/vidxp/core/identifiers.py b/src/vidxp/core/identifiers.py index 779e008..fc4f8a0 100644 --- a/src/vidxp/core/identifiers.py +++ b/src/vidxp/core/identifiers.py @@ -78,3 +78,4 @@ def _require_workflow_uuid(value: str) -> str: IndexSnapshotId: TypeAlias = Uuid4Hex JobId: TypeAlias = WorkflowUuid ArtifactId: TypeAlias = Uuid4Hex +UploadIntentId: TypeAlias = Uuid4Hex diff --git a/src/vidxp/core/storage.py b/src/vidxp/core/storage.py index f37260c..d145815 100644 --- a/src/vidxp/core/storage.py +++ b/src/vidxp/core/storage.py @@ -1,7 +1,8 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Iterable, Mapping +from typing import Any, Callable, Iterable, Mapping, TypeVar +from urllib.parse import urlsplit from vidxp.core.contracts import ( CancellationToken, @@ -10,6 +11,12 @@ batched, ) +_T = TypeVar("_T") + + +class IndexStorageUnavailableError(RuntimeError): + """The configured remote vector store could not serve an operation.""" + def metadata_filter( config: IndexConfig, @@ -55,27 +62,68 @@ def _generation_ids( return tuple(dict.fromkeys(values)) -def _client_for_path(path: str): - import chromadb +class ChromaClientFactory: + def __init__(self, server_url: str | None = None) -> None: + self.server_url = server_url + + @property + def remote(self) -> bool: + return self.server_url is not None + + def create(self, path: Path): + import chromadb - return chromadb.PersistentClient(path=path) + if self.server_url is None: + return chromadb.PersistentClient(path=str(path.resolve())) + parsed = urlsplit(self.server_url) + return chromadb.HttpClient( + host=parsed.hostname or "", + port=parsed.port or (443 if parsed.scheme == "https" else 80), + ssl=parsed.scheme == "https", + ) + + def heartbeat(self) -> int: + client = self.create(Path(".")) + try: + return int(client.heartbeat()) + finally: + close = getattr(client, "close", None) + if close is not None: + close() class IndexStorage: - def __init__(self, config: IndexConfig, *, create: bool = True): + def __init__( + self, + config: IndexConfig, + *, + create: bool = True, + client_factory: ChromaClientFactory | None = None, + ): self.config = config self.path = config.index_directory self._create = create - if create: + self._client_factory = client_factory or ChromaClientFactory() + if create and not self._client_factory.remote: self.path.mkdir(parents=True, exist_ok=True) elif ( - not self.path.is_dir() - or not (self.path / "chroma.sqlite3").is_file() + not self._client_factory.remote + and ( + not self.path.is_dir() + or not (self.path / "chroma.sqlite3").is_file() + ) ): raise FileNotFoundError( f"The committed Chroma store is missing at {self.path}." ) - self.client = _client_for_path(str(self.path.resolve())) + try: + self.client = self._client_factory.create(self.path) + except Exception as exc: + if self._client_factory.remote: + raise IndexStorageUnavailableError( + "The remote Chroma service is unavailable." + ) from exc + raise self._names = dict(config.collection_names) self._collections: dict[str, Any] = {} @@ -99,17 +147,26 @@ def collection(self, modality: str): except KeyError as exc: raise ValueError(f"Unsupported collection modality: {modality}") from exc if self._create: - collection = self.client.get_or_create_collection( + collection = self._call( + self.client.get_or_create_collection, name=name, metadata={"hnsw:space": self.config.vector_distance}, ) else: + from chromadb.errors import NotFoundError + try: collection = self.client.get_collection(name=name) - except Exception as exc: + except NotFoundError as exc: raise FileNotFoundError( f"Committed Chroma collection {name!r} is missing." ) from exc + except Exception as exc: + if self._client_factory.remote: + raise IndexStorageUnavailableError( + "The remote Chroma service is unavailable." + ) from exc + raise configuration = getattr(collection, "configuration", {}) or {} actual_distance = (configuration.get("hnsw") or {}).get("space") if ( @@ -123,20 +180,31 @@ def collection(self, modality: str): self._collections[modality] = collection return collection + def _call(self, operation: Callable[..., _T], /, *args, **kwargs) -> _T: + try: + return operation(*args, **kwargs) + except Exception as exc: + if self._client_factory.remote: + raise IndexStorageUnavailableError( + "The remote Chroma service is unavailable." + ) from exc + raise + def clear(self, modalities: Iterable[str] | None = None) -> None: selected = tuple(modalities or self._names) existing = { getattr(collection, "name", collection) - for collection in self.client.list_collections() + for collection in self._call(self.client.list_collections) } for modality in selected: name = self._names[modality] if name in existing: - self.client.delete_collection(name) + self._call(self.client.delete_collection, name) self._collections.pop(modality, None) def delete_video(self, modality: str, video_id: str) -> None: - self.collection(modality).delete( + self._call( + self.collection(modality).delete, where=metadata_filter(self.config, video_id=video_id), ) @@ -147,7 +215,8 @@ def delete_records( video_id: str, filters: Mapping[str, Any] | None = None, ) -> None: - self.collection(modality).delete( + self._call( + self.collection(modality).delete, where=metadata_filter( self.config, video_id=video_id, @@ -170,7 +239,7 @@ def delete_generation( generation_ids=(generation_id,), ) for modality in selected: - self.collection(modality).delete(where=where) + self._call(self.collection(modality).delete, where=where) def upsert( self, @@ -195,7 +264,7 @@ def upsert( } if any(record.document is not None for record in group): options["documents"] = [record.document or "" for record in group] - collection.upsert(**options) + self._call(collection.upsert, **options) stored += len(group) return stored @@ -226,7 +295,7 @@ def query( extra=filters, ), } - result = self.collection(modality).query(**options) + result = self._call(self.collection(modality).query, **options) ids = (result.get("ids") or [[]])[0] metadatas = (result.get("metadatas") or [[]])[0] distances = (result.get("distances") or [[]])[0] @@ -260,7 +329,8 @@ def records( raise ValueError("limit must be positive") if offset < 0: raise ValueError("offset must be nonnegative") - result = self.collection(modality).get( + result = self._call( + self.collection(modality).get, where=metadata_filter( self.config, video_id=video_id, @@ -288,7 +358,8 @@ def count_records( selected_generations = _generation_ids(generation_ids) if selected_generations == (): return 0 - result = self.collection(modality).get( + result = self._call( + self.collection(modality).get, where=metadata_filter( self.config, video_id=video_id, @@ -300,7 +371,7 @@ def count_records( return len(result.get("ids") or ()) def size_bytes(self) -> int: - return directory_size(self.path) + return 0 if self._client_factory.remote else directory_size(self.path) class SnapshotScopedIndexStore: diff --git a/src/vidxp/core/uploads.py b/src/vidxp/core/uploads.py new file mode 100644 index 0000000..248acb9 --- /dev/null +++ b/src/vidxp/core/uploads.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from enum import StrEnum + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator + +from vidxp.core.identifiers import ( + JobId, + MediaId, + MimeType, + RepositoryId, + Uuid4Hex, +) +from vidxp.core.media import validate_display_filename + + +class UploadState(StrEnum): + pending = "pending" + accepted = "accepted" + processing = "processing" + ready = "ready" + failed = "failed" + expired = "expired" + + +class UploadIntentRecord(BaseModel): + """Authoritative upload state; tus upload identity stays internal.""" + + model_config = ConfigDict( + extra="forbid", + frozen=True, + allow_inf_nan=False, + ) + + intent_id: Uuid4Hex + request_key: str = Field(pattern=r"^[0-9a-f]{64}$") + repository_id: RepositoryId + owner_subject: str = Field(min_length=1, max_length=255) + original_filename: str = Field(min_length=1, max_length=255) + byte_size: int = Field(gt=0) + declared_mime_type: MimeType | None = None + state: UploadState + created_at: AwareDatetime + expires_at: AwareDatetime + upload_id: str | None = Field( + default=None, + min_length=1, + max_length=255, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._~-]*$", + ) + job_id: JobId | None = None + media_id: MediaId | None = None + + @model_validator(mode="after") + def _validate_state(self) -> "UploadIntentRecord": + validate_display_filename(self.original_filename) + if self.expires_at <= self.created_at: + raise ValueError("upload expiry must follow creation") + if self.state == UploadState.pending and self.upload_id is not None: + raise ValueError("pending uploads cannot have an upload identifier") + if self.state in { + UploadState.accepted, + UploadState.processing, + UploadState.failed, + } and self.upload_id is None: + raise ValueError(f"{self.state} uploads require an upload identifier") + if self.state in { + UploadState.processing, + UploadState.failed, + UploadState.ready, + } and self.job_id is None: + raise ValueError(f"{self.state} uploads require a job identifier") + if self.state in { + UploadState.pending, + UploadState.accepted, + } and self.job_id is not None: + raise ValueError(f"{self.state} uploads cannot have a job identifier") + if self.state == UploadState.ready and self.media_id is None: + raise ValueError("ready uploads require a media identifier") + if self.media_id is not None and self.state != UploadState.ready: + raise ValueError("only ready uploads may reference media") + return self diff --git a/src/vidxp/database_cli.py b/src/vidxp/database_cli.py new file mode 100644 index 0000000..c9626b3 --- /dev/null +++ b/src/vidxp/database_cli.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import argparse +from importlib.resources import as_file, files +from typing import Sequence + +from alembic import command +from alembic.config import Config + +from vidxp.settings import ApplicationMode, VidXPSettings +from vidxp.workflow_runtime import workflow_database_url + + +def upgrade_database(database_url: str) -> None: + migration_root = files("vidxp.migrations") + with as_file(migration_root) as path: + config = Config() + config.set_main_option("script_location", str(path)) + config.set_main_option( + "sqlalchemy.url", + database_url.replace("%", "%%"), + ) + command.upgrade(config, "head") + + +def main(arguments: Sequence[str] | None = None) -> None: + parser = argparse.ArgumentParser( + description="Apply VidXP relational database migrations." + ) + parser.add_argument("--database-url") + options = parser.parse_args(arguments) + overrides = ( + {"database_url": options.database_url} + if options.database_url is not None + else {} + ) + settings = VidXPSettings( + mode=ApplicationMode.server, + runtime_backend="cpu", + **overrides, + ) + upgrade_database(workflow_database_url(settings)) + + +if __name__ == "__main__": + main() diff --git a/src/vidxp/health_cli.py b/src/vidxp/health_cli.py new file mode 100644 index 0000000..f2cff73 --- /dev/null +++ b/src/vidxp/health_cli.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import argparse +from collections.abc import Sequence +from time import monotonic, sleep +from urllib.request import urlopen + +from sqlalchemy import create_engine, select + +from vidxp.core.storage import ChromaClientFactory +from vidxp.settings import VidXPSettings +from vidxp.workflow_runtime import workflow_database_url + + +def main(arguments: Sequence[str] | None = None) -> None: + parser = argparse.ArgumentParser(description="Check VidXP dependencies.") + parser.add_argument("role", choices=("chroma", "database", "worker")) + options = parser.parse_args(arguments) + settings = VidXPSettings() + if options.role == "chroma": + if settings.chroma_server_url is None: + raise RuntimeError("Chroma readiness requires a server URL.") + deadline = monotonic() + 120 + endpoint = settings.chroma_server_url.rstrip("/") + "/api/v2/heartbeat" + while True: + try: + with urlopen(endpoint, timeout=3) as response: + if response.status == 200: + return + except OSError: + if monotonic() >= deadline: + raise + sleep(1) + engine = create_engine( + workflow_database_url(settings), + pool_pre_ping=True, + ) + try: + with engine.connect() as connection: + connection.execute(select(1)).scalar_one() + finally: + engine.dispose() + if options.role == "worker": + if settings.chroma_server_url is None: + raise RuntimeError("Worker readiness requires Chroma server mode.") + ChromaClientFactory(settings.chroma_server_url).heartbeat() + + +if __name__ == "__main__": + main() diff --git a/src/vidxp/hook_app.py b/src/vidxp/hook_app.py new file mode 100644 index 0000000..fbb6b5a --- /dev/null +++ b/src/vidxp/hook_app.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import asyncio +import logging +from contextlib import asynccontextmanager, suppress +from typing import AsyncIterator + +from fastapi import FastAPI +from fastapi import HTTPException + +from vidxp.composition import UploadHookContext, create_upload_hook_context +from vidxp.infrastructure.tusd_contracts import ( + TusdHookRequest, + TusdHookResponse, +) +from vidxp.settings import VidXPSettings +from vidxp.tusd_hooks import TusdHookService + +LOGGER = logging.getLogger(__name__) + + +def create_hook_app( + settings: VidXPSettings | None = None, + *, + context: UploadHookContext | None = None, +) -> FastAPI: + active_context = context or create_upload_hook_context(settings) + owns_context = context is None + hooks = TusdHookService( + uploads=active_context.uploads, + authenticator=active_context.authenticator, + authorization=active_context.authorization, + ) + recovery_task: asyncio.Task[None] | None = None + + async def recover() -> None: + interval = active_context.settings.upload_recovery_interval_seconds + while True: + try: + await asyncio.to_thread(active_context.uploads.reconcile) + except Exception: + LOGGER.exception("The resumable-upload recovery sweep failed.") + await asyncio.sleep(interval) + + @asynccontextmanager + async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + nonlocal recovery_task + recovery_task = asyncio.create_task(recover()) + try: + yield + finally: + recovery_task.cancel() + with suppress(asyncio.CancelledError): + await recovery_task + recovery_task = None + if owns_context: + active_context.close() + + app = FastAPI( + title="VidXP tusd hooks", + docs_url=None, + redoc_url=None, + openapi_url=None, + lifespan=lifespan, + ) + + @app.get("/health") + def health() -> dict[str, str]: + try: + if recovery_task is None or recovery_task.done(): + raise RuntimeError("The upload recovery task is not running.") + active_context.catalog.health() + except Exception as exc: + raise HTTPException( + status_code=503, + detail="The upload catalog is unavailable.", + ) from exc + return {"status": "ok"} + + @app.post("/hooks") + def handle(hook: TusdHookRequest) -> dict: + response: TusdHookResponse = hooks.handle(hook) + return response.wire() + + return app diff --git a/src/vidxp/hook_cli.py b/src/vidxp/hook_cli.py new file mode 100644 index 0000000..0041da2 --- /dev/null +++ b/src/vidxp/hook_cli.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import uvicorn + +from vidxp.hook_app import create_hook_app +from vidxp.settings import VidXPSettings + + +def main() -> None: + settings = VidXPSettings() + uvicorn.run( + create_hook_app(settings), + host=settings.http_bind_host, + port=settings.http_port, + access_log=False, + ) + + +if __name__ == "__main__": + main() diff --git a/src/vidxp/infrastructure/dbos_jobs.py b/src/vidxp/infrastructure/dbos_jobs.py index ecf4fd4..6c89fb8 100644 --- a/src/vidxp/infrastructure/dbos_jobs.py +++ b/src/vidxp/infrastructure/dbos_jobs.py @@ -24,6 +24,8 @@ JobRequest, JobState, ListJobsCommand, + MediaAsset, + MediaImportJobResult, PrepareModelsJobResult, PrepareModelsResult, SearchJobResult, @@ -99,13 +101,26 @@ class DBOSJobBackend: def __init__( self, *, - system_database_url: str, + system_database_url: str | None, application_version: str, + system_database_engine: Any | None = None, before_access: Callable[[], None] | None = None, health_check: Callable[[], None] | None = None, stop_executor: Callable[[], bool] | None = None, ) -> None: - self.client = DBOSClient(system_database_url=system_database_url) + self.client = DBOSClient( + system_database_url=system_database_url, + system_database_engine=system_database_engine, + dbos_system_schema=( + "dbos" + if system_database_engine is not None + or ( + system_database_url is not None + and not system_database_url.startswith("sqlite:///") + ) + else None + ), + ) self.application_version = application_version self.before_access = before_access self.health_check = health_check @@ -130,7 +145,52 @@ def submit( existing = self._status(identifier, load_input=True) if existing is not None: return self._idempotent_job(existing, request) - options: EnqueueOptions = { + self.client.enqueue( + self._enqueue_options( + request=request, + queue=queue, + identifier=identifier, + ), + request.model_dump(mode="json"), + ) + status = self._status(identifier, load_input=True) + if status is None: + raise RuntimeError("DBOS did not persist the submitted workflow.") + return self._idempotent_job(status, request) + + def enqueue_in_transaction( + self, + connection: Any, + request: JobRequest, + *, + queue: JobQueue, + job_id: str, + ) -> str: + """Enqueue through a caller-owned transaction on the DBOS database.""" + + self._prepare_access() + identifier = _job_id(job_id) + handle = self.client.enqueue_in_transaction( + connection, + self._enqueue_options( + request=request, + queue=queue, + identifier=identifier, + ), + request.model_dump(mode="json"), + ) + if handle.get_workflow_id() != identifier: + raise RuntimeError("DBOS returned an unexpected workflow identity.") + return identifier + + def _enqueue_options( + self, + *, + request: JobRequest, + queue: JobQueue, + identifier: str, + ) -> EnqueueOptions: + return { "workflow_name": WORKFLOW_NAMES[request.kind], "queue_name": QUEUE_NAMES[queue], "workflow_id": identifier, @@ -139,11 +199,6 @@ def submit( "class_name": WORKFLOW_CLASS_NAME, "instance_name": WORKFLOW_INSTANCE_NAME, } - self.client.enqueue(options, request.model_dump(mode="json")) - status = self._status(identifier, load_input=True) - if status is None: - raise RuntimeError("DBOS did not persist the submitted workflow.") - return self._idempotent_job(status, request) def get(self, job_id: str) -> Job | None: self._prepare_access() @@ -326,7 +381,11 @@ def _job(self, status: Any) -> Job: ) result = None if state == JobState.succeeded: - if kind == JobKind.index: + if kind == JobKind.media_import: + result = MediaImportJobResult( + result=MediaAsset.model_validate(status.output) + ) + elif kind == JobKind.index: result = IndexJobResult( result=IndexResult.model_validate(status.output) ) diff --git a/src/vidxp/infrastructure/dbos_workflows.py b/src/vidxp/infrastructure/dbos_workflows.py index 4ab3831..0e724ba 100644 --- a/src/vidxp/infrastructure/dbos_workflows.py +++ b/src/vidxp/infrastructure/dbos_workflows.py @@ -18,6 +18,7 @@ IndexJobRequest, JobKind, JobProgress, + MediaImportJobRequest, SnippetJobRequest, PrepareModelsJobRequest, SearchJobRequest, @@ -162,6 +163,34 @@ def execute() -> dict[str, Any]: return _step_boundary(execute) + @DBOS.step(name="vidxp.run_media_import.v1") + def run_media_import_step( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + def execute() -> dict[str, Any]: + request = MediaImportJobRequest.model_validate(payload) + _publish_progress( + { + "stage": "importing", + "message": "Validating and importing the completed upload.", + } + ) + result = self.application.import_completed_upload( + request.upload_id + ) + _publish_progress( + { + "stage": "complete", + "message": "The uploaded media is ready.", + "current": 1, + "total": 1, + } + ) + return result.model_dump(mode="json") + + return _step_boundary(execute) + @DBOS.step(name="vidxp.run_artifact.v1") def run_artifact_step(self, payload: dict[str, Any]) -> dict[str, Any]: def execute() -> dict[str, Any]: @@ -245,6 +274,13 @@ def execute() -> dict[str, Any]: def index_workflow(self, payload: dict[str, Any]) -> dict[str, Any]: return self.run_index_step(payload) + @DBOS.workflow(name=WORKFLOW_NAMES[JobKind.media_import]) + def media_import_workflow( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + return self.run_media_import_step(payload) + @DBOS.workflow(name=WORKFLOW_NAMES[JobKind.snippet]) def snippet_workflow(self, payload: dict[str, Any]) -> dict[str, Any]: return self.run_artifact_step(payload) diff --git a/src/vidxp/infrastructure/local_catalog.py b/src/vidxp/infrastructure/local_catalog.py index a9de483..b5cb42a 100644 --- a/src/vidxp/infrastructure/local_catalog.py +++ b/src/vidxp/infrastructure/local_catalog.py @@ -1,354 +1,47 @@ from __future__ import annotations -import sqlite3 -from contextlib import contextmanager from pathlib import Path -from typing import Iterator -from vidxp.core.artifacts import ArtifactRecord, ArtifactState -from vidxp.core.media import MediaRecord, utc_now +from sqlalchemy import Column, Integer, MetaData, Table, insert, select, update +from vidxp.infrastructure.sql_catalog import SQLCatalog -CATALOG_SCHEMA_VERSION = 2 +CATALOG_SCHEMA_VERSION = 3 +_local_metadata = MetaData() +catalog_metadata = Table( + "catalog_metadata", + _local_metadata, + Column("schema_version", Integer, nullable=False), +) -class LocalCatalog: - """Repository-scoped SQLite catalog for media and generated artifacts.""" +class LocalCatalog(SQLCatalog): + """Repository-scoped SQLite catalog using the shared SQL adapter.""" def __init__(self, database: Path) -> None: - self.database = database - self.database.parent.mkdir(parents=True, exist_ok=True) - self._initialize() - - def _connect(self) -> sqlite3.Connection: - connection = sqlite3.connect(self.database, timeout=30) - connection.execute("PRAGMA foreign_keys = ON") - connection.execute("PRAGMA busy_timeout = 30000") - return connection - - @contextmanager - def _session(self) -> Iterator[sqlite3.Connection]: - connection = self._connect() - try: - with connection: - yield connection - finally: - connection.close() - - def _initialize(self) -> None: - with self._session() as connection: - connection.executescript( - """ - CREATE TABLE IF NOT EXISTS catalog_metadata ( - schema_version INTEGER NOT NULL - ); - CREATE UNIQUE INDEX IF NOT EXISTS catalog_metadata_singleton - ON catalog_metadata((1)); - CREATE TABLE IF NOT EXISTS media ( - media_id TEXT PRIMARY KEY, - sha256 TEXT NOT NULL UNIQUE, - created_at TEXT NOT NULL, - payload TEXT NOT NULL - ); - CREATE TABLE IF NOT EXISTS artifacts ( - artifact_id TEXT PRIMARY KEY, - media_id TEXT NOT NULL, - created_at TEXT NOT NULL, - payload TEXT NOT NULL, - FOREIGN KEY(media_id) REFERENCES media(media_id) - ); - CREATE INDEX IF NOT EXISTS artifacts_media_id - ON artifacts(media_id); - CREATE TABLE IF NOT EXISTS artifact_requests ( - request_key TEXT PRIMARY KEY, - artifact_id TEXT NOT NULL UNIQUE, - FOREIGN KEY(artifact_id) REFERENCES artifacts(artifact_id) - ); - CREATE TABLE IF NOT EXISTS media_import_requests ( - request_key TEXT PRIMARY KEY, - request_fingerprint TEXT NOT NULL, - media_id TEXT, - FOREIGN KEY(media_id) REFERENCES media(media_id) - ); - """ - ) - row = connection.execute( - "SELECT schema_version FROM catalog_metadata" - ).fetchone() - if row is None: - connection.execute( - "INSERT OR IGNORE INTO catalog_metadata" - "(schema_version) VALUES (?)", - (CATALOG_SCHEMA_VERSION,), - ) - elif row[0] == 1: + database.parent.mkdir(parents=True, exist_ok=True) + super().__init__( + f"sqlite:///{database.resolve().as_posix()}", + initialize=True, + ) + _local_metadata.create_all(self.engine) + with self.transaction() as connection: + version = connection.execute( + select(catalog_metadata.c.schema_version) + ).scalar_one_or_none() + if version is None: connection.execute( - "UPDATE catalog_metadata SET schema_version = ?", - (CATALOG_SCHEMA_VERSION,), - ) - elif row[0] != CATALOG_SCHEMA_VERSION: - raise RuntimeError( - "The repository catalog schema is incompatible." - ) - - def get_media(self, media_id: str) -> MediaRecord | None: - with self._session() as connection: - row = connection.execute( - "SELECT payload FROM media WHERE media_id = ?", - (media_id,), - ).fetchone() - return None if row is None else MediaRecord.model_validate_json(row[0]) - - def get_media_by_checksum(self, sha256: str) -> MediaRecord | None: - with self._session() as connection: - row = connection.execute( - "SELECT payload FROM media WHERE sha256 = ?", - (sha256,), - ).fetchone() - return None if row is None else MediaRecord.model_validate_json(row[0]) - - def put_media(self, record: MediaRecord) -> MediaRecord: - with self._session() as connection: - connection.execute("BEGIN IMMEDIATE") - id_row = connection.execute( - "SELECT payload FROM media WHERE media_id = ?", - (record.media_id,), - ).fetchone() - if id_row is not None: - authoritative = MediaRecord.model_validate_json(id_row[0]) - if authoritative != record: - raise FileExistsError( - f"Media {record.media_id} already has another record." + insert(catalog_metadata).values( + schema_version=CATALOG_SCHEMA_VERSION ) - return authoritative - checksum_row = connection.execute( - "SELECT payload FROM media WHERE sha256 = ?", - (record.sha256,), - ).fetchone() - if checksum_row is not None: - return MediaRecord.model_validate_json(checksum_row[0]) - connection.execute( - """ - INSERT INTO media(media_id, sha256, created_at, payload) - VALUES (?, ?, ?, ?) - """, - ( - record.media_id, - record.sha256, - record.created_at.isoformat(), - record.model_dump_json(), - ), - ) - return record - - def list_media( - self, - *, - limit: int, - offset: int = 0, - ) -> tuple[MediaRecord, ...]: - if limit <= 0 or offset < 0: - raise ValueError("limit must be positive and offset nonnegative") - with self._session() as connection: - rows = connection.execute( - """ - SELECT payload FROM media - ORDER BY created_at, media_id - LIMIT ? OFFSET ? - """, - (limit, offset), - ).fetchall() - return tuple(MediaRecord.model_validate_json(row[0]) for row in rows) - - def count_media(self) -> int: - with self._session() as connection: - row = connection.execute("SELECT COUNT(*) FROM media").fetchone() - return int(row[0]) - - def reserve_media_import( - self, - request_key: str, - request_fingerprint: str, - ) -> MediaRecord | None: - with self._session() as connection: - connection.execute("BEGIN IMMEDIATE") - row = connection.execute( - """ - SELECT request_fingerprint, media_id - FROM media_import_requests - WHERE request_key = ? - """, - (request_key,), - ).fetchone() - if row is None: + ) + elif version in {1, 2}: connection.execute( - """ - INSERT INTO media_import_requests( - request_key, request_fingerprint + update(catalog_metadata).values( + schema_version=CATALOG_SCHEMA_VERSION ) - VALUES (?, ?) - """, - (request_key, request_fingerprint), - ) - return None - stored_fingerprint, media_id = row - if stored_fingerprint != request_fingerprint: - raise FileExistsError( - "The media import key is bound to different content." ) - if media_id is None: - return None - media = connection.execute( - "SELECT payload FROM media WHERE media_id = ?", - (media_id,), - ).fetchone() - if media is None: + elif version != CATALOG_SCHEMA_VERSION: raise RuntimeError( - "A completed media import references missing media." - ) - return MediaRecord.model_validate_json(media[0]) - - def complete_media_import( - self, - request_key: str, - request_fingerprint: str, - record: MediaRecord, - ) -> None: - with self._session() as connection: - connection.execute("BEGIN IMMEDIATE") - row = connection.execute( - """ - SELECT request_fingerprint, media_id - FROM media_import_requests - WHERE request_key = ? - """, - (request_key,), - ).fetchone() - if row is None or row[0] != request_fingerprint: - raise FileExistsError( - "The media import reservation does not match the content." - ) - if row[1] is not None and row[1] != record.media_id: - raise FileExistsError( - "The media import key is already completed." - ) - connection.execute( - """ - UPDATE media_import_requests - SET media_id = ? - WHERE request_key = ? - """, - (record.media_id, request_key), - ) - - def get_artifact(self, artifact_id: str) -> ArtifactRecord | None: - with self._session() as connection: - row = connection.execute( - "SELECT payload FROM artifacts WHERE artifact_id = ?", - (artifact_id,), - ).fetchone() - return ( - None - if row is None - else ArtifactRecord.model_validate_json(row[0]) - ) - - def get_artifact_by_request( - self, - request_key: str, - ) -> ArtifactRecord | None: - with self._session() as connection: - row = connection.execute( - """ - SELECT artifacts.payload - FROM artifact_requests - JOIN artifacts USING (artifact_id) - WHERE artifact_requests.request_key = ? - """, - (request_key,), - ).fetchone() - if row is None: - return None - record = ArtifactRecord.model_validate_json(row[0]) - if ( - record.state != ArtifactState.ready - or ( - record.expires_at is not None - and record.expires_at <= utc_now() - ) - ): - return None - return record - - def put_artifact(self, record: ArtifactRecord) -> ArtifactRecord: - with self._session() as connection: - connection.execute("BEGIN IMMEDIATE") - request_row = connection.execute( - """ - SELECT artifacts.payload - FROM artifact_requests - JOIN artifacts USING (artifact_id) - WHERE artifact_requests.request_key = ? - """, - (record.request_key,), - ).fetchone() - if request_row is not None: - existing = ArtifactRecord.model_validate_json(request_row[0]) - if ( - existing.state == ArtifactState.ready - and ( - existing.expires_at is None - or existing.expires_at > utc_now() - ) - ): - return existing - connection.execute( - "DELETE FROM artifact_requests WHERE request_key = ?", - (record.request_key,), + "The repository catalog schema is incompatible." ) - row = connection.execute( - "SELECT payload FROM artifacts WHERE artifact_id = ?", - (record.artifact_id,), - ).fetchone() - if row is not None: - existing = ArtifactRecord.model_validate_json(row[0]) - if existing != record: - raise FileExistsError( - f"Artifact {record.artifact_id} already exists." - ) - return existing - connection.execute( - """ - INSERT INTO artifacts( - artifact_id, media_id, created_at, payload - ) VALUES (?, ?, ?, ?) - """, - ( - record.artifact_id, - record.media_id, - record.created_at.isoformat(), - record.model_dump_json(), - ), - ) - connection.execute( - """ - INSERT INTO artifact_requests(request_key, artifact_id) - VALUES (?, ?) - """, - (record.request_key, record.artifact_id), - ) - return record - - def invalidate_artifact_request( - self, - request_key: str, - artifact_id: str, - ) -> None: - with self._session() as connection: - connection.execute( - """ - DELETE FROM artifact_requests - WHERE request_key = ? AND artifact_id = ? - """, - (request_key, artifact_id), - ) diff --git a/src/vidxp/infrastructure/local_index.py b/src/vidxp/infrastructure/local_index.py index 5739fad..36bdda2 100644 --- a/src/vidxp/infrastructure/local_index.py +++ b/src/vidxp/infrastructure/local_index.py @@ -20,7 +20,11 @@ from vidxp.core.indexing_common import ProgressCallback from vidxp.core.manifest import MANIFEST_FILE, ManifestStore from vidxp.core.runner import index_video -from vidxp.core.storage import IndexStorage, SnapshotScopedIndexStore +from vidxp.core.storage import ( + ChromaClientFactory, + IndexStorage, + SnapshotScopedIndexStore, +) from vidxp.infrastructure.local_snapshots import LocalSnapshotRepository from vidxp.repository_layout import RepositoryLayout from vidxp.runtime import ModelRuntime @@ -49,9 +53,18 @@ class LocalIndexReader: """Model-free access to immutable local index snapshots.""" - def __init__(self, layout: RepositoryLayout) -> None: + def __init__( + self, + layout: RepositoryLayout, + *, + chroma_server_url: str | None = None, + snapshot_repository: LocalSnapshotRepository | None = None, + ) -> None: self.layout = layout - self.repository = LocalSnapshotRepository(layout.indexes) + self.repository = snapshot_repository or LocalSnapshotRepository( + layout.indexes + ) + self.chroma_clients = ChromaClientFactory(chroma_server_url) def _require_index_directory(self, index_directory: str | Path) -> None: if Path(index_directory) != self.layout.indexes: @@ -140,10 +153,13 @@ def _validate_snapshot_storage( "authoritative manifest." ) - @staticmethod - def _open_committed_storage(config: IndexConfig) -> IndexStorage: + def _open_committed_storage(self, config: IndexConfig) -> IndexStorage: try: - return IndexStorage(config, create=False) + return IndexStorage( + config, + create=False, + client_factory=self.chroma_clients, + ) except FileNotFoundError as exc: raise IndexSchemaError( "The committed Chroma store is missing." @@ -156,8 +172,15 @@ def __init__( registry: CapabilityRegistry, runtime: ModelRuntime, layout: RepositoryLayout, + *, + chroma_server_url: str | None = None, + snapshot_repository: LocalSnapshotRepository | None = None, ) -> None: - super().__init__(layout) + super().__init__( + layout, + chroma_server_url=chroma_server_url, + snapshot_repository=snapshot_repository, + ) self.registry = registry self.runtime = runtime @@ -264,6 +287,7 @@ def create( with IndexStorage( build_config, create=False, + client_factory=self.chroma_clients, ) as storage: recovered_counts = ( self._validate_generation_records( @@ -302,6 +326,7 @@ def create( self._cleanup_abandoned_generations( repository, build_config, + client_factory=self.chroma_clients, ) generation_validated = False try: @@ -310,7 +335,10 @@ def create( registry=self.registry, runtime=self.runtime, ) - with IndexStorage(build_config) as storage: + with IndexStorage( + build_config, + client_factory=self.chroma_clients, + ) as storage: index_video( str(path), progress_callback=progress, @@ -397,17 +425,22 @@ def _validate_generation_records( ) return counts - @classmethod + @staticmethod def _cleanup_abandoned_generations( - cls, repository: LocalSnapshotRepository, config: IndexConfig, + *, + client_factory: ChromaClientFactory | None = None, ) -> None: + clients = client_factory or ChromaClientFactory() completed: set[str] = set() incomplete: dict[str, Path] = {} if repository.generations.is_dir(): for path in repository.generations.iterdir(): - if not path.is_dir() or not cls._is_generation_id(path.name): + if ( + not path.is_dir() + or not LocalIndexBackend._is_generation_id(path.name) + ): continue manifest_path = path / MANIFEST_FILE try: @@ -435,8 +468,12 @@ def _cleanup_abandoned_generations( snapshot_sha256=None, ) database = repository.store / "chroma.sqlite3" - if database.is_file(): - with IndexStorage(cleanup_config, create=False) as storage: + if clients.remote or database.is_file(): + with IndexStorage( + cleanup_config, + create=False, + client_factory=clients, + ) as storage: stored_ids: set[str] = set() for modality in cleanup_config.enabled_modalities: try: @@ -447,7 +484,9 @@ def _cleanup_abandoned_generations( generation_id = record.get("generation_id") if ( isinstance(generation_id, str) - and cls._is_generation_id(generation_id) + and LocalIndexBackend._is_generation_id( + generation_id + ) ): stored_ids.add(generation_id) for generation_id in sorted( @@ -478,8 +517,8 @@ def _is_generation_id(value: str) -> bool: return False return identifier.version == 4 and identifier.hex == value - @staticmethod def _remove_uncommitted_generation( + self, repository: LocalSnapshotRepository, config: IndexConfig, *, @@ -506,7 +545,10 @@ def _remove_uncommitted_generation( if state == "complete" and preserve_completed: return try: - with IndexStorage(config) as storage: + with IndexStorage( + config, + client_factory=self.chroma_clients, + ) as storage: storage.delete_generation(generation_id) except Exception: # Cleanup is best-effort and must never replace the build failure. diff --git a/src/vidxp/infrastructure/sql_catalog.py b/src/vidxp/infrastructure/sql_catalog.py new file mode 100644 index 0000000..97655fc --- /dev/null +++ b/src/vidxp/infrastructure/sql_catalog.py @@ -0,0 +1,719 @@ +from __future__ import annotations + +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from datetime import datetime +import json +from typing import Any + +from sqlalchemy import Engine, create_engine, delete, event, func, insert, select, update +from sqlalchemy.engine import Connection +from sqlalchemy.exc import IntegrityError +from sqlalchemy.pool import NullPool + +from vidxp.core.artifacts import ArtifactRecord, ArtifactState +from vidxp.core.media import MediaRecord, utc_now +from vidxp.core.uploads import UploadIntentRecord, UploadState +from vidxp.infrastructure.sql_tables import ( + artifact_requests, + artifacts, + media, + media_import_requests, + metadata, + upload_intents, + upload_quotas, +) + +_RESERVED_UPLOAD_STATES = { + UploadState.pending.value, + UploadState.accepted.value, + UploadState.processing.value, + UploadState.failed.value, +} + + +class UploadQuotaExceededError(RuntimeError): + """Raised when an atomic upload reservation exceeds its quota.""" + + +def _payload(value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + raise RuntimeError("The relational catalog payload is invalid.") + return value + + +def _record(model: Any, value: Any) -> Any: + return model.model_validate_json( + json.dumps(_payload(value), separators=(",", ":")) + ) + + +def _upload_record(row: Any) -> UploadIntentRecord: + return UploadIntentRecord( + intent_id=row.intent_id, + request_key=row.request_key, + repository_id=row.repository_id, + owner_subject=row.owner_subject, + original_filename=row.original_filename, + byte_size=row.byte_size, + declared_mime_type=row.declared_mime_type, + state=row.state, + created_at=datetime.fromisoformat(row.created_at), + expires_at=datetime.fromisoformat(row.expires_at), + upload_id=row.upload_id, + job_id=row.job_id, + media_id=row.media_id, + ) + + +class SQLCatalog: + """SQLAlchemy Core catalog shared by SQLite and PostgreSQL adapters.""" + + def __init__( + self, + database_url: str, + *, + initialize: bool = False, + engine: Engine | None = None, + ) -> None: + sqlite = database_url.startswith("sqlite:") + self.engine = engine or create_engine( + database_url, + pool_pre_ping=True, + **( + { + "poolclass": NullPool, + "connect_args": {"timeout": 30}, + } + if sqlite + else {} + ), + ) + self._owns_engine = engine is None + if sqlite and engine is None: + event.listen(self.engine, "connect", self._configure_sqlite) + if initialize: + metadata.create_all(self.engine) + + def close(self) -> None: + if self._owns_engine: + self.engine.dispose() + + def health(self) -> None: + with self.engine.connect() as connection: + connection.execute(select(1)).scalar_one() + + @contextmanager + def transaction(self) -> Iterator[Connection]: + with self._write_transaction() as connection: + yield connection + + @staticmethod + def _configure_sqlite(dbapi_connection: Any, _record: Any) -> None: + cursor = dbapi_connection.cursor() + try: + cursor.execute("PRAGMA foreign_keys = ON") + cursor.execute("PRAGMA busy_timeout = 30000") + finally: + cursor.close() + + @contextmanager + def _write_transaction(self) -> Iterator[Connection]: + with self.engine.connect() as connection: + if self.engine.dialect.name == "sqlite": + connection.exec_driver_sql("BEGIN IMMEDIATE") + try: + yield connection + except BaseException: + connection.rollback() + raise + else: + connection.commit() + else: + with connection.begin(): + yield connection + + def get_media(self, media_id: str) -> MediaRecord | None: + with self.engine.connect() as connection: + payload = connection.execute( + select(media.c.payload).where(media.c.media_id == media_id) + ).scalar_one_or_none() + return ( + None + if payload is None + else _record(MediaRecord, payload) + ) + + def get_media_by_checksum(self, sha256: str) -> MediaRecord | None: + with self.engine.connect() as connection: + payload = connection.execute( + select(media.c.payload).where(media.c.sha256 == sha256) + ).scalar_one_or_none() + return ( + None + if payload is None + else _record(MediaRecord, payload) + ) + + def put_media(self, record: MediaRecord) -> MediaRecord: + values = { + "media_id": record.media_id, + "sha256": record.sha256, + "created_at": record.created_at.isoformat(), + "payload": record.model_dump(mode="json"), + } + with self._write_transaction() as connection: + existing = self._media_by_id(connection, record.media_id) + if existing is not None: + if existing != record: + raise FileExistsError( + f"Media {record.media_id} already has another record." + ) + return existing + checksum_match = self._media_by_checksum(connection, record.sha256) + if checksum_match is not None: + return checksum_match + try: + with connection.begin_nested(): + connection.execute(insert(media).values(**values)) + except IntegrityError: + authoritative = self._media_by_checksum( + connection, + record.sha256, + ) + if authoritative is not None: + return authoritative + existing = self._media_by_id(connection, record.media_id) + if existing == record: + return existing + raise + return record + + @staticmethod + def _media_by_id( + connection: Connection, + media_id: str, + ) -> MediaRecord | None: + payload = connection.execute( + select(media.c.payload).where(media.c.media_id == media_id) + ).scalar_one_or_none() + return ( + None + if payload is None + else _record(MediaRecord, payload) + ) + + @staticmethod + def _media_by_checksum( + connection: Connection, + sha256: str, + ) -> MediaRecord | None: + payload = connection.execute( + select(media.c.payload).where(media.c.sha256 == sha256) + ).scalar_one_or_none() + return ( + None + if payload is None + else _record(MediaRecord, payload) + ) + + def list_media( + self, + *, + limit: int, + offset: int = 0, + ) -> tuple[MediaRecord, ...]: + if limit <= 0 or offset < 0: + raise ValueError("limit must be positive and offset nonnegative") + with self.engine.connect() as connection: + payloads = connection.execute( + select(media.c.payload) + .order_by(media.c.created_at, media.c.media_id) + .limit(limit) + .offset(offset) + ).scalars() + return tuple( + _record(MediaRecord, payload) + for payload in payloads + ) + + def count_media(self) -> int: + with self.engine.connect() as connection: + return int( + connection.execute( + select(func.count()).select_from(media) + ).scalar_one() + ) + + def reserve_media_import( + self, + request_key: str, + request_fingerprint: str, + ) -> MediaRecord | None: + with self._write_transaction() as connection: + row = connection.execute( + select( + media_import_requests.c.request_fingerprint, + media_import_requests.c.media_id, + ) + .where(media_import_requests.c.request_key == request_key) + .with_for_update() + ).one_or_none() + if row is None: + try: + with connection.begin_nested(): + connection.execute( + insert(media_import_requests).values( + request_key=request_key, + request_fingerprint=request_fingerprint, + ) + ) + return None + except IntegrityError: + row = connection.execute( + select( + media_import_requests.c.request_fingerprint, + media_import_requests.c.media_id, + ) + .where( + media_import_requests.c.request_key == request_key + ) + .with_for_update() + ).one() + if row.request_fingerprint != request_fingerprint: + raise FileExistsError( + "The media import key is bound to different content." + ) + if row.media_id is None: + return None + stored = self._media_by_id(connection, row.media_id) + if stored is None: + raise RuntimeError( + "A completed media import references missing media." + ) + return stored + + def complete_media_import( + self, + request_key: str, + request_fingerprint: str, + record: MediaRecord, + ) -> None: + with self._write_transaction() as connection: + row = connection.execute( + select( + media_import_requests.c.request_fingerprint, + media_import_requests.c.media_id, + ) + .where(media_import_requests.c.request_key == request_key) + .with_for_update() + ).one_or_none() + if row is None or row.request_fingerprint != request_fingerprint: + raise FileExistsError( + "The media import reservation does not match the content." + ) + if row.media_id is not None and row.media_id != record.media_id: + raise FileExistsError( + "The media import key is already completed." + ) + connection.execute( + update(media_import_requests) + .where(media_import_requests.c.request_key == request_key) + .values(media_id=record.media_id) + ) + + def get_artifact(self, artifact_id: str) -> ArtifactRecord | None: + with self.engine.connect() as connection: + payload = connection.execute( + select(artifacts.c.payload).where( + artifacts.c.artifact_id == artifact_id + ) + ).scalar_one_or_none() + return ( + None + if payload is None + else _record(ArtifactRecord, payload) + ) + + def get_artifact_by_request( + self, + request_key: str, + ) -> ArtifactRecord | None: + with self.engine.connect() as connection: + payload = connection.execute( + select(artifacts.c.payload) + .select_from( + artifact_requests.join( + artifacts, + artifact_requests.c.artifact_id + == artifacts.c.artifact_id, + ) + ) + .where(artifact_requests.c.request_key == request_key) + ).scalar_one_or_none() + if payload is None: + return None + record = _record(ArtifactRecord, payload) + if ( + record.state != ArtifactState.ready + or ( + record.expires_at is not None + and record.expires_at <= utc_now() + ) + ): + return None + return record + + def put_artifact(self, record: ArtifactRecord) -> ArtifactRecord: + with self._write_transaction() as connection: + request_match = self._artifact_by_request( + connection, + record.request_key, + ) + if request_match is not None: + if ( + request_match.state == ArtifactState.ready + and ( + request_match.expires_at is None + or request_match.expires_at > utc_now() + ) + ): + return request_match + connection.execute( + delete(artifact_requests).where( + artifact_requests.c.request_key == record.request_key + ) + ) + existing = self._artifact_by_id( + connection, + record.artifact_id, + ) + if existing is not None: + if existing != record: + raise FileExistsError( + f"Artifact {record.artifact_id} already exists." + ) + return existing + connection.execute( + insert(artifacts).values( + artifact_id=record.artifact_id, + media_id=record.media_id, + created_at=record.created_at.isoformat(), + payload=record.model_dump(mode="json"), + ) + ) + connection.execute( + insert(artifact_requests).values( + request_key=record.request_key, + artifact_id=record.artifact_id, + ) + ) + return record + + @staticmethod + def _artifact_by_id( + connection: Connection, + artifact_id: str, + ) -> ArtifactRecord | None: + payload = connection.execute( + select(artifacts.c.payload).where( + artifacts.c.artifact_id == artifact_id + ) + ).scalar_one_or_none() + return ( + None + if payload is None + else _record(ArtifactRecord, payload) + ) + + @staticmethod + def _artifact_by_request( + connection: Connection, + request_key: str, + ) -> ArtifactRecord | None: + payload = connection.execute( + select(artifacts.c.payload) + .select_from( + artifact_requests.join( + artifacts, + artifact_requests.c.artifact_id == artifacts.c.artifact_id, + ) + ) + .where(artifact_requests.c.request_key == request_key) + ).scalar_one_or_none() + return ( + None + if payload is None + else _record(ArtifactRecord, payload) + ) + + def invalidate_artifact_request( + self, + request_key: str, + artifact_id: str, + ) -> None: + with self._write_transaction() as connection: + connection.execute( + delete(artifact_requests).where( + artifact_requests.c.request_key == request_key, + artifact_requests.c.artifact_id == artifact_id, + ) + ) + + def create_upload_intent( + self, + record: UploadIntentRecord, + *, + quota_limit: int, + ) -> UploadIntentRecord: + with self._write_transaction() as connection: + self._reserve_upload_quota( + connection, + repository_id=record.repository_id, + owner_subject=record.owner_subject, + byte_size=record.byte_size, + quota_limit=quota_limit, + ) + connection.execute( + insert(upload_intents).values( + intent_id=record.intent_id, + request_key=record.request_key, + repository_id=record.repository_id, + owner_subject=record.owner_subject, + original_filename=record.original_filename, + byte_size=record.byte_size, + declared_mime_type=record.declared_mime_type, + state=record.state.value, + created_at=record.created_at.isoformat(), + expires_at=record.expires_at.isoformat(), + upload_id=record.upload_id, + job_id=record.job_id, + media_id=record.media_id, + ) + ) + return record + + @staticmethod + def _reserve_upload_quota( + connection: Connection, + *, + repository_id: str, + owner_subject: str, + byte_size: int, + quota_limit: int, + ) -> None: + identity = ( + upload_quotas.c.repository_id == repository_id, + upload_quotas.c.owner_subject == owner_subject, + ) + row = connection.execute( + select(upload_quotas.c.reserved_bytes) + .where(*identity) + .with_for_update() + ).one_or_none() + if row is None: + try: + with connection.begin_nested(): + connection.execute( + insert(upload_quotas).values( + repository_id=repository_id, + owner_subject=owner_subject, + reserved_bytes=0, + ) + ) + except IntegrityError: + pass + row = connection.execute( + select(upload_quotas.c.reserved_bytes) + .where(*identity) + .with_for_update() + ).one() + reserved = int(row.reserved_bytes) + if reserved + byte_size > quota_limit: + raise UploadQuotaExceededError + connection.execute( + update(upload_quotas) + .where(*identity) + .values(reserved_bytes=reserved + byte_size) + ) + + def get_upload_intent_by_request( + self, + request_key: str, + ) -> UploadIntentRecord | None: + with self.engine.connect() as connection: + row = connection.execute( + select(upload_intents).where( + upload_intents.c.request_key == request_key + ) + ).one_or_none() + return None if row is None else _upload_record(row) + + def get_upload_intent_by_upload_id( + self, + upload_id: str, + *, + connection: Connection | None = None, + for_update: bool = False, + ) -> UploadIntentRecord | None: + statement = select(upload_intents).where( + upload_intents.c.upload_id == upload_id + ) + if for_update: + statement = statement.with_for_update() + if connection is not None: + row = connection.execute(statement).one_or_none() + else: + with self.engine.connect() as active: + row = active.execute(statement).one_or_none() + return None if row is None else _upload_record(row) + + def get_upload_intent( + self, + intent_id: str, + *, + connection: Connection | None = None, + for_update: bool = False, + ) -> UploadIntentRecord | None: + statement = select(upload_intents).where( + upload_intents.c.intent_id == intent_id + ) + if for_update: + statement = statement.with_for_update() + if connection is not None: + row = connection.execute(statement).one_or_none() + else: + with self.engine.connect() as active: + row = active.execute(statement).one_or_none() + return None if row is None else _upload_record(row) + + def update_upload( + self, + intent_id: str, + *, + state: UploadState, + connection: Connection, + upload_id: str | None = None, + job_id: str | None = None, + media_id: str | None = None, + clear_upload_id: bool = False, + expected_states: set[UploadState] | None = None, + ) -> bool: + current = connection.execute( + select( + upload_intents.c.repository_id, + upload_intents.c.owner_subject, + upload_intents.c.byte_size, + upload_intents.c.state, + ) + .where(upload_intents.c.intent_id == intent_id) + .with_for_update() + ).one_or_none() + if current is None: + raise RuntimeError("The upload intent update was lost.") + if ( + expected_states is not None + and UploadState(current.state) not in expected_states + ): + return False + values: dict[str, Any] = {"state": state.value} + if clear_upload_id: + values["upload_id"] = None + elif upload_id is not None: + values["upload_id"] = upload_id + if job_id is not None: + values["job_id"] = job_id + if media_id is not None: + values["media_id"] = media_id + result = connection.execute( + update(upload_intents) + .where(upload_intents.c.intent_id == intent_id) + .values(**values) + ) + if result.rowcount != 1: + raise RuntimeError("The upload intent update was lost.") + if ( + current.state in _RESERVED_UPLOAD_STATES + and state.value not in _RESERVED_UPLOAD_STATES + ): + quota = connection.execute( + select(upload_quotas.c.reserved_bytes) + .where( + upload_quotas.c.repository_id + == current.repository_id, + upload_quotas.c.owner_subject == current.owner_subject, + ) + .with_for_update() + ).scalar_one() + connection.execute( + update(upload_quotas) + .where( + upload_quotas.c.repository_id + == current.repository_id, + upload_quotas.c.owner_subject == current.owner_subject, + ) + .values( + reserved_bytes=max(0, int(quota) - current.byte_size) + ) + ) + return True + + def expired_uploads( + self, + *, + now: datetime, + ) -> tuple[UploadIntentRecord, ...]: + with self.engine.connect() as connection: + rows = connection.execute( + select(upload_intents).where( + upload_intents.c.expires_at <= now.isoformat(), + upload_intents.c.state.in_( + ( + UploadState.pending.value, + UploadState.accepted.value, + UploadState.failed.value, + ) + ), + ) + ) + return tuple(_upload_record(row) for row in rows) + + def recoverable_uploads(self) -> tuple[UploadIntentRecord, ...]: + with self.engine.connect() as connection: + rows = connection.execute( + select(upload_intents).where( + upload_intents.c.state == UploadState.accepted.value + ) + ) + return tuple(_upload_record(row) for row in rows) + + def cleanup_uploads(self) -> tuple[UploadIntentRecord, ...]: + with self.engine.connect() as connection: + rows = connection.execute( + select(upload_intents).where( + upload_intents.c.upload_id.is_not(None), + upload_intents.c.state.in_( + ( + UploadState.ready.value, + UploadState.expired.value, + ) + ), + ) + ) + return tuple(_upload_record(row) for row in rows) + + def processing_uploads(self) -> tuple[UploadIntentRecord, ...]: + with self.engine.connect() as connection: + rows = connection.execute( + select(upload_intents).where( + upload_intents.c.state == UploadState.processing.value + ) + ) + return tuple(_upload_record(row) for row in rows) + + def with_upload_transaction( + self, + operation: Callable[[Connection], Any], + ) -> Any: + with self._write_transaction() as connection: + return operation(connection) diff --git a/src/vidxp/infrastructure/sql_snapshots.py b/src/vidxp/infrastructure/sql_snapshots.py new file mode 100644 index 0000000..b576c3a --- /dev/null +++ b/src/vidxp/infrastructure/sql_snapshots.py @@ -0,0 +1,434 @@ +from __future__ import annotations + +import hashlib +import json +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterator +from uuid import uuid4 + +from sqlalchemy import Engine, insert, select, text, update +from sqlalchemy.engine import Connection +from sqlalchemy.exc import IntegrityError +from pydantic import ValidationError + +from vidxp.core.contracts import INDEX_SCHEMA_VERSION, IndexConfig, IndexSchemaError +from vidxp.core.generations import CompletedGenerationManifest +from vidxp.core.manifest import MANIFEST_FILE, sha256_file +from vidxp.core.snapshots import GenerationReference, IndexSnapshot +from vidxp.index_state import IndexNotReadyError, IndexingInProgressError +from vidxp.infrastructure.local_snapshots import LocalSnapshotRepository +from vidxp.infrastructure.sql_tables import ( + index_generations, + index_snapshots, + repositories, +) + + +def _checksum(snapshot: IndexSnapshot) -> str: + payload = json.dumps( + snapshot.model_dump(mode="json"), + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +class SQLSnapshotRepository(LocalSnapshotRepository): + """PostgreSQL-authoritative snapshots with shared generation manifests.""" + + def __init__( + self, + indexes: str | Path, + *, + engine: Engine, + repository_id: str, + ) -> None: + super().__init__(indexes) + self.engine = engine + self.repository_id = repository_id + + @contextmanager + def lease(self) -> Iterator[None]: + self.ensure_directories() + with self.engine.connect() as connection: + acquired = connection.execute( + text("SELECT pg_try_advisory_lock(hashtext(:identity))"), + {"identity": f"vidxp:index:{self.repository_id}"}, + ).scalar_one() + if not acquired: + raise IndexingInProgressError( + "An index mutation is already active for this repository." + ) + try: + yield + finally: + connection.execute( + text("SELECT pg_advisory_unlock(hashtext(:identity))"), + {"identity": f"vidxp:index:{self.repository_id}"}, + ) + + def mutation_in_progress(self) -> bool: + with self.engine.connect() as connection: + acquired = connection.execute( + text("SELECT pg_try_advisory_lock(hashtext(:identity))"), + {"identity": f"vidxp:index:{self.repository_id}"}, + ).scalar_one() + if acquired: + connection.execute( + text("SELECT pg_advisory_unlock(hashtext(:identity))"), + {"identity": f"vidxp:index:{self.repository_id}"}, + ) + return not bool(acquired) + + @staticmethod + def _ensure_repository(connection: Connection, repository_id: str) -> None: + if connection.execute( + select(repositories.c.repository_id).where( + repositories.c.repository_id == repository_id + ) + ).scalar_one_or_none() is not None: + return + try: + with connection.begin_nested(): + connection.execute( + insert(repositories).values(repository_id=repository_id) + ) + except IntegrityError: + pass + + def read_active(self, *, required: bool = False) -> IndexSnapshot | None: + with self.engine.connect() as connection: + row = connection.execute( + select( + repositories.c.active_snapshot_id, + repositories.c.active_snapshot_sha256, + ).where(repositories.c.repository_id == self.repository_id) + ).one_or_none() + if row is None or row.active_snapshot_id is None: + if required: + raise IndexNotReadyError( + "No active index snapshot was found. Index media first." + ) + return None + return self._read_snapshot( + connection, + row.active_snapshot_id, + expected_sha256=row.active_snapshot_sha256, + ) + + def read_snapshot( + self, + snapshot_id: str, + *, + expected_sha256: str | None = None, + ) -> IndexSnapshot: + with self.engine.connect() as connection: + return self._read_snapshot( + connection, + snapshot_id, + expected_sha256=expected_sha256, + ) + + def _read_snapshot( + self, + connection: Connection, + snapshot_id: str, + *, + expected_sha256: str | None, + ) -> IndexSnapshot: + row = connection.execute( + select( + index_snapshots.c.repository_id, + index_snapshots.c.sha256, + index_snapshots.c.payload, + ).where(index_snapshots.c.snapshot_id == snapshot_id) + ).one_or_none() + if row is None or row.repository_id != self.repository_id: + raise IndexSchemaError(f"Index snapshot {snapshot_id} is missing.") + if expected_sha256 is not None and row.sha256 != expected_sha256: + raise IndexSchemaError( + f"Index snapshot {snapshot_id} failed integrity validation." + ) + try: + snapshot = IndexSnapshot.model_validate_json( + json.dumps(row.payload, separators=(",", ":")) + ) + except ValueError as exc: + raise IndexSchemaError( + f"Index snapshot {snapshot_id} is invalid." + ) from exc + if snapshot.snapshot_id != snapshot_id or _checksum(snapshot) != row.sha256: + raise IndexSchemaError( + f"Index snapshot {snapshot_id} failed integrity validation." + ) + self._validate_generations(snapshot) + return snapshot + + def validate_generation( + self, + reference: GenerationReference, + ): + with self.engine.connect() as connection: + payload = connection.execute( + select(index_generations.c.payload).where( + index_generations.c.generation_id + == reference.generation_id, + index_generations.c.repository_id == self.repository_id, + ) + ).scalar_one_or_none() + if ( + payload is None + or GenerationReference.model_validate_json( + json.dumps(payload, separators=(",", ":")) + ) + != reference + ): + raise IndexSchemaError( + f"Generation {reference.generation_id} is not published." + ) + return super().validate_generation(reference) + + def generation_reference( + self, + *, + generation_id: str, + media_id: str, + ) -> GenerationReference: + manifest_path = self.generation_directory(generation_id) / MANIFEST_FILE + try: + manifest = CompletedGenerationManifest.model_validate_json( + manifest_path.read_text(encoding="utf-8") + ) + reference = GenerationReference( + generation_id=generation_id, + media_id=media_id, + manifest_sha256=sha256_file(manifest_path), + input_sha256=manifest.inputs[media_id].sha256, + config_fingerprint=manifest.config_fingerprint, + modalities=tuple( + manifest.configuration["enabled_modalities"] + ), + record_counts=dict(manifest.record_counts), + store_size_bytes_at_commit=( + manifest.store_size_bytes_at_commit + ), + ) + except (OSError, KeyError, TypeError, ValueError, ValidationError) as exc: + raise IndexSchemaError( + f"Completed generation {generation_id} has an invalid manifest." + ) from exc + LocalSnapshotRepository.validate_generation(self, reference) + return reference + + def publish_generation( + self, + reference: GenerationReference, + config: IndexConfig, + ) -> IndexSnapshot: + return self._publish( + replacement=reference, + remove_media_id=None, + config_fingerprint=reference.config_fingerprint, + configuration=self.snapshot_configuration(config), + ) + + def require_compatible_profile( + self, + *, + media_id: str, + config_fingerprint: str, + ) -> IndexSnapshot | None: + active = self.read_active() + if active is not None and ( + set(active.generations) - {media_id} + and active.config_fingerprint != config_fingerprint + ): + raise IndexSchemaError( + "All media in one active snapshot must use the same index " + "profile. Re-index or clear the other media first." + ) + return active + + def remove(self, media_id: str) -> bool: + active = self.read_active() + if active is None or media_id not in active.generations: + return False + self._publish( + replacement=None, + remove_media_id=media_id, + config_fingerprint=active.config_fingerprint, + configuration=dict(active.configuration), + ) + return True + + def clear(self) -> bool: + active = self.read_active() + if active is None or not active.generations: + return False + self._publish( + replacement=None, + remove_media_id="*", + config_fingerprint=active.config_fingerprint, + configuration=dict(active.configuration), + ) + return True + + def _publish( + self, + *, + replacement: GenerationReference | None, + remove_media_id: str | None, + config_fingerprint: str, + configuration: dict[str, Any], + ) -> IndexSnapshot: + with self.engine.begin() as connection: + self._ensure_repository(connection, self.repository_id) + repository = connection.execute( + select( + repositories.c.active_snapshot_id, + repositories.c.active_snapshot_sha256, + ) + .where(repositories.c.repository_id == self.repository_id) + .with_for_update() + ).one() + active = ( + None + if repository.active_snapshot_id is None + else self._read_snapshot( + connection, + repository.active_snapshot_id, + expected_sha256=repository.active_snapshot_sha256, + ) + ) + generations = dict(active.generations) if active is not None else {} + if remove_media_id == "*": + generations.clear() + elif remove_media_id is not None: + generations.pop(remove_media_id, None) + if replacement is not None: + existing = connection.execute( + select(index_generations.c.payload).where( + index_generations.c.generation_id + == replacement.generation_id + ) + ).scalar_one_or_none() + if existing is None: + connection.execute( + insert(index_generations).values( + generation_id=replacement.generation_id, + repository_id=self.repository_id, + media_id=replacement.media_id, + manifest_sha256=replacement.manifest_sha256, + payload=replacement.model_dump(mode="json"), + ) + ) + elif ( + GenerationReference.model_validate_json( + json.dumps(existing, separators=(",", ":")) + ) + != replacement + ): + raise IndexSchemaError( + "The generation identity is already published " + "with different metadata." + ) + other_media = set(generations) - {replacement.media_id} + if other_media and active is not None: + configuration = dict(active.configuration) + generations[replacement.media_id] = replacement + snapshot = IndexSnapshot( + snapshot_id=uuid4().hex, + created_at=datetime.now(timezone.utc), + config_fingerprint=config_fingerprint, + configuration=configuration, + generations=generations, + ) + checksum = _checksum(snapshot) + connection.execute( + insert(index_snapshots).values( + snapshot_id=snapshot.snapshot_id, + repository_id=self.repository_id, + created_at=snapshot.created_at.isoformat(), + sha256=checksum, + payload=snapshot.model_dump(mode="json"), + ) + ) + connection.execute( + update(repositories) + .where(repositories.c.repository_id == self.repository_id) + .values( + active_snapshot_id=snapshot.snapshot_id, + active_snapshot_sha256=checksum, + ) + ) + return snapshot + + def active_config( + self, + *, + device: str, + ) -> tuple[IndexConfig, IndexSnapshot]: + snapshot = self.read_active(required=True) + assert snapshot is not None + if not snapshot.generations: + raise IndexNotReadyError( + "The active index snapshot contains no media." + ) + return ( + self._config_for_snapshot( + snapshot, + snapshot_sha256=_checksum(snapshot), + device=device, + ), + snapshot, + ) + + def config_for_snapshot( + self, + snapshot_id: str, + *, + snapshot_sha256: str, + device: str, + ) -> IndexConfig: + snapshot = self.read_snapshot( + snapshot_id, + expected_sha256=snapshot_sha256, + ) + if not snapshot.generations: + raise IndexNotReadyError( + "The requested index snapshot contains no media." + ) + return self._config_for_snapshot( + snapshot, + snapshot_sha256=snapshot_sha256, + device=device, + ) + + def status(self) -> dict[str, Any] | None: + snapshot = self.read_active() + if snapshot is None: + return None + media_ids = sorted(snapshot.generations) + return { + "schema_version": 1, + "state": "ready" if media_ids else "empty", + "stage": "status", + "message": ( + f"The active snapshot contains {len(media_ids)} media item(s)." + if media_ids + else "The active index snapshot is empty." + ), + "updated_at": snapshot.created_at.isoformat(), + "summary": { + "index_schema_version": INDEX_SCHEMA_VERSION, + "snapshot_id": snapshot.snapshot_id, + "media_count": len(media_ids), + "media_ids": media_ids, + "modalities": tuple( + snapshot.configuration.get("enabled_modalities", ()) + ), + }, + } diff --git a/src/vidxp/infrastructure/sql_tables.py b/src/vidxp/infrastructure/sql_tables.py new file mode 100644 index 0000000..d3732a2 --- /dev/null +++ b/src/vidxp/infrastructure/sql_tables.py @@ -0,0 +1,161 @@ +from sqlalchemy import ( + BigInteger, + CheckConstraint, + Column, + ForeignKey, + Index, + JSON, + MetaData, + String, + Table, + Text, + UniqueConstraint, +) + + +metadata = MetaData() + +media = Table( + "media", + metadata, + Column("media_id", String(32), primary_key=True), + Column("sha256", String(64), nullable=False, unique=True), + Column("created_at", Text, nullable=False), + Column("payload", JSON, nullable=False), +) + +artifacts = Table( + "artifacts", + metadata, + Column("artifact_id", String(32), primary_key=True), + Column( + "media_id", + String(32), + ForeignKey("media.media_id"), + nullable=False, + ), + Column("created_at", Text, nullable=False), + Column("payload", JSON, nullable=False), +) +Index("artifacts_media_id", artifacts.c.media_id) + +artifact_requests = Table( + "artifact_requests", + metadata, + Column("request_key", String(64), primary_key=True), + Column( + "artifact_id", + String(32), + ForeignKey("artifacts.artifact_id"), + nullable=False, + unique=True, + ), +) + +media_import_requests = Table( + "media_import_requests", + metadata, + Column("request_key", String(64), primary_key=True), + Column("request_fingerprint", String(64), nullable=False), + Column( + "media_id", + String(32), + ForeignKey("media.media_id"), + nullable=True, + ), +) + +upload_intents = Table( + "upload_intents", + metadata, + Column("intent_id", String(32), primary_key=True), + Column("request_key", String(64), nullable=False, unique=True), + Column("repository_id", String(255), nullable=False), + Column("owner_subject", String(255), nullable=False), + Column("original_filename", String(255), nullable=False), + Column("byte_size", BigInteger, nullable=False), + Column("declared_mime_type", String(127), nullable=True), + Column("state", String(32), nullable=False), + Column("created_at", Text, nullable=False), + Column("expires_at", Text, nullable=False), + Column("upload_id", String(255), nullable=True, unique=True), + Column("job_id", String(36), nullable=True, unique=True), + Column( + "media_id", + String(32), + ForeignKey("media.media_id"), + nullable=True, + ), + UniqueConstraint( + "repository_id", + "intent_id", + name="upload_intents_repository_intent_key", + ), +) + +upload_quotas = Table( + "upload_quotas", + metadata, + Column("repository_id", String(255), primary_key=True), + Column("owner_subject", String(255), primary_key=True), + Column("reserved_bytes", BigInteger, nullable=False, default=0), + CheckConstraint( + "reserved_bytes >= 0", + name="upload_quotas_reserved_bytes_nonnegative", + ), +) + +repositories = Table( + "repositories", + metadata, + Column("repository_id", String(255), primary_key=True), + Column("active_snapshot_id", String(32), nullable=True), + Column("active_snapshot_sha256", String(64), nullable=True), +) + +index_generations = Table( + "index_generations", + metadata, + Column("generation_id", String(32), primary_key=True), + Column("repository_id", String(255), nullable=False), + Column("media_id", String(32), nullable=False), + Column("manifest_sha256", String(64), nullable=False), + Column("payload", JSON, nullable=False), + UniqueConstraint( + "repository_id", + "media_id", + "generation_id", + name="index_generations_repository_media_generation_key", + ), +) +Index( + "index_generations_repository_media", + index_generations.c.repository_id, + index_generations.c.media_id, +) + +index_snapshots = Table( + "index_snapshots", + metadata, + Column("snapshot_id", String(32), primary_key=True), + Column("repository_id", String(255), nullable=False), + Column("created_at", Text, nullable=False), + Column("sha256", String(64), nullable=False), + Column("payload", JSON, nullable=False), +) +Index( + "index_snapshots_repository_created", + index_snapshots.c.repository_id, + index_snapshots.c.created_at, +) +Index( + "upload_intents_owner_state", + upload_intents.c.repository_id, + upload_intents.c.owner_subject, + upload_intents.c.state, +) +Index( + "upload_intents_expiry", + upload_intents.c.expires_at, + upload_intents.c.state, +) diff --git a/src/vidxp/infrastructure/tusd_contracts.py b/src/vidxp/infrastructure/tusd_contracts.py new file mode 100644 index 0000000..12e51c6 --- /dev/null +++ b/src/vidxp/infrastructure/tusd_contracts.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class TusdModel(BaseModel): + model_config = ConfigDict( + extra="ignore", + frozen=True, + populate_by_name=True, + ) + + +class TusdHTTPRequest(TusdModel): + method: str = Field(default="", alias="Method") + uri: str = Field(default="", alias="URI") + headers: dict[str, list[str]] = Field( + default_factory=dict, + alias="Header", + ) + + def header(self, name: str) -> tuple[str, ...]: + lowered = name.lower() + for key, values in self.headers.items(): + if key.lower() == lowered: + return tuple(values) + return () + + +class TusdUpload(TusdModel): + upload_id: str = Field(default="", alias="ID") + size: int = Field(alias="Size", ge=0) + offset: int = Field(default=0, alias="Offset", ge=0) + metadata: dict[str, str] = Field( + default_factory=dict, + alias="MetaData", + ) + size_is_deferred: bool = Field( + default=False, + alias="SizeIsDeferred", + ) + is_partial: bool = Field(default=False, alias="IsPartial") + is_final: bool = Field(default=False, alias="IsFinal") + partial_uploads: list[str] | None = Field( + default=None, + alias="PartialUploads", + ) + + +class TusdEvent(TusdModel): + upload: TusdUpload = Field(alias="Upload") + request: TusdHTTPRequest = Field(alias="HTTPRequest") + + +class TusdHookRequest(TusdModel): + hook_type: Literal[ + "pre-create", + "post-finish", + "pre-terminate", + "post-terminate", + ] = Field(alias="Type") + event: TusdEvent = Field(alias="Event") + + +class TusdHTTPResponse(TusdModel): + status_code: int = Field(alias="StatusCode", ge=100, le=599) + body: str = Field(default="", alias="Body") + headers: dict[str, str] = Field(default_factory=dict, alias="Header") + + +class TusdChangeFileInfo(TusdModel): + upload_id: str = Field(alias="ID") + + +class TusdHookResponse(TusdModel): + reject_upload: bool = Field(default=False, alias="RejectUpload") + reject_termination: bool = Field( + default=False, + alias="RejectTermination", + ) + http_response: TusdHTTPResponse | None = Field( + default=None, + alias="HTTPResponse", + ) + change_file_info: TusdChangeFileInfo | None = Field( + default=None, + alias="ChangeFileInfo", + ) + + def wire(self) -> dict[str, Any]: + return self.model_dump( + mode="json", + by_alias=True, + exclude_none=True, + ) diff --git a/src/vidxp/job_service.py b/src/vidxp/job_service.py index e81dac4..b89fe2f 100644 --- a/src/vidxp/job_service.py +++ b/src/vidxp/job_service.py @@ -14,12 +14,14 @@ ErrorCategory, IndexJobRequest, Job, + JobKind, JobPage, JobQueue, JobResult, JobState, InvalidRequestError, ListJobsCommand, + MediaImportJobRequest, PrepareModelsCommand, PrepareModelsJobRequest, ResourceNotFoundError, @@ -166,6 +168,25 @@ def submit_prepare_models( job_id=job_id, ) + def enqueue_media_import_in_transaction( + self, + upload_id: str, + *, + connection: Any, + job_id: str, + ) -> str: + enqueue = getattr(self.backend, "enqueue_in_transaction", None) + if enqueue is None: + raise RuntimeError( + "The durable job backend cannot join an upload transaction." + ) + return enqueue( + connection, + MediaImportJobRequest(upload_id=upload_id), + queue=JobQueue.cpu, + job_id=job_id, + ) + @job_boundary def get(self, job_id: str) -> Job: job = self.backend.get(job_id) @@ -180,6 +201,12 @@ def list(self, command: ListJobsCommand) -> JobPage: @job_boundary def cancel(self, job_id: str) -> Job: current = self.get(job_id) + if current.kind == JobKind.media_import: + raise ApplicationError( + "job_not_cancellable", + ErrorCategory.conflict, + "Media import jobs are managed by the upload lifecycle.", + ) if current.state in { JobState.succeeded, JobState.failed, @@ -200,6 +227,12 @@ def retry( retry_id: str | None = None, ) -> Job: current = self.get(job_id) + if current.kind == JobKind.media_import: + raise ApplicationError( + "job_not_retryable", + ErrorCategory.conflict, + "Create a new upload intent to retry a media import.", + ) if current.state not in { JobState.failed, JobState.cancelled, diff --git a/src/vidxp/migrations/__init__.py b/src/vidxp/migrations/__init__.py new file mode 100644 index 0000000..8038de5 --- /dev/null +++ b/src/vidxp/migrations/__init__.py @@ -0,0 +1 @@ +"""VidXP Alembic migration package.""" diff --git a/src/vidxp/migrations/env.py b/src/vidxp/migrations/env.py new file mode 100644 index 0000000..106d917 --- /dev/null +++ b/src/vidxp/migrations/env.py @@ -0,0 +1,39 @@ +from alembic import context +from sqlalchemy import engine_from_config, pool + +from vidxp.infrastructure.sql_tables import metadata + + +def run_migrations_offline() -> None: + context.configure( + url=context.config.get_main_option("sqlalchemy.url"), + target_metadata=metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + engine = engine_from_config( + context.config.get_section(context.config.config_ini_section) or {}, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with engine.connect() as connection: + context.configure( + connection=connection, + target_metadata=metadata, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + engine.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/src/vidxp/migrations/versions/20260729_01_catalog_uploads.py b/src/vidxp/migrations/versions/20260729_01_catalog_uploads.py new file mode 100644 index 0000000..189fd33 --- /dev/null +++ b/src/vidxp/migrations/versions/20260729_01_catalog_uploads.py @@ -0,0 +1,191 @@ +"""Create shared catalog and remote upload tables. + +Revision ID: 20260729_01 +Revises: +Create Date: 2026-07-29 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "20260729_01" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "media", + sa.Column("media_id", sa.String(length=32), nullable=False), + sa.Column("sha256", sa.String(length=64), nullable=False), + sa.Column("created_at", sa.Text(), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.PrimaryKeyConstraint("media_id"), + sa.UniqueConstraint("sha256"), + ) + op.create_table( + "artifacts", + sa.Column("artifact_id", sa.String(length=32), nullable=False), + sa.Column("media_id", sa.String(length=32), nullable=False), + sa.Column("created_at", sa.Text(), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.ForeignKeyConstraint(["media_id"], ["media.media_id"]), + sa.PrimaryKeyConstraint("artifact_id"), + ) + op.create_index( + "artifacts_media_id", + "artifacts", + ["media_id"], + unique=False, + ) + op.create_table( + "artifact_requests", + sa.Column("request_key", sa.String(length=64), nullable=False), + sa.Column("artifact_id", sa.String(length=32), nullable=False), + sa.ForeignKeyConstraint( + ["artifact_id"], + ["artifacts.artifact_id"], + ), + sa.PrimaryKeyConstraint("request_key"), + sa.UniqueConstraint("artifact_id"), + ) + op.create_table( + "media_import_requests", + sa.Column("request_key", sa.String(length=64), nullable=False), + sa.Column( + "request_fingerprint", + sa.String(length=64), + nullable=False, + ), + sa.Column("media_id", sa.String(length=32), nullable=True), + sa.ForeignKeyConstraint(["media_id"], ["media.media_id"]), + sa.PrimaryKeyConstraint("request_key"), + ) + op.create_table( + "repositories", + sa.Column("repository_id", sa.String(length=255), nullable=False), + sa.Column("active_snapshot_id", sa.String(length=32), nullable=True), + sa.Column( + "active_snapshot_sha256", + sa.String(length=64), + nullable=True, + ), + sa.PrimaryKeyConstraint("repository_id"), + ) + op.create_table( + "index_generations", + sa.Column("generation_id", sa.String(length=32), nullable=False), + sa.Column("repository_id", sa.String(length=255), nullable=False), + sa.Column("media_id", sa.String(length=32), nullable=False), + sa.Column("manifest_sha256", sa.String(length=64), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.PrimaryKeyConstraint("generation_id"), + sa.UniqueConstraint( + "repository_id", + "media_id", + "generation_id", + name="index_generations_repository_media_generation_key", + ), + ) + op.create_index( + "index_generations_repository_media", + "index_generations", + ["repository_id", "media_id"], + unique=False, + ) + op.create_table( + "index_snapshots", + sa.Column("snapshot_id", sa.String(length=32), nullable=False), + sa.Column("repository_id", sa.String(length=255), nullable=False), + sa.Column("created_at", sa.Text(), nullable=False), + sa.Column("sha256", sa.String(length=64), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.PrimaryKeyConstraint("snapshot_id"), + ) + op.create_index( + "index_snapshots_repository_created", + "index_snapshots", + ["repository_id", "created_at"], + unique=False, + ) + op.create_table( + "upload_quotas", + sa.Column("repository_id", sa.String(length=255), nullable=False), + sa.Column("owner_subject", sa.String(length=255), nullable=False), + sa.Column("reserved_bytes", sa.BigInteger(), nullable=False), + sa.CheckConstraint( + "reserved_bytes >= 0", + name="upload_quotas_reserved_bytes_nonnegative", + ), + sa.PrimaryKeyConstraint("repository_id", "owner_subject"), + ) + op.create_table( + "upload_intents", + sa.Column("intent_id", sa.String(length=32), nullable=False), + sa.Column("request_key", sa.String(length=64), nullable=False), + sa.Column("repository_id", sa.String(length=255), nullable=False), + sa.Column("owner_subject", sa.String(length=255), nullable=False), + sa.Column("original_filename", sa.String(length=255), nullable=False), + sa.Column("byte_size", sa.BigInteger(), nullable=False), + sa.Column( + "declared_mime_type", + sa.String(length=127), + nullable=True, + ), + sa.Column("state", sa.String(length=32), nullable=False), + sa.Column("created_at", sa.Text(), nullable=False), + sa.Column("expires_at", sa.Text(), nullable=False), + sa.Column("upload_id", sa.String(length=255), nullable=True), + sa.Column("job_id", sa.String(length=36), nullable=True), + sa.Column("media_id", sa.String(length=32), nullable=True), + sa.ForeignKeyConstraint(["media_id"], ["media.media_id"]), + sa.PrimaryKeyConstraint("intent_id"), + sa.UniqueConstraint("job_id"), + sa.UniqueConstraint("request_key"), + sa.UniqueConstraint("upload_id"), + sa.UniqueConstraint( + "repository_id", + "intent_id", + name="upload_intents_repository_intent_key", + ), + ) + op.create_index( + "upload_intents_expiry", + "upload_intents", + ["expires_at", "state"], + unique=False, + ) + op.create_index( + "upload_intents_owner_state", + "upload_intents", + ["repository_id", "owner_subject", "state"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + "upload_intents_owner_state", + table_name="upload_intents", + ) + op.drop_index("upload_intents_expiry", table_name="upload_intents") + op.drop_table("upload_intents") + op.drop_table("upload_quotas") + op.drop_index( + "index_snapshots_repository_created", + table_name="index_snapshots", + ) + op.drop_table("index_snapshots") + op.drop_index( + "index_generations_repository_media", + table_name="index_generations", + ) + op.drop_table("index_generations") + op.drop_table("repositories") + op.drop_table("media_import_requests") + op.drop_table("artifact_requests") + op.drop_index("artifacts_media_id", table_name="artifacts") + op.drop_table("artifacts") + op.drop_table("media") diff --git a/src/vidxp/migrations/versions/__init__.py b/src/vidxp/migrations/versions/__init__.py new file mode 100644 index 0000000..8d639b7 --- /dev/null +++ b/src/vidxp/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Reviewed VidXP database revisions.""" diff --git a/src/vidxp/requirements/server-storage.txt b/src/vidxp/requirements/server-storage.txt new file mode 100644 index 0000000..dfb0435 --- /dev/null +++ b/src/vidxp/requirements/server-storage.txt @@ -0,0 +1 @@ +chromadb-client>=1.5.9,<2 diff --git a/src/vidxp/requirements/server.txt b/src/vidxp/requirements/server.txt index 3e4cbae..9810ff7 100644 --- a/src/vidxp/requirements/server.txt +++ b/src/vidxp/requirements/server.txt @@ -1,6 +1,8 @@ asgi-correlation-id>=5.0.1,<6 +alembic>=1.18.5,<2 fastapi>=0.140.13,<0.141 psutil>=7.2.2,<8 +psycopg[binary]>=3.3.4,<4 pyjwt[crypto]>=2.13,<3 python-multipart>=0.0.32,<0.1 uvicorn[standard]>=0.51,<0.52 diff --git a/src/vidxp/requirements/test.txt b/src/vidxp/requirements/test.txt index b24719e..f1de72e 100644 --- a/src/vidxp/requirements/test.txt +++ b/src/vidxp/requirements/test.txt @@ -1 +1,2 @@ httpx>=0.28.1,<0.29 +pytest>=9.1.1,<10 diff --git a/src/vidxp/settings.py b/src/vidxp/settings.py index 2ab14a2..079ca2d 100644 --- a/src/vidxp/settings.py +++ b/src/vidxp/settings.py @@ -42,6 +42,12 @@ class VidXPSettings(BaseSettings): ) mode: ApplicationMode = ApplicationMode.local + repository_id: str = Field( + default="default", + min_length=1, + max_length=255, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$", + ) repository_root: Path = Path("chroma_data") runtime_backend: str = "auto" model_cache: Path = Field( @@ -51,7 +57,7 @@ class VidXPSettings(BaseSettings): max_loaded_models: int = Field(default=3, gt=0, le=16) max_concurrent_indexing: int = Field(default=1, gt=0, le=16) max_concurrent_inference: int = Field(default=2, gt=0, le=64) - workflow_database_url: str | None = Field(default=None, min_length=1) + database_url: str | None = Field(default=None, min_length=1) workflow_poll_interval_seconds: float = Field( default=0.25, gt=0, @@ -110,6 +116,41 @@ class VidXPSettings(BaseSettings): gt=0, le=256 * 1024 * 1024, ) + upload_public_endpoint: str | None = Field( + default=None, + min_length=1, + max_length=2048, + ) + upload_internal_endpoint: str | None = Field( + default=None, + min_length=1, + max_length=2048, + ) + upload_max_bytes: int = Field( + default=50 * 1024 * 1024 * 1024, + gt=0, + ) + upload_principal_quota_bytes: int = Field( + default=100 * 1024 * 1024 * 1024, + gt=0, + ) + upload_intent_ttl_seconds: int = Field( + default=24 * 60 * 60, + ge=300, + le=7 * 24 * 60 * 60, + ) + upload_recovery_interval_seconds: int = Field( + default=60, + ge=10, + le=3600, + ) + upload_quarantine_root: Path | None = None + upload_cleanup_token: SecretStr | None = None + chroma_server_url: str | None = Field( + default=None, + min_length=1, + max_length=2048, + ) trusted_local_import_roots: tuple[Path, ...] = () ffprobe_executable: str = Field(default="ffprobe", min_length=1) ffmpeg_executable: str = Field(default="ffmpeg", min_length=1) @@ -246,6 +287,58 @@ def _validate_oidc_url( raise ValueError("http_oidc_issuer must not contain a query.") return value + @field_validator( + "upload_public_endpoint", + "upload_internal_endpoint", + "chroma_server_url", + ) + @classmethod + def _validate_service_url( + cls, + value: str | None, + info: ValidationInfo, + ) -> str | None: + if value is None: + return None + if value != value.strip() or "\\" in value: + raise ValueError(f"{info.field_name} contains unsafe characters.") + parsed = urlsplit(value) + if ( + parsed.scheme not in {"http", "https"} + or parsed.hostname is None + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise ValueError(f"{info.field_name} must be a plain HTTP URL.") + try: + parsed.port + except ValueError as exc: + raise ValueError( + f"{info.field_name} contains an invalid port." + ) from exc + if ( + info.field_name == "upload_public_endpoint" + and parsed.scheme != "https" + and parsed.hostname.lower() not in { + "localhost", + "127.0.0.1", + "::1", + } + ): + raise ValueError( + "upload_public_endpoint must use HTTPS outside loopback." + ) + if ( + info.field_name == "chroma_server_url" + and parsed.path not in {"", "/"} + ): + raise ValueError("chroma_server_url must not contain a path.") + if info.field_name != "chroma_server_url" and not value.endswith("/"): + raise ValueError(f"{info.field_name} must end with a slash.") + return value + @field_validator("trusted_local_import_roots") @classmethod def _clean_import_roots( @@ -313,6 +406,16 @@ def _require_explicit_server_backend(self) -> "VidXPSettings": raise ValueError( "HTTP credentials require an explicit authentication mode." ) + if self.upload_public_endpoint is not None: + if ( + self.upload_internal_endpoint is None + or self.upload_cleanup_token is None + or len(self.upload_cleanup_token.get_secret_value()) < 32 + ): + raise ValueError( + "Remote uploads require an internal tusd endpoint and " + "a cleanup token of at least 32 characters." + ) return self def validate_http_server(self) -> None: @@ -338,6 +441,14 @@ def validate_http_server(self) -> None: def layout(self) -> RepositoryLayout: return RepositoryLayout(root=self.repository_root.expanduser()) + @property + def quarantine_root(self) -> Path: + return ( + self.upload_quarantine_root + if self.upload_quarantine_root is not None + else self.repository_root / "upload-quarantine" + ).expanduser() + class LocalExecutionSettings(BaseModel): """Non-secret settings allowed to cross into local execution processes.""" @@ -378,7 +489,11 @@ def application_settings(self) -> VidXPSettings: defaults = VidXPSettings.model_construct().model_dump(mode="python") defaults.update(self.model_dump(mode="python")) defaults["mode"] = ApplicationMode.local - defaults["workflow_database_url"] = None + defaults["database_url"] = None + defaults["upload_public_endpoint"] = None + defaults["upload_internal_endpoint"] = None + defaults["upload_cleanup_token"] = None + defaults["chroma_server_url"] = None defaults["http_auth_mode"] = HttpAuthMode.none defaults["http_static_bearer_token"] = None defaults["http_oidc_issuer"] = None diff --git a/src/vidxp/tusd_hooks.py b/src/vidxp/tusd_hooks.py new file mode 100644 index 0000000..d891b4a --- /dev/null +++ b/src/vidxp/tusd_hooks.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from vidxp.application_models import ( + ApplicationError, + ErrorCategory, +) +from vidxp.authentication import Authenticator +from vidxp.authorization import AuthorizationPolicy, RepositoryPermission +from vidxp.infrastructure.tusd_contracts import ( + TusdChangeFileInfo, + TusdHookRequest, + TusdHookResponse, + TusdHTTPResponse, +) +from vidxp.upload_service import RemoteUploadService + + +def _reject( + *, + status: int, + code: str, + message: str, + stop: bool = False, +) -> TusdHookResponse: + return TusdHookResponse( + reject_upload=not stop, + reject_termination=stop, + http_response=TusdHTTPResponse( + status_code=status, + body=message, + headers={"X-VidXP-Error": code}, + ), + ) + + +def _status(error: ApplicationError) -> int: + return { + ErrorCategory.authentication: 401, + ErrorCategory.authorization: 403, + ErrorCategory.not_found: 404, + ErrorCategory.conflict: 409, + ErrorCategory.resource_limit: 429, + ErrorCategory.validation: 400, + ErrorCategory.unavailable: 503, + }.get(error.detail.category, 500) + + +def _bearer(request) -> str | None: + values = request.header("authorization") + if len(values) != 1: + return None + scheme, separator, token = values[0].partition(" ") + if separator != " " or scheme.lower() != "bearer" or not token: + return None + return token + + +class TusdHookService: + """Translate tusd hook events into the upload application service.""" + + def __init__( + self, + *, + uploads: RemoteUploadService, + authenticator: Authenticator, + authorization: AuthorizationPolicy, + ) -> None: + self.uploads = uploads + self.authenticator = authenticator + self.authorization = authorization + + def handle(self, hook: TusdHookRequest) -> TusdHookResponse: + try: + if hook.hook_type == "pre-create": + return self._pre_create(hook) + if hook.hook_type == "post-finish": + self._post_finish(hook) + return TusdHookResponse() + if hook.hook_type == "pre-terminate": + self._pre_terminate(hook) + return TusdHookResponse() + self.uploads.record_terminated(hook.event.upload.upload_id) + return TusdHookResponse() + except ApplicationError as exc: + if hook.hook_type in {"post-finish", "post-terminate"}: + raise + return _reject( + status=_status(exc), + code=exc.detail.code, + message=exc.detail.message, + stop=hook.hook_type == "pre-terminate", + ) + + def _pre_create(self, hook: TusdHookRequest) -> TusdHookResponse: + upload = hook.event.upload + if ( + upload.size_is_deferred + or upload.is_partial + or upload.is_final + or upload.partial_uploads + or set(upload.metadata) != {"intent_id"} + ): + raise ApplicationError( + "upload_protocol_unsupported", + ErrorCategory.validation, + "Deferred-length and concatenated uploads are not supported.", + ) + principal = self.authorization.require( + self.authenticator.authenticate(_bearer(hook.event.request)), + RepositoryPermission.write, + ) + record = self.uploads.accept_creation( + upload.metadata["intent_id"], + principal=principal, + byte_size=upload.size, + ) + assert record.upload_id is not None + return TusdHookResponse( + change_file_info=TusdChangeFileInfo(upload_id=record.upload_id) + ) + + def _post_finish(self, hook: TusdHookRequest) -> None: + upload = hook.event.upload + intent_id = upload.metadata.get("intent_id") + if intent_id is None: + raise ApplicationError( + "upload_completion_invalid", + ErrorCategory.validation, + "The completed upload is missing its intent.", + ) + self.uploads.complete_upload( + intent_id=intent_id, + upload_id=upload.upload_id, + byte_size=upload.size, + offset=upload.offset, + ) + + def _pre_terminate(self, hook: TusdHookRequest) -> None: + values = hook.event.request.header("x-vidxp-cleanup-token") + token = values[0] if len(values) == 1 else None + self.uploads.authorize_termination( + hook.event.upload.upload_id, + cleanup_token=token, + ) diff --git a/src/vidxp/upload_service.py b/src/vidxp/upload_service.py new file mode 100644 index 0000000..9af129a --- /dev/null +++ b/src/vidxp/upload_service.py @@ -0,0 +1,684 @@ +from __future__ import annotations + +import hashlib +import json +import logging +from datetime import timedelta +from hmac import compare_digest +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen +from uuid import uuid4 + +from sqlalchemy.engine import Connection +from sqlalchemy.exc import IntegrityError + +from vidxp.application_models import ( + ApplicationError, + CreateUploadIntentCommand, + ErrorCategory, + JobState, + Principal, + UploadIntent, +) +from vidxp.core.media import QuarantinedMedia, utc_now +from vidxp.core.uploads import UploadIntentRecord, UploadState +from vidxp.infrastructure.sql_catalog import ( + SQLCatalog, + UploadQuotaExceededError, +) +from vidxp.media_service import MediaService +from vidxp.settings import VidXPSettings + +LOGGER = logging.getLogger(__name__) + + +def _public_intent(record: UploadIntentRecord) -> UploadIntent: + return UploadIntent( + intent_id=record.intent_id, + original_filename=record.original_filename, + byte_size=record.byte_size, + declared_mime_type=record.declared_mime_type, + state=record.state, + created_at=record.created_at, + expires_at=record.expires_at, + job_id=record.job_id, + media_id=record.media_id, + ) + + +class RemoteUploadService: + """Intent policy and minimal glue around tusd, DBOS, and media import.""" + + def __init__( + self, + *, + settings: VidXPSettings, + catalog: SQLCatalog, + media: MediaService | None, + jobs: Any | None = None, + ) -> None: + self.settings = settings + self.catalog = catalog + self.media = media + self.jobs = jobs + + def create_intent( + self, + command: CreateUploadIntentCommand, + *, + principal: Principal, + request_key: str, + ) -> UploadIntent: + self._require_configured() + if command.byte_size > self.settings.upload_max_bytes: + raise ApplicationError( + "upload_too_large", + ErrorCategory.resource_limit, + "The requested upload exceeds the configured limit.", + ) + if existing := self.catalog.get_upload_intent_by_request(request_key): + self._require_owner(existing, principal) + self._require_same_request(existing, command) + return _public_intent(existing) + now = utc_now() + record = UploadIntentRecord( + intent_id=uuid4().hex, + request_key=request_key, + repository_id=self.settings.repository_id, + owner_subject=principal.subject, + original_filename=command.original_filename, + byte_size=command.byte_size, + declared_mime_type=command.declared_mime_type, + state=UploadState.pending, + created_at=now, + expires_at=now + + timedelta(seconds=self.settings.upload_intent_ttl_seconds), + ) + try: + return _public_intent( + self.catalog.create_upload_intent( + record, + quota_limit=self.settings.upload_principal_quota_bytes, + ) + ) + except UploadQuotaExceededError as exc: + raise ApplicationError( + "upload_quota_exceeded", + ErrorCategory.resource_limit, + "The principal upload quota would be exceeded.", + ) from exc + except IntegrityError: + replay = self.catalog.get_upload_intent_by_request(request_key) + if replay is not None: + self._require_owner(replay, principal) + self._require_same_request(replay, command) + return _public_intent(replay) + raise + + def get_intent( + self, + intent_id: str, + *, + principal: Principal, + ) -> UploadIntent: + record = self.catalog.get_upload_intent(intent_id) + if record is None: + raise ApplicationError( + "resource_not_found", + ErrorCategory.not_found, + "The requested upload intent was not found.", + ) + self._require_owner(record, principal) + return _public_intent(record) + + def upload_url( + self, + intent_id: str, + *, + principal: Principal, + ) -> str | None: + record = self.catalog.get_upload_intent(intent_id) + if record is None: + raise ApplicationError( + "resource_not_found", + ErrorCategory.not_found, + "The requested upload intent was not found.", + ) + self._require_owner(record, principal) + if record.state != UploadState.accepted or record.upload_id is None: + return None + assert self.settings.upload_public_endpoint is not None + return self.settings.upload_public_endpoint + record.upload_id + + def accept_creation( + self, + intent_id: str, + *, + principal: Principal, + byte_size: int, + ) -> UploadIntentRecord: + now = utc_now() + + def accept(connection: Connection) -> UploadIntentRecord | None: + record = self.catalog.get_upload_intent( + intent_id, + connection=connection, + for_update=True, + ) + if record is None: + raise ApplicationError( + "upload_intent_invalid", + ErrorCategory.validation, + "The upload intent is invalid.", + ) + self._require_owner(record, principal) + if record.repository_id != self.settings.repository_id: + raise ApplicationError( + "upload_intent_invalid", + ErrorCategory.validation, + "The upload intent is invalid.", + ) + if record.expires_at <= now: + self.catalog.update_upload( + record.intent_id, + state=UploadState.expired, + connection=connection, + ) + return None + if byte_size != record.byte_size: + raise ApplicationError( + "upload_size_mismatch", + ErrorCategory.validation, + "The upload size does not match its intent.", + ) + if record.state == UploadState.accepted: + assert record.upload_id is not None + if self._quarantine_path( + f"{record.upload_id}.info" + ).exists(): + raise ApplicationError( + "upload_already_created", + ErrorCategory.conflict, + "The upload was already created; resume its existing URL.", + ) + return record + if record.state != UploadState.pending: + raise ApplicationError( + "upload_intent_consumed", + ErrorCategory.conflict, + "The upload intent has already been consumed.", + ) + upload_id = uuid4().hex + self.catalog.update_upload( + record.intent_id, + state=UploadState.accepted, + connection=connection, + upload_id=upload_id, + ) + return record.model_copy( + update={ + "state": UploadState.accepted, + "upload_id": upload_id, + } + ) + + accepted = self.catalog.with_upload_transaction(accept) + if accepted is None: + raise ApplicationError( + "upload_intent_expired", + ErrorCategory.validation, + "The upload intent has expired.", + ) + return accepted + + def complete_upload( + self, + *, + intent_id: str, + upload_id: str, + byte_size: int, + offset: int, + ) -> str: + if self.jobs is None: + raise RuntimeError("Upload job submission is not configured.") + + def complete(connection: Connection) -> str: + record = self.catalog.get_upload_intent( + intent_id, + connection=connection, + for_update=True, + ) + if ( + record is None + or record.upload_id != upload_id + or record.repository_id != self.settings.repository_id + ): + raise ApplicationError( + "upload_completion_invalid", + ErrorCategory.validation, + "The completed upload does not match its intent.", + ) + if byte_size != record.byte_size or offset != record.byte_size: + raise ApplicationError( + "upload_incomplete", + ErrorCategory.validation, + "The upload is not complete.", + ) + if record.job_id is not None: + return record.job_id + if record.state != UploadState.accepted: + raise ApplicationError( + "upload_completion_invalid", + ErrorCategory.conflict, + "The upload is not ready for completion.", + ) + job_id = self.jobs.enqueue_media_import_in_transaction( + upload_id, + connection=connection, + job_id=upload_id, + ) + self.catalog.update_upload( + record.intent_id, + state=UploadState.processing, + connection=connection, + job_id=job_id, + ) + return job_id + + return self.catalog.with_upload_transaction(complete) + + def import_completed( + self, + upload_id: str, + ): + if self.media is None: + raise RuntimeError("Media import is not configured in this process.") + record = self.catalog.get_upload_intent_by_upload_id(upload_id) + if record is None: + raise ApplicationError( + "upload_not_found", + ErrorCategory.not_found, + "The completed upload is unavailable.", + ) + if record.state == UploadState.ready and record.media_id is not None: + asset = self.media.get(record.media_id) + self._cleanup_after_import(upload_id) + return asset + if record.state not in { + UploadState.processing, + UploadState.failed, + }: + raise ApplicationError( + "upload_not_ready", + ErrorCategory.conflict, + "The upload is not ready for import.", + ) + path = self._quarantine_path(upload_id) + request_key = hashlib.sha256( + f"vidxp-upload-import-v1\0{record.intent_id}".encode() + ).hexdigest() + try: + asset = self.media.import_quarantined( + QuarantinedMedia( + path=path, + original_filename=record.original_filename, + declared_mime_type=record.declared_mime_type, + ), + request_key=request_key, + ) + except Exception: + self.catalog.with_upload_transaction( + lambda connection: self.catalog.update_upload( + record.intent_id, + state=UploadState.failed, + connection=connection, + ) + ) + raise + self.catalog.with_upload_transaction( + lambda connection: self.catalog.update_upload( + record.intent_id, + state=UploadState.ready, + connection=connection, + media_id=asset.media_id, + ) + ) + self._cleanup_after_import(upload_id) + return asset + + def reconcile(self) -> dict[str, int]: + recovered = 0 + errors = 0 + for record in self.catalog.recoverable_uploads(): + try: + if record.upload_id is None: + continue + info = self._read_upload_info(record.upload_id) + if info is None: + continue + metadata = info.get("MetaData") + if not isinstance(metadata, dict): + continue + if metadata.get("intent_id") != record.intent_id: + continue + size = info.get("Size") + offset = info.get("Offset") + if ( + isinstance(size, int) + and isinstance(offset, int) + and size == offset == record.byte_size + ): + self.complete_upload( + intent_id=record.intent_id, + upload_id=record.upload_id, + byte_size=size, + offset=offset, + ) + recovered += 1 + except Exception: + errors += 1 + LOGGER.exception( + "Upload recovery failed for intent %s.", + record.intent_id, + ) + failed = 0 + if self.jobs is not None: + for record in self.catalog.processing_uploads(): + try: + assert record.job_id is not None + job = self.jobs.get(record.job_id) + if job.state in { + JobState.failed, + JobState.cancelled, + JobState.recovery_exhausted, + }: + changed = self.catalog.with_upload_transaction( + lambda connection, item=record: ( + self.catalog.update_upload( + item.intent_id, + state=UploadState.failed, + connection=connection, + expected_states={ + UploadState.processing, + }, + ) + ) + ) + failed += int(changed) + except Exception: + errors += 1 + LOGGER.exception( + "Upload job reconciliation failed for intent %s.", + record.intent_id, + ) + expired = 0 + now = utc_now() + for record in self.catalog.expired_uploads(now=now): + try: + if ( + record.state == UploadState.accepted + and record.upload_id is not None + and self._upload_is_active(record.upload_id, now=now) + ): + continue + upload_id = self._expire_intent(record.intent_id) + if upload_id is not None: + self._cleanup_upload(upload_id) + expired += 1 + except Exception: + errors += 1 + LOGGER.exception( + "Upload expiration failed for intent %s.", + record.intent_id, + ) + cleaned = 0 + for record in self.catalog.cleanup_uploads(): + assert record.upload_id is not None + try: + self._cleanup_upload(record.upload_id) + cleaned += 1 + except Exception: + errors += 1 + LOGGER.exception( + "Upload cleanup failed for intent %s.", + record.intent_id, + ) + return { + "recovered": recovered, + "expired": expired, + "cleaned": cleaned, + "failed": failed, + "errors": errors, + } + + def authorize_termination( + self, + upload_id: str, + *, + cleanup_token: str | None, + ) -> None: + def authorize(connection: Connection) -> None: + record = self.catalog.get_upload_intent_by_upload_id( + upload_id, + connection=connection, + for_update=True, + ) + if record is None: + raise ApplicationError( + "upload_not_found", + ErrorCategory.not_found, + "The upload is unavailable.", + ) + expected = self.settings.upload_cleanup_token + internal = ( + expected is not None + and cleanup_token is not None + and compare_digest( + expected.get_secret_value(), + cleanup_token, + ) + ) + if record.state == UploadState.accepted: + self.catalog.update_upload( + record.intent_id, + state=UploadState.expired, + connection=connection, + ) + return + if internal and record.state in { + UploadState.ready, + UploadState.failed, + UploadState.expired, + }: + if record.state == UploadState.failed: + self.catalog.update_upload( + record.intent_id, + state=UploadState.expired, + connection=connection, + ) + return + raise ApplicationError( + "upload_termination_forbidden", + ErrorCategory.conflict, + "The upload cannot be terminated in its current state.", + ) + + self.catalog.with_upload_transaction(authorize) + + def record_terminated(self, upload_id: str) -> None: + def terminate(connection: Connection) -> None: + record = self.catalog.get_upload_intent_by_upload_id( + upload_id, + connection=connection, + for_update=True, + ) + if record is None: + return + if record.state == UploadState.processing: + raise ApplicationError( + "upload_termination_forbidden", + ErrorCategory.conflict, + "A processing upload cannot be terminated.", + ) + state = ( + UploadState.ready + if record.state == UploadState.ready + else UploadState.expired + ) + self.catalog.update_upload( + record.intent_id, + state=state, + connection=connection, + clear_upload_id=True, + ) + + self.catalog.with_upload_transaction(terminate) + + def _expire_intent(self, intent_id: str) -> str | None: + def expire(connection: Connection) -> str | None: + record = self.catalog.get_upload_intent( + intent_id, + connection=connection, + for_update=True, + ) + if record is None or record.state not in { + UploadState.pending, + UploadState.accepted, + UploadState.failed, + }: + return None + self.catalog.update_upload( + intent_id, + state=UploadState.expired, + connection=connection, + ) + return record.upload_id + + return self.catalog.with_upload_transaction(expire) + + def _upload_is_active(self, upload_id: str, *, now) -> bool: + cutoff = now.timestamp() - self.settings.upload_intent_ttl_seconds + for suffix in ("", ".info"): + path = self._quarantine_path(f"{upload_id}{suffix}") + try: + if ( + path.is_file() + and not path.is_symlink() + and path.stat().st_mtime > cutoff + ): + return True + except OSError: + continue + return False + + def _read_upload_info(self, upload_id: str) -> dict[str, Any] | None: + path = self._quarantine_path(f"{upload_id}.info") + if not path.is_file() or path.is_symlink(): + return None + try: + with path.open("rb") as handle: + payload = handle.read(65_537) + if len(payload) > 65_536: + return None + result = json.loads(payload) + except (OSError, json.JSONDecodeError): + return None + return result if isinstance(result, dict) else None + + def _quarantine_path(self, name: str) -> Path: + if ( + not name + or len(name) > 260 + or any( + character + not in "abcdefghijklmnopqrstuvwxyz" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._~-" + for character in name + ) + ): + raise ApplicationError( + "upload_identifier_invalid", + ErrorCategory.validation, + "The upload identifier is invalid.", + ) + root = self.settings.quarantine_root.resolve() + path = (root / name).resolve() + if path.parent != root: + raise RuntimeError("The upload escaped its quarantine root.") + return path + + def _terminate_upload(self, upload_id: str) -> None: + endpoint = self.settings.upload_internal_endpoint + if endpoint is None: + return + request = Request(endpoint + upload_id, method="DELETE") + request.add_header("Tus-Resumable", "1.0.0") + if self.settings.upload_cleanup_token is not None: + request.add_header( + "X-VidXP-Cleanup-Token", + self.settings.upload_cleanup_token.get_secret_value(), + ) + try: + with urlopen(request, timeout=5) as response: + if response.status not in {204, 404}: + raise RuntimeError("tusd rejected upload cleanup.") + except HTTPError as exc: + if exc.code != 404: + raise RuntimeError("tusd upload cleanup failed.") from exc + except URLError as exc: + raise RuntimeError("tusd upload cleanup is unavailable.") from exc + + def _cleanup_upload(self, upload_id: str) -> None: + self._terminate_upload(upload_id) + self.record_terminated(upload_id) + + def _cleanup_after_import(self, upload_id: str) -> None: + try: + self._cleanup_upload(upload_id) + except Exception: + LOGGER.warning( + "Imported upload %s is awaiting quarantine cleanup.", + upload_id, + exc_info=True, + ) + + def _require_configured(self) -> None: + if self.settings.upload_public_endpoint is None: + raise ApplicationError( + "remote_upload_unavailable", + ErrorCategory.unavailable, + "Remote resumable uploads are not configured.", + ) + + def _require_owner( + self, + record: UploadIntentRecord, + principal: Principal, + ) -> None: + if ( + record.repository_id != self.settings.repository_id + or record.owner_subject != principal.subject + ): + raise ApplicationError( + "resource_not_found", + ErrorCategory.not_found, + "The requested upload intent was not found.", + ) + + @staticmethod + def _require_same_request( + record: UploadIntentRecord, + command: CreateUploadIntentCommand, + ) -> None: + if ( + record.original_filename != command.original_filename + or record.byte_size != command.byte_size + or record.declared_mime_type != command.declared_mime_type + ): + raise ApplicationError( + "idempotency_key_reused", + ErrorCategory.validation, + "The idempotency key was already used for another request.", + ) diff --git a/src/vidxp/workflow_contracts.py b/src/vidxp/workflow_contracts.py index ef656a4..4bff1cc 100644 --- a/src/vidxp/workflow_contracts.py +++ b/src/vidxp/workflow_contracts.py @@ -10,6 +10,7 @@ ERROR_EVENT = "vidxp.error" WORKFLOW_NAMES: dict[JobKind, str] = { + JobKind.media_import: "vidxp.media_import.v1", JobKind.index: "vidxp.index.v1", JobKind.search: "vidxp.search.v1", JobKind.snippet: "vidxp.snippet.v1", diff --git a/src/vidxp/workflow_runtime.py b/src/vidxp/workflow_runtime.py index cc20246..75f18ee 100644 --- a/src/vidxp/workflow_runtime.py +++ b/src/vidxp/workflow_runtime.py @@ -43,8 +43,8 @@ class LocalWorkerReady(BaseModel): def workflow_database_url(settings: VidXPSettings) -> str: - if settings.workflow_database_url is not None: - database_url = settings.workflow_database_url + if settings.database_url is not None: + database_url = settings.database_url if ( settings.mode == ApplicationMode.server and not urlsplit(database_url).scheme.startswith("postgresql") @@ -55,7 +55,7 @@ def workflow_database_url(settings: VidXPSettings) -> str: return database_url if settings.mode == ApplicationMode.server: raise ValueError( - "Server mode requires VIDXP_WORKFLOW_DATABASE_URL." + "Server mode requires VIDXP_DATABASE_URL." ) database = settings.layout.workflow_database.resolve() return f"sqlite:///{database.as_posix()}" diff --git a/src/vidxp/workflow_worker.py b/src/vidxp/workflow_worker.py index 9f71de2..f30d978 100644 --- a/src/vidxp/workflow_worker.py +++ b/src/vidxp/workflow_worker.py @@ -82,7 +82,7 @@ def _resolved_database_url( if override is None: return workflow_database_url(settings) return workflow_database_url( - settings.model_copy(update={"workflow_database_url": override}) + settings.model_copy(update={"database_url": override}) ) @@ -101,6 +101,9 @@ def run_worker( "executor_id": executor_id, "max_executor_threads": 1 if role == "all" else None, "use_listen_notify": not database_url.startswith("sqlite:///"), + "dbos_system_schema": ( + None if database_url.startswith("sqlite:///") else "dbos" + ), } DBOS(config=config) diff --git a/tests/test_api.py b/tests/test_api.py index 69327ad..b00316d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -25,6 +25,7 @@ MediaAsset, Principal, SearchCommand, + UploadIntent, ) from vidxp.composition import ( HttpApplicationContext, @@ -34,10 +35,12 @@ from vidxp.authentication import create_authenticator from vidxp.authorization import AuthorizationPolicy from vidxp.core.media import MediaState, MediaStream +from vidxp.core.uploads import UploadState from vidxp.job_service import JobService from vidxp.ports import LocalFileResource from vidxp.readiness_service import ReadinessService from vidxp.settings import HttpAuthMode, VidXPSettings +from vidxp.upload_service import RemoteUploadService MEDIA_ID = "123456781234423481234567890abcde" @@ -88,6 +91,7 @@ def context( upload_limit: int = 256 * 1024 * 1024, json_limit: int = 4 * 1024 * 1024, allowed_origins: tuple[str, ...] = (), + remote_uploads: bool = False, ) -> HttpApplicationContext: settings = VidXPSettings( repository_root=root, @@ -98,11 +102,23 @@ def context( http_max_small_upload_bytes=upload_limit, http_max_json_body_bytes=json_limit, http_allowed_origins=allowed_origins, + upload_public_endpoint=( + "http://localhost:8080/uploads/" + if remote_uploads + else None + ), + upload_internal_endpoint=( + "http://localhost:8080/uploads/" + if remote_uploads + else None + ), + upload_cleanup_token="c" * 32 if remote_uploads else None, ) application = Mock() jobs = Mock(spec=JobService) readiness = Mock() readiness.ready.return_value = True + uploads = Mock(spec=RemoteUploadService) if remote_uploads else None return HttpApplicationContext( application=application, jobs=jobs, @@ -110,6 +126,7 @@ def context( authenticator=create_authenticator(settings), authorization=AuthorizationPolicy(), settings=settings, + uploads=uploads, ) @staticmethod @@ -131,6 +148,56 @@ def test_health_and_minimal_readiness_are_public(self): self.assertEqual(ready.status_code, 200) self.assertEqual(ready.json(), {"ready": True, "status": "ready"}) + def test_resumable_upload_api_uses_typed_intents_and_private_urls(self): + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + auth=HttpAuthMode.static, + remote_uploads=True, + ) + assert context.uploads is not None + now = datetime.now(timezone.utc) + intent = UploadIntent( + intent_id="123456781234423481234567890abcde", + original_filename="video.mp4", + byte_size=20, + declared_mime_type="video/mp4", + state=UploadState.pending, + created_at=now, + expires_at=now.replace(year=now.year + 1), + ) + context.uploads.create_intent.return_value = intent + context.uploads.upload_url.return_value = None + with TestClient(create_app(context=context)) as client: + created = client.post( + "/api/v1/media/uploads", + headers={ + **self.auth(), + "Idempotency-Key": IDEMPOTENCY_KEY, + }, + json={ + "original_filename": "video.mp4", + "byte_size": 20, + "declared_mime_type": "video/mp4", + }, + ) + + self.assertEqual(created.status_code, 201) + self.assertEqual(created.headers["cache-control"], "private, no-store") + self.assertEqual(created.headers["referrer-policy"], "no-referrer") + self.assertEqual( + created.headers["location"], + f"/api/v1/media/uploads/{intent.intent_id}", + ) + payload = created.json() + self.assertEqual(payload["intent"]["state"], "pending") + self.assertEqual( + payload["creation_url"], + "http://localhost:8080/uploads/", + ) + self.assertIsNone(payload["resume_url"]) + self.assertTrue(payload["upload_metadata"].startswith("intent_id ")) + def test_readiness_failure_returns_503_without_details(self): with TemporaryDirectory() as directory: context = self.context(Path(directory)) diff --git a/tests/test_job_contracts.py b/tests/test_job_contracts.py index 59fbd4c..a3e4ddf 100644 --- a/tests/test_job_contracts.py +++ b/tests/test_job_contracts.py @@ -207,7 +207,7 @@ def test_server_workflow_database_must_be_postgres(self): settings = VidXPSettings( mode=ApplicationMode.server, runtime_backend="cpu", - workflow_database_url="sqlite:///jobs.sqlite3", + database_url="sqlite:///jobs.sqlite3", ) with self.assertRaisesRegex(ValueError, "PostgreSQL"): diff --git a/tests/test_local_worker.py b/tests/test_local_worker.py index 942ae07..12a0a26 100644 --- a/tests/test_local_worker.py +++ b/tests/test_local_worker.py @@ -75,7 +75,7 @@ def spawn(command, **options): { "VIDXP_HTTP_AUTH_MODE": "static", "VIDXP_HTTP_STATIC_BEARER_TOKEN": "e" * 32, - "VIDXP_WORKFLOW_DATABASE_URL": ( + "VIDXP_DATABASE_URL": ( "postgresql://user:password@db/vidxp" ), "DBOS__CLOUD": "true", @@ -133,7 +133,7 @@ def test_application_settings_ignore_inherited_vidxp_environment(self): { "VIDXP_HTTP_AUTH_MODE": "static", "VIDXP_HTTP_STATIC_BEARER_TOKEN": "e" * 32, - "VIDXP_WORKFLOW_DATABASE_URL": ( + "VIDXP_DATABASE_URL": ( "postgresql://user:password@db/vidxp" ), }, @@ -142,7 +142,7 @@ def test_application_settings_ignore_inherited_vidxp_environment(self): self.assertEqual(settings.http_auth_mode, "none") self.assertIsNone(settings.http_static_bearer_token) - self.assertIsNone(settings.workflow_database_url) + self.assertIsNone(settings.database_url) def test_startup_wait_finishes_when_worker_owns_lock(self): with TemporaryDirectory() as directory: diff --git a/tests/test_media_catalog.py b/tests/test_media_catalog.py index 427b6d0..25fec72 100644 --- a/tests/test_media_catalog.py +++ b/tests/test_media_catalog.py @@ -60,6 +60,32 @@ def media_record( class LocalCatalogTests(unittest.TestCase): + def test_catalog_enforces_sqlite_integrity_and_schema_version(self): + with TemporaryDirectory() as directory: + database = Path(directory) / "catalog.sqlite3" + catalog = LocalCatalog(database) + with catalog.engine.connect() as connection: + self.assertEqual( + connection.exec_driver_sql( + "PRAGMA foreign_keys" + ).scalar_one(), + 1, + ) + self.assertEqual( + connection.exec_driver_sql( + "PRAGMA busy_timeout" + ).scalar_one(), + 30_000, + ) + with catalog.transaction() as connection: + connection.exec_driver_sql( + "UPDATE catalog_metadata SET schema_version = 999" + ) + catalog.close() + + with self.assertRaisesRegex(RuntimeError, "incompatible"): + LocalCatalog(database) + def test_catalog_persists_and_deduplicates_media_by_checksum(self): with TemporaryDirectory() as directory: database = Path(directory) / "catalog.sqlite3" diff --git a/tests/test_models.py b/tests/test_models.py index f0774d1..1951687 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -401,6 +401,8 @@ def test_server_runtime_and_external_allowlist_are_explicit(self): capability_allowlist=("acme-capabilities:ocr",), ) self.assertEqual(settings.runtime_backend, "cuda:0") + with self.assertRaises(ValidationError): + VidXPSettings(chroma_server_url="http://localhost:8000/prefix") def test_auto_runtime_remains_cpu_until_acceleration_parity_is_enabled(self): with patch( diff --git a/tests/test_packaging.py b/tests/test_packaging.py index ac19422..4666501 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -73,7 +73,16 @@ def test_capability_extras_read_capability_owned_requirements(self): } self.assertEqual( chroma_contracts, - {"src/vidxp/requirements/storage.txt"}, + { + "src/vidxp/requirements/storage.txt", + "src/vidxp/requirements/server-storage.txt", + }, + ) + self.assertIn( + "chromadb-client", + ( + ROOT / "src" / "vidxp" / "requirements" / "server-storage.txt" + ).read_text(encoding="utf-8"), ) self.assertNotIn("benchmarks/requirements.txt", all_block) self.assertNotIn("requirements/frontend.txt", all_block) diff --git a/tests/test_storage.py b/tests/test_storage.py index c8adab4..3231532 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -10,6 +10,7 @@ ) from vidxp.core.storage import ( IndexStorage, + IndexStorageUnavailableError, SnapshotScopedIndexStore, directory_size, metadata_filter, @@ -73,6 +74,7 @@ def fake_storage(config, collection): storage.config = config storage.path = config.index_directory storage.client = FakeClient(collection) + storage._client_factory = MagicMock(remote=False) storage._create = True storage._collections = {} storage._names = { @@ -92,6 +94,13 @@ def setUp(self): enabled_modalities=("scene",), ) + def test_remote_transport_failures_are_retryable_storage_failures(self): + storage = object.__new__(IndexStorage) + storage._client_factory = MagicMock(remote=True) + + with self.assertRaises(IndexStorageUnavailableError): + storage._call(MagicMock(side_effect=RuntimeError("offline"))) + def test_upserts_are_split_into_declared_write_batches(self): collection = FakeCollection() storage = fake_storage(self.config, collection) diff --git a/tests/test_tusd_hooks.py b/tests/test_tusd_hooks.py new file mode 100644 index 0000000..d10b164 --- /dev/null +++ b/tests/test_tusd_hooks.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from pathlib import Path + +from vidxp.application_models import ( + CreateUploadIntentCommand, + Principal, +) +from vidxp.authentication import StaticBearerAuthenticator +from vidxp.authorization import AuthorizationPolicy +from vidxp.infrastructure.local_catalog import LocalCatalog +from vidxp.infrastructure.tusd_contracts import TusdHookRequest +from vidxp.settings import VidXPSettings +from vidxp.tusd_hooks import TusdHookService +from vidxp.upload_service import RemoteUploadService + + +class _Jobs: + def enqueue_media_import_in_transaction( + self, + upload_id: str, + *, + connection, + job_id: str, + ) -> str: + del upload_id, connection + return job_id + + +def _hooks(tmp_path: Path): + token = "t" * 32 + catalog = LocalCatalog(tmp_path / "catalog.sqlite3") + settings = VidXPSettings( + repository_root=tmp_path, + upload_public_endpoint="http://localhost:8080/uploads/", + upload_internal_endpoint="http://localhost:8080/uploads/", + upload_cleanup_token="c" * 32, + ) + uploads = RemoteUploadService( + settings=settings, + catalog=catalog, + media=object(), + jobs=_Jobs(), + ) + intent = uploads.create_intent( + CreateUploadIntentCommand( + original_filename="sample.mp4", + byte_size=20, + declared_mime_type="video/mp4", + ), + principal=Principal( + subject="static", + scopes=frozenset({"*"}), + ), + request_key="a" * 64, + ) + return ( + TusdHookService( + uploads=uploads, + authenticator=StaticBearerAuthenticator(token), + authorization=AuthorizationPolicy(), + ), + catalog, + intent, + token, + ) + + +def _pre_create(intent_id: str, token: str) -> TusdHookRequest: + return TusdHookRequest.model_validate( + { + "Type": "pre-create", + "Event": { + "Upload": { + "ID": "", + "Size": 20, + "Offset": 0, + "MetaData": {"intent_id": intent_id}, + }, + "HTTPRequest": { + "Method": "POST", + "URI": "/uploads/", + "Header": {"Authorization": [f"Bearer {token}"]}, + }, + }, + } + ) + + +def test_pre_create_authenticates_and_assigns_stored_id( + tmp_path: Path, +) -> None: + hooks, catalog, intent, token = _hooks(tmp_path) + response = hooks.handle(_pre_create(intent.intent_id, token)) + + assert not response.reject_upload + assert response.change_file_info is not None + stored = catalog.get_upload_intent(intent.intent_id) + assert stored is not None + assert response.change_file_info.upload_id == stored.upload_id + catalog.close() + + +def test_pre_create_rejects_unsupported_tus_modes(tmp_path: Path) -> None: + hooks, catalog, intent, token = _hooks(tmp_path) + request = _pre_create(intent.intent_id, token).model_copy( + update={ + "event": _pre_create(intent.intent_id, token).event.model_copy( + update={ + "upload": _pre_create( + intent.intent_id, + token, + ).event.upload.model_copy( + update={"size_is_deferred": True} + ) + } + ) + } + ) + + response = hooks.handle(request) + + assert response.reject_upload + assert response.http_response is not None + assert response.http_response.status_code == 400 + catalog.close() + + +def test_pre_create_retry_reuses_id_then_rejects_new_materialized_upload( + tmp_path: Path, +) -> None: + hooks, catalog, intent, token = _hooks(tmp_path) + first = hooks.handle(_pre_create(intent.intent_id, token)) + replay = hooks.handle(_pre_create(intent.intent_id, token)) + assert first.change_file_info == replay.change_file_info + assert first.change_file_info is not None + + upload_id = first.change_file_info.upload_id + stored = catalog.get_upload_intent(intent.intent_id) + assert stored is not None + quarantine = tmp_path / "upload-quarantine" + quarantine.mkdir(parents=True, exist_ok=True) + (quarantine / f"{upload_id}.info").write_text("{}", encoding="utf-8") + + duplicate = hooks.handle(_pre_create(intent.intent_id, token)) + assert duplicate.reject_upload + assert duplicate.http_response is not None + assert duplicate.http_response.status_code == 409 + catalog.close() diff --git a/tests/test_upload_service.py b/tests/test_upload_service.py new file mode 100644 index 0000000..6d0f6b2 --- /dev/null +++ b/tests/test_upload_service.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +from pathlib import Path +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import patch +from urllib.error import HTTPError +from uuid import uuid4 + +import pytest +from sqlalchemy.exc import IntegrityError + +from vidxp.application_models import ( + ApplicationError, + CreateUploadIntentCommand, + JobState, + Principal, +) +from vidxp.infrastructure.local_catalog import LocalCatalog +from vidxp.core.media import utc_now +from vidxp.core.uploads import UploadIntentRecord, UploadState +from vidxp.settings import VidXPSettings +from vidxp.upload_service import RemoteUploadService + + +class _Media: + pass + + +class _Jobs: + def __init__(self) -> None: + self.calls: list[tuple[str, str]] = [] + self.states: dict[str, JobState] = {} + + def enqueue_media_import_in_transaction( + self, + upload_id: str, + *, + connection, + job_id: str, + ) -> str: + del connection + self.calls.append((upload_id, job_id)) + return job_id + + def get(self, job_id: str): + return SimpleNamespace(state=self.states[job_id]) + + +def _service( + root: Path, + *, + quota: int = 100, +) -> tuple[RemoteUploadService, LocalCatalog, _Jobs]: + catalog = LocalCatalog(root / "catalog.sqlite3") + jobs = _Jobs() + settings = VidXPSettings( + repository_root=root, + upload_public_endpoint="http://localhost:8080/uploads/", + upload_internal_endpoint="http://localhost:8080/uploads/", + upload_cleanup_token="x" * 32, + upload_max_bytes=quota, + upload_principal_quota_bytes=quota, + ) + return ( + RemoteUploadService( + settings=settings, + catalog=catalog, + media=_Media(), + jobs=jobs, + ), + catalog, + jobs, + ) + + +def _command(size: int = 60) -> CreateUploadIntentCommand: + return CreateUploadIntentCommand( + original_filename="sample.mp4", + byte_size=size, + declared_mime_type="video/mp4", + ) + + +def test_upload_intent_is_idempotent_and_owner_scoped(tmp_path: Path) -> None: + service, catalog, _ = _service(tmp_path) + owner = Principal(subject="owner") + created = service.create_intent( + _command(), + principal=owner, + request_key="a" * 64, + ) + + assert ( + service.create_intent( + _command(), + principal=owner, + request_key="a" * 64, + ) + == created + ) + with pytest.raises(ApplicationError) as denied: + service.get_intent( + created.intent_id, + principal=Principal(subject="other"), + ) + assert denied.value.detail.code == "resource_not_found" + catalog.close() + + +def test_upload_quota_is_reserved_and_released_atomically( + tmp_path: Path, +) -> None: + service, catalog, _ = _service(tmp_path) + owner = Principal(subject="owner") + first = service.create_intent( + _command(), + principal=owner, + request_key="a" * 64, + ) + + with pytest.raises(ApplicationError) as exceeded: + service.create_intent( + _command(), + principal=owner, + request_key="b" * 64, + ) + assert exceeded.value.detail.code == "upload_quota_exceeded" + + service.accept_creation( + first.intent_id, + principal=owner, + byte_size=60, + ) + service.record_terminated( + catalog.get_upload_intent(first.intent_id).upload_id # type: ignore[union-attr] + ) + second = service.create_intent( + _command(), + principal=owner, + request_key="b" * 64, + ) + assert second.byte_size == 60 + catalog.close() + + +def test_duplicate_finish_enqueues_one_import_job(tmp_path: Path) -> None: + service, catalog, jobs = _service(tmp_path) + owner = Principal(subject="owner") + intent = service.create_intent( + _command(), + principal=owner, + request_key="a" * 64, + ) + accepted = service.accept_creation( + intent.intent_id, + principal=owner, + byte_size=60, + ) + assert accepted.upload_id is not None + + first = service.complete_upload( + intent_id=intent.intent_id, + upload_id=accepted.upload_id, + byte_size=60, + offset=60, + ) + second = service.complete_upload( + intent_id=intent.intent_id, + upload_id=accepted.upload_id, + byte_size=60, + offset=60, + ) + + assert first == second == accepted.upload_id + assert jobs.calls == [(accepted.upload_id, accepted.upload_id)] + catalog.close() + + +def test_creation_hook_retry_replays_only_before_tusd_materializes_upload( + tmp_path: Path, +) -> None: + service, catalog, _ = _service(tmp_path) + owner = Principal(subject="owner") + intent = service.create_intent( + _command(), + principal=owner, + request_key="a" * 64, + ) + + first = service.accept_creation( + intent.intent_id, + principal=owner, + byte_size=60, + ) + replay = service.accept_creation( + intent.intent_id, + principal=owner, + byte_size=60, + ) + + assert first.upload_id == replay.upload_id + assert first.upload_id is not None + service.settings.quarantine_root.mkdir(parents=True, exist_ok=True) + (service.settings.quarantine_root / f"{first.upload_id}.info").write_text( + "{}", + encoding="utf-8", + ) + with pytest.raises(ApplicationError) as duplicate: + service.accept_creation( + intent.intent_id, + principal=owner, + byte_size=60, + ) + assert duplicate.value.detail.code == "upload_already_created" + catalog.close() + + +def test_termination_reserves_state_before_finish_can_enqueue( + tmp_path: Path, +) -> None: + service, catalog, jobs = _service(tmp_path) + owner = Principal(subject="owner") + intent = service.create_intent( + _command(), + principal=owner, + request_key="a" * 64, + ) + accepted = service.accept_creation( + intent.intent_id, + principal=owner, + byte_size=60, + ) + assert accepted.upload_id is not None + + service.authorize_termination( + accepted.upload_id, + cleanup_token=None, + ) + + with pytest.raises(ApplicationError) as rejected: + service.complete_upload( + intent_id=intent.intent_id, + upload_id=accepted.upload_id, + byte_size=60, + offset=60, + ) + assert rejected.value.detail.code == "upload_completion_invalid" + assert jobs.calls == [] + stored = catalog.get_upload_intent(intent.intent_id) + assert stored is not None and stored.state == UploadState.expired + catalog.close() + + +def test_expired_creation_persists_state_and_releases_quota( + tmp_path: Path, +) -> None: + service, catalog, _ = _service(tmp_path) + now = utc_now() + record = UploadIntentRecord( + intent_id=uuid4().hex, + request_key="a" * 64, + repository_id=service.settings.repository_id, + owner_subject="owner", + original_filename="sample.mp4", + byte_size=60, + declared_mime_type="video/mp4", + state=UploadState.pending, + created_at=now - timedelta(minutes=10), + expires_at=now - timedelta(minutes=5), + ) + catalog.create_upload_intent(record, quota_limit=100) + + with pytest.raises(ApplicationError) as expired: + service.accept_creation( + record.intent_id, + principal=Principal(subject="owner"), + byte_size=60, + ) + + assert expired.value.detail.code == "upload_intent_expired" + stored = catalog.get_upload_intent(record.intent_id) + assert stored is not None and stored.state == UploadState.expired + service.create_intent( + _command(), + principal=Principal(subject="owner"), + request_key="b" * 64, + ) + catalog.close() + + +def test_active_resumable_upload_is_not_expired(tmp_path: Path) -> None: + service, catalog, _ = _service(tmp_path) + now = utc_now() + upload_id = "2" * 32 + record = UploadIntentRecord( + intent_id=uuid4().hex, + request_key="a" * 64, + repository_id=service.settings.repository_id, + owner_subject="owner", + original_filename="sample.mp4", + byte_size=60, + declared_mime_type="video/mp4", + state=UploadState.accepted, + created_at=now - timedelta(days=2), + expires_at=now - timedelta(days=1), + upload_id=upload_id, + ) + catalog.create_upload_intent(record, quota_limit=100) + service.settings.quarantine_root.mkdir(parents=True, exist_ok=True) + (service.settings.quarantine_root / f"{upload_id}.info").write_text( + '{"MetaData":{"intent_id":"' + + record.intent_id + + '"},"Size":60,"Offset":10}', + encoding="utf-8", + ) + + result = service.reconcile() + + assert result["expired"] == 0 + stored = catalog.get_upload_intent(record.intent_id) + assert stored is not None and stored.state == UploadState.accepted + catalog.close() + + +def test_concurrent_idempotency_winner_must_match_request( + tmp_path: Path, +) -> None: + service, catalog, _ = _service(tmp_path) + original_create = catalog.create_upload_intent + + def lose_race(record, *, quota_limit): + winner = record.model_copy( + update={ + "intent_id": uuid4().hex, + "original_filename": "other.mp4", + } + ) + original_create(winner, quota_limit=quota_limit) + raise IntegrityError("insert upload", {}, RuntimeError("duplicate")) + + with patch.object(catalog, "create_upload_intent", side_effect=lose_race): + with pytest.raises(ApplicationError) as conflict: + service.create_intent( + _command(), + principal=Principal(subject="owner"), + request_key="a" * 64, + ) + + assert conflict.value.detail.code == "idempotency_key_reused" + catalog.close() + + +def test_terminal_import_job_releases_processing_upload( + tmp_path: Path, +) -> None: + service, catalog, jobs = _service(tmp_path) + owner = Principal(subject="owner") + intent = service.create_intent( + _command(), + principal=owner, + request_key="a" * 64, + ) + accepted = service.accept_creation( + intent.intent_id, + principal=owner, + byte_size=60, + ) + assert accepted.upload_id is not None + job_id = service.complete_upload( + intent_id=intent.intent_id, + upload_id=accepted.upload_id, + byte_size=60, + offset=60, + ) + jobs.states[job_id] = JobState.cancelled + + result = service.reconcile() + + assert result["failed"] == 1 + stored = catalog.get_upload_intent(intent.intent_id) + assert stored is not None and stored.state == UploadState.failed + catalog.close() + + +def test_cleanup_reconciles_tusd_not_found_and_clears_capability_url( + tmp_path: Path, +) -> None: + service, catalog, _ = _service(tmp_path) + owner = Principal(subject="owner") + intent = service.create_intent( + _command(), + principal=owner, + request_key="a" * 64, + ) + accepted = service.accept_creation( + intent.intent_id, + principal=owner, + byte_size=60, + ) + assert accepted.upload_id is not None + service.authorize_termination( + accepted.upload_id, + cleanup_token=None, + ) + missing = HTTPError( + url="http://localhost/uploads/missing", + code=404, + msg="not found", + hdrs=None, + fp=None, + ) + + with patch("vidxp.upload_service.urlopen", side_effect=missing): + result = service.reconcile() + + assert result["cleaned"] == 1 + stored = catalog.get_upload_intent(intent.intent_id) + assert stored is not None + assert stored.state == UploadState.expired + assert stored.upload_id is None + catalog.close() diff --git a/uv.lock b/uv.lock index ba5137b..d984bb4 100644 --- a/uv.lock +++ b/uv.lock @@ -152,6 +152,20 @@ 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 = "alembic" +version = "1.18.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, +] + [[package]] name = "altair" version = "6.2.2" @@ -539,6 +553,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/ce/0f7be6e5d0feafa2cda54b12e6542afeea7dea89d2d411e14da90f8abb96/chromadb-1.5.9-cp39-abi3-win_amd64.whl", hash = "sha256:4fd0b560e56761b7f3cb4d5c6205fd5f20814484b4a3e4e9af9038c2b428fc6c", size = 23542454, upload-time = "2026-05-05T05:54:54.942Z" }, ] +[[package]] +name = "chromadb-client" +version = "1.5.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "jsonschema" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-sdk" }, + { name = "orjson" }, + { name = "overrides" }, + { name = "pybase64" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/3b/47c02df4d56883f1dee3548d03a52e75ab1d1c282030ffaf32031bb46569/chromadb_client-1.5.9.tar.gz", hash = "sha256:5e481d51ce6634fe54230b0411bf1ff257607ace222fc64c669e27874c12175b", size = 22112301, upload-time = "2026-05-05T05:36:19.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/0c/4ae7985586638bcaf5459f6cad5e81704317df1946419c8a16fb8f9c1fbc/chromadb_client-1.5.9-py3-none-any.whl", hash = "sha256:3200b9f79389974f9a35bbcdd0657289f5352da3a7d0d37c63194283e02d0605", size = 798976, upload-time = "2026-05-05T05:36:15.966Z" }, +] + [[package]] name = "click" version = "8.4.2" @@ -1129,6 +1169,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, ] +[[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 = "itsdangerous" version = "2.2.0" @@ -1207,6 +1256,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/30/a96d47df739689ac0001ade0afefc16e3b477fc2fb426b568515fdc8afce/kubernetes-36.0.3-py2.py3-none-any.whl", hash = "sha256:8fde9241c4b298e6374a069dcf728359b4e72c2fb29489a975ba4e1c047cf10f", size = 4618066, upload-time = "2026-07-13T20:38:10.172Z" }, ] +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" @@ -2048,6 +2109,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, ] +[[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 = "pooch" version = "1.9.0" @@ -2678,6 +2748,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -3695,6 +3781,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "rich" }, + { name = "sqlalchemy" }, { name = "typer" }, ] @@ -3763,87 +3850,135 @@ scene = [ { name = "transformers" }, ] server = [ + { name = "alembic" }, { name = "asgi-correlation-id" }, { name = "fastapi" }, { name = "psutil" }, + { name = "psycopg", extra = ["binary"] }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "uvicorn", extra = ["standard"] }, ] +server-worker = [ + { name = "alembic" }, + { name = "asgi-correlation-id" }, + { name = "chromadb-client" }, + { name = "fastapi" }, + { name = "faster-whisper" }, + { name = "huggingface-hub" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "opencv-python-headless" }, + { name = "pillow" }, + { name = "pooch" }, + { name = "psutil" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "sentence-transformers" }, + { name = "torch", version = "2.13.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "transformers" }, + { name = "uvicorn", extra = ["standard"] }, +] storage = [ { name = "chromadb" }, { name = "psutil" }, ] test = [ { name = "httpx" }, + { name = "pytest" }, ] [package.metadata] requires-dist = [ + { name = "alembic", marker = "extra == 'server'", specifier = ">=1.18.5,<2" }, + { name = "alembic", marker = "extra == 'server-worker'", specifier = ">=1.18.5,<2" }, { name = "asgi-correlation-id", marker = "extra == 'server'", specifier = ">=5.0.1,<6" }, + { name = "asgi-correlation-id", marker = "extra == 'server-worker'", specifier = ">=5.0.1,<6" }, { name = "chromadb", marker = "extra == 'actor'", specifier = ">=1.5.9,<2" }, { name = "chromadb", marker = "extra == 'all'", specifier = ">=1.5.9,<2" }, { name = "chromadb", marker = "extra == 'dialogue'", specifier = ">=1.5.9,<2" }, { name = "chromadb", marker = "extra == 'local-worker'", specifier = ">=1.5.9,<2" }, { name = "chromadb", marker = "extra == 'scene'", specifier = ">=1.5.9,<2" }, { name = "chromadb", marker = "extra == 'storage'", specifier = ">=1.5.9,<2" }, + { name = "chromadb-client", marker = "extra == 'server-worker'", specifier = ">=1.5.9,<2" }, { name = "dbos", specifier = ">=2.28,<3" }, { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.140.13,<0.141" }, + { name = "fastapi", marker = "extra == 'server-worker'", specifier = ">=0.140.13,<0.141" }, { name = "faster-whisper", marker = "extra == 'all'", specifier = ">=1.2.1,<2" }, { name = "faster-whisper", marker = "extra == 'dialogue'", specifier = ">=1.2.1,<2" }, { name = "faster-whisper", marker = "extra == 'local-worker'", specifier = ">=1.2.1,<2" }, + { name = "faster-whisper", marker = "extra == 'server-worker'", specifier = ">=1.2.1,<2" }, { name = "filelock", specifier = ">=3.32,<4" }, { name = "httpx", marker = "extra == 'test'", specifier = ">=0.28.1,<0.29" }, { name = "huggingface-hub", marker = "extra == 'all'", specifier = ">=1.25.1,<2" }, { name = "huggingface-hub", marker = "extra == 'dialogue'", specifier = ">=1.25.1,<2" }, { name = "huggingface-hub", marker = "extra == 'local-worker'", specifier = ">=1.25.1,<2" }, { name = "huggingface-hub", marker = "extra == 'scene'", specifier = ">=1.25.1,<2" }, + { name = "huggingface-hub", marker = "extra == 'server-worker'", specifier = ">=1.25.1,<2" }, { name = "numpy", marker = "extra == 'actor'", specifier = ">=2.3,<3" }, { name = "numpy", marker = "extra == 'all'", specifier = ">=2.3,<3" }, { name = "numpy", marker = "extra == 'local-worker'", specifier = ">=2.3,<3" }, { name = "numpy", marker = "extra == 'scene'", specifier = ">=2.3,<3" }, + { name = "numpy", marker = "extra == 'server-worker'", specifier = ">=2.3,<3" }, { name = "opencv-python-headless", marker = "extra == 'actor'", specifier = ">=5.0.0.93,<6" }, { name = "opencv-python-headless", marker = "extra == 'all'", specifier = ">=5.0.0.93,<6" }, { name = "opencv-python-headless", marker = "extra == 'local-worker'", specifier = ">=5.0.0.93,<6" }, { name = "opencv-python-headless", marker = "extra == 'scene'", specifier = ">=5.0.0.93,<6" }, + { name = "opencv-python-headless", marker = "extra == 'server-worker'", specifier = ">=5.0.0.93,<6" }, { name = "packaging", specifier = ">=26.2,<27" }, { name = "pillow", marker = "extra == 'all'", specifier = ">=12.3,<13" }, { name = "pillow", marker = "extra == 'local-worker'", specifier = ">=12.3,<13" }, { name = "pillow", marker = "extra == 'scene'", specifier = ">=12.3,<13" }, + { name = "pillow", marker = "extra == 'server-worker'", specifier = ">=12.3,<13" }, { name = "platformdirs", specifier = ">=4.11,<5" }, { name = "pooch", marker = "extra == 'actor'", specifier = ">=1.9,<2" }, { name = "pooch", marker = "extra == 'all'", specifier = ">=1.9,<2" }, { name = "pooch", marker = "extra == 'local-worker'", specifier = ">=1.9,<2" }, + { name = "pooch", marker = "extra == 'server-worker'", specifier = ">=1.9,<2" }, { name = "psutil", marker = "extra == 'actor'", specifier = ">=7.2.2,<8" }, { name = "psutil", marker = "extra == 'all'", specifier = ">=7.2.2,<8" }, { name = "psutil", marker = "extra == 'dialogue'", specifier = ">=7.2.2,<8" }, { name = "psutil", marker = "extra == 'local-worker'", specifier = ">=7.2.2,<8" }, { name = "psutil", marker = "extra == 'scene'", specifier = ">=7.2.2,<8" }, { name = "psutil", marker = "extra == 'server'", specifier = ">=7.2.2,<8" }, + { name = "psutil", marker = "extra == 'server-worker'", specifier = ">=7.2.2,<8" }, { name = "psutil", marker = "extra == 'storage'", specifier = ">=7.2.2,<8" }, + { name = "psycopg", extras = ["binary"], marker = "extra == 'server'", specifier = ">=3.3.4,<4" }, + { name = "psycopg", extras = ["binary"], marker = "extra == 'server-worker'", specifier = ">=3.3.4,<4" }, { name = "pydantic", specifier = ">=2.13.4,<3" }, { name = "pydantic-settings", specifier = ">=2.14.2,<3" }, { name = "pyjwt", extras = ["crypto"], marker = "extra == 'server'", specifier = ">=2.13,<3" }, + { name = "pyjwt", extras = ["crypto"], marker = "extra == 'server-worker'", specifier = ">=2.13,<3" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=9.1.1,<10" }, { name = "python-multipart", marker = "extra == 'server'", specifier = ">=0.0.32,<0.1" }, + { name = "python-multipart", marker = "extra == 'server-worker'", specifier = ">=0.0.32,<0.1" }, { name = "rich", specifier = ">=15,<16" }, { name = "sentence-transformers", marker = "extra == 'all'", specifier = ">=5.6.1,<6" }, { name = "sentence-transformers", marker = "extra == 'dialogue'", specifier = ">=5.6.1,<6" }, { name = "sentence-transformers", marker = "extra == 'local-worker'", specifier = ">=5.6.1,<6" }, + { name = "sentence-transformers", marker = "extra == 'server-worker'", specifier = ">=5.6.1,<6" }, + { name = "sqlalchemy", specifier = ">=2.0.51,<2.1" }, { name = "srt", marker = "extra == 'benchmarks'", specifier = ">=3.5,<4" }, { name = "streamlit", marker = "extra == 'frontend'", specifier = ">=1.60,<2" }, { name = "torch", marker = "(sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all')", specifier = ">=2.13,<3", index = "https://download.pytorch.org/whl/cpu" }, { name = "torch", marker = "(sys_platform == 'linux' and extra == 'local-worker') or (sys_platform == 'win32' and extra == 'local-worker')", specifier = ">=2.13,<3", index = "https://download.pytorch.org/whl/cpu" }, { name = "torch", marker = "(sys_platform == 'linux' and extra == 'scene') or (sys_platform == 'win32' and extra == 'scene')", specifier = ">=2.13,<3", index = "https://download.pytorch.org/whl/cpu" }, + { name = "torch", marker = "(sys_platform == 'linux' and extra == 'server-worker') or (sys_platform == 'win32' and extra == 'server-worker')", specifier = ">=2.13,<3", index = "https://download.pytorch.org/whl/cpu" }, { name = "torch", marker = "sys_platform != 'linux' and sys_platform != 'win32' and extra == 'all'", specifier = ">=2.13,<3" }, { name = "torch", marker = "sys_platform != 'linux' and sys_platform != 'win32' and extra == 'local-worker'", specifier = ">=2.13,<3" }, { name = "torch", marker = "sys_platform != 'linux' and sys_platform != 'win32' and extra == 'scene'", specifier = ">=2.13,<3" }, + { name = "torch", marker = "sys_platform != 'linux' and sys_platform != 'win32' and extra == 'server-worker'", specifier = ">=2.13,<3" }, { name = "transformers", marker = "extra == 'all'", specifier = ">=5.14.1,<6" }, { name = "transformers", marker = "extra == 'local-worker'", specifier = ">=5.14.1,<6" }, { name = "transformers", marker = "extra == 'scene'", specifier = ">=5.14.1,<6" }, + { name = "transformers", marker = "extra == 'server-worker'", specifier = ">=5.14.1,<6" }, { name = "typer", specifier = ">=0.27,<1" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.51,<0.52" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'server-worker'", specifier = ">=0.51,<0.52" }, ] -provides-extras = ["storage", "dialogue", "scene", "actor", "all", "local-worker", "server", "test", "frontend", "benchmarks"] +provides-extras = ["storage", "dialogue", "scene", "actor", "all", "local-worker", "server", "server-worker", "test", "frontend", "benchmarks"] [[package]] name = "watchdog" From 19e479b5b13cdea6f9c479851f1668759a50d54a Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 10:52:11 +0500 Subject: [PATCH 11/46] feat: add model context protocol server --- README.md | 46 +- changes/mcp-reserved-path.security.md | 4 +- changes/mcp-server.feature.md | 3 + changes/model-unavailable-error.bugfix.md | 2 + changes/server-storage-readiness.bugfix.md | 2 + compose.coolify.yaml | 1 + docs/architecture/platform.md | 36 +- docs/deployment/coolify.md | 13 +- pyproject.toml | 7 +- src/vidxp/api.py | 27 +- src/vidxp/api_middleware.py | 15 +- src/vidxp/api_models.py | 5 - src/vidxp/api_routes/dependencies.py | 40 +- src/vidxp/api_routes/platform.py | 7 +- src/vidxp/application.py | 15 +- src/vidxp/application_models.py | 47 +- src/vidxp/authentication.py | 81 +++- src/vidxp/capabilities/registry.py | 25 +- src/vidxp/capability_service.py | 17 +- src/vidxp/composition.py | 68 ++- src/vidxp/control_plane.py | 3 +- src/vidxp/idempotency.py | 65 +++ src/vidxp/index_state.py | 9 + src/vidxp/infrastructure/local_index.py | 52 +-- src/vidxp/infrastructure/local_snapshots.py | 8 +- src/vidxp/infrastructure/sql_snapshots.py | 8 +- src/vidxp/mcp.py | 478 ++++++++++++++++++++ src/vidxp/mcp_cli.py | 46 ++ src/vidxp/ports.py | 2 +- src/vidxp/read_job_planner.py | 2 +- src/vidxp/requirements/mcp.txt | 1 + src/vidxp/settings.py | 110 +++++ tests/test_api.py | 158 ++++++- tests/test_application.py | 45 +- tests/test_authentication.py | 57 +++ tests/test_capabilities.py | 15 + tests/test_mcp.py | 386 ++++++++++++++++ tests/test_models.py | 30 +- tests/test_packaging.py | 10 +- tests/test_read_job_planner.py | 5 +- uv.lock | 116 ++++- 41 files changed, 1898 insertions(+), 169 deletions(-) create mode 100644 changes/mcp-server.feature.md create mode 100644 changes/model-unavailable-error.bugfix.md create mode 100644 changes/server-storage-readiness.bugfix.md create mode 100644 src/vidxp/idempotency.py create mode 100644 src/vidxp/mcp.py create mode 100644 src/vidxp/mcp_cli.py create mode 100644 src/vidxp/requirements/mcp.txt create mode 100644 tests/test_mcp.py diff --git a/README.md b/README.md index 929ebea..f6f0995 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@

  • From the command line
  • Through its browser interface
  • As an indexing and retrieval layer inside another application
  • -
  • Through the HTTP API, with MCP and desktop interfaces on the roadmap
  • +
  • Through the HTTP API or MCP, with a desktop interface on the roadmap
  • @@ -203,10 +203,8 @@ Its OpenAPI document and interactive reference are available at The API process is a model-free control plane. Under `/api/v1`, it exposes bounded media import and delivery, capability metadata, index status, durable job submission and control, artifact delivery, and authenticated readiness. -Indexing and other model work execute only through DBOS workers. Synchronous -search is intentionally not mounted in this phase because it would perform -model work in the API process; the remote query boundary is added with the -search/MCP phases. +Indexing and other model work execute only through DBOS workers. Remote search +is submitted as a durable job so model work never runs in the API/MCP process. Large remote media uses an upload intent plus the deployment profile's `tusd` service. Create the intent at `/api/v1/media/uploads`, then use the returned @@ -242,13 +240,45 @@ VIDXP_HTTP_BIND_HOST=0.0.0.0 VIDXP_HTTP_AUTH_MODE=static VIDXP_HTTP_STATIC_BEARER_TOKEN= VIDXP_HTTP_TRUSTED_HOSTS=["api.example.com"] +VIDXP_MCP_ALLOWED_HOSTS=["api.example.com"] ``` Use `Authorization: Bearer ` for every route except `/health` and `/ready`. Configure `VIDXP_HTTP_ALLOWED_ORIGINS` only for browser origins that -must call `/api/*`; that CORS policy does not apply to the reserved MCP -namespace. Authenticated profiles keep the interactive `/docs` UI disabled, while -the protected `/openapi.json` contract remains available to authenticated clients. +must call `/api/*`; MCP has its own Host and Origin policy. Authenticated profiles +keep the interactive `/docs` UI disabled, while the protected `/openapi.json` +contract remains available to authenticated clients. + +## MCP + +The server profile exposes a curated Streamable HTTP endpoint at `/mcp`. Its +tools list capabilities, inspect index state, submit idempotent indexing and +search jobs, poll jobs, and explicitly cancel jobs. The MCP adapter calls the +same application and durable-job services as the CLI and API; it does not mirror +OpenAPI or call the HTTP API internally. + +Remote video bytes still use HTTP/tus. Upload and resume the video, wait for a +`media_id`, and then pass that ID to `start_indexing`. MCP never carries video +bytes, base64 video, or server file paths. + +Static deployments use the configured bearer token as an MCP request header. +OIDC deployments must also set the canonical resource URL: + +```text +VIDXP_MCP_PUBLIC_URL=https://api.example.com/mcp +VIDXP_MCP_ALLOWED_HOSTS=["api.example.com"] +VIDXP_MCP_ALLOWED_ORIGINS=[] +``` + +For adjacent local agents, install the MCP extra and use stdio: + +```bash +pipx install "vidxp[all,mcp]" +vidxp-mcp --repository default +``` + +The stdio process owns its local repository/job lifecycle and exits cleanly when +the client closes the transport. ## Container diff --git a/changes/mcp-reserved-path.security.md b/changes/mcp-reserved-path.security.md index 9aded71..62d0560 100644 --- a/changes/mcp-reserved-path.security.md +++ b/changes/mcp-reserved-path.security.md @@ -1,2 +1,2 @@ -Keep reserved MCP paths behind the active HTTP authentication and request-size -policies until the sibling MCP application installs its own validated boundary. +Protect MCP paths with static bearer or SDK-native OIDC resource-server +authentication plus independent request-size, Host, and Origin policies. diff --git a/changes/mcp-server.feature.md b/changes/mcp-server.feature.md new file mode 100644 index 0000000..8d8fc19 --- /dev/null +++ b/changes/mcp-server.feature.md @@ -0,0 +1,3 @@ +Add a curated MCP SDK v2 adapter with remote Streamable HTTP, local stdio, +typed structured tools, shared authorization, durable job polling, and +protocol-native errors. diff --git a/changes/model-unavailable-error.bugfix.md b/changes/model-unavailable-error.bugfix.md new file mode 100644 index 0000000..b35c85f --- /dev/null +++ b/changes/model-unavailable-error.bugfix.md @@ -0,0 +1,2 @@ +Report unavailable model artifacts separately from missing Python +dependencies, with an actionable preparation hint. diff --git a/changes/server-storage-readiness.bugfix.md b/changes/server-storage-readiness.bugfix.md new file mode 100644 index 0000000..a374419 --- /dev/null +++ b/changes/server-storage-readiness.bugfix.md @@ -0,0 +1,2 @@ +Validate server workers against the remote Chroma client profile instead of +requiring the embedded Chroma distribution. diff --git a/compose.coolify.yaml b/compose.coolify.yaml index 40da6c9..3997b11 100644 --- a/compose.coolify.yaml +++ b/compose.coolify.yaml @@ -11,6 +11,7 @@ x-vidxp-environment: &vidxp-environment VIDXP_HTTP_AUTH_MODE: static VIDXP_HTTP_STATIC_BEARER_TOKEN: ${VIDXP_HTTP_STATIC_BEARER_TOKEN:?Set a bearer token of at least 32 characters} VIDXP_HTTP_TRUSTED_HOSTS: '["127.0.0.1","localhost","${VIDXP_PUBLIC_API_HOST:?Set the public API host}"]' + VIDXP_MCP_ALLOWED_HOSTS: '["127.0.0.1:*","localhost:*","${VIDXP_PUBLIC_API_HOST:?Set the public API host}"]' VIDXP_UPLOAD_PUBLIC_ENDPOINT: ${VIDXP_UPLOAD_PUBLIC_ENDPOINT:?Set the public tusd endpoint ending in /uploads/} VIDXP_UPLOAD_INTERNAL_ENDPOINT: http://tusd:8080/uploads/ VIDXP_UPLOAD_CLEANUP_TOKEN: ${VIDXP_UPLOAD_CLEANUP_TOKEN:?Set a cleanup token of at least 32 characters} diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md index 3eebb45..d3c989f 100644 --- a/docs/architecture/platform.md +++ b/docs/architecture/platform.md @@ -219,22 +219,24 @@ Initial categories: - internal FastAPI maps categories to HTTP statuses and a consistent error envelope. MCP maps -them to controlled protocol errors. CLI maps them to exit codes and terminal -messages. No adapter infers product meaning from broad built-in exceptions. +expected application failures to controlled tool-execution errors. CLI maps them +to exit codes and terminal messages. No adapter infers product meaning from broad +built-in exceptions. Every MCP tool is registered through one wrapper that catches expected application -errors and raises a public SDK `MCPError` with: +errors and raises the SDK's public `ToolError` with a compact safe JSON payload: - an assigned safe protocol integer code - a safe public message -- the stable VidXP application code and correlation ID in `data` +- the stable VidXP application code, retryability, and correlation ID +- safe validation details or the required repository scope when applicable The wrapper catches every other exception, logs the cause with correlation data, and raises only a generic internal `MCPError`. `MCPServer` re-raises `MCPError` without converting the underlying exception to text, so the SDK's default -`str(exception)` leakage path is not used. Errors remain outside the Pydantic success -schema; directly returned `CallToolResult(isError=True)` values are not used because -the high-level SDK validates returned values against that success schema. +`str(exception)` leakage path is not used. Expected errors become +`CallToolResult(isError=True)` through the SDK's normal execution-error path and +remain outside the Pydantic success schema. ## 8. Repository layout @@ -873,11 +875,15 @@ Initial curated tools: - `get_capability` - `get_index_status` - `start_indexing` -- `get_index_job` -- `cancel_index_job` - `search_moments` -- paginated actor-cluster inspection -- `query_video` when its application service exists +- `get_job` +- `cancel_job` + +Generic job polling/cancellation covers both indexing and search without +duplicating tool contracts. Actor inspection is added only after its existing +index-read logic is extracted behind a model-free application service; MCP must +not construct `ModelRuntime` merely to inspect clusters. `query_video` is added +when its Phase 9 application service exists. Tool results use real output schemas and structured content. Descriptions remain short and agent-oriented; full API response schemas are not embedded as prose. @@ -1351,6 +1357,14 @@ Validated on 2026-07-28: mount lifespan, root metadata path, token audience validation, safe errors, streaming cancellation, host/origin policy, and the MCP-specific body limit are incorporated above. +- **External FastMCP 3.4.5:** rejected for this phase because its server extra + requires MCP SDK `<2`. The similarly named high-level server in official SDK v2 + is now `MCPServer`; it supplies typed schemas, structured results, Streamable + HTTP, stdio, OAuth resource-server behavior, transport security, and request + limits without generating tools from HTTP routes. +- **fastapi-mcp 0.4:** rejected because it generates an OpenAPI mirror and invokes + the FastAPI application through an internal ASGI HTTP client. That duplicates + transport contracts instead of using VidXP's shared application services. - **DBOS 2.28:** accepted as durable queue/workflow state, not as process isolation. A Windows/Python 3.13 probe exercised SQLite queue, progress, stable-executor crash recovery, and a separate local worker process. Postgres 16 API-client plus diff --git a/docs/deployment/coolify.md b/docs/deployment/coolify.md index 9a2eb67..5786beb 100644 --- a/docs/deployment/coolify.md +++ b/docs/deployment/coolify.md @@ -6,7 +6,8 @@ contexts and does not download models in the API process. ## Images - `VIDXP_CONTROL_IMAGE`: the VidXP `control` target for the API, private tusd - hooks, readiness checks, and migrations. It contains no PyTorch or Chroma server. + hooks, MCP server, readiness checks, and migrations. It contains no PyTorch or + Chroma server. - `VIDXP_WORKER_IMAGE`: the VidXP `worker` target with CPU capabilities and Chroma's HTTP-only client. - PostgreSQL 18.3, Chroma 1.5.9, and tusd 2.10.0 are pinned by digest in the @@ -31,10 +32,20 @@ Route the API service's port 8000 to the API hostname. Route only tusd's `/uploads/` path on port 8080 to the upload hostname. Do not publish PostgreSQL, Chroma, or the hook service. +The same API origin exposes Streamable HTTP MCP at `/mcp`. Configure the proxy +to preserve `Authorization`, `Accept`, `Content-Type`, `MCP-Protocol-Version`, +`Mcp-Method`, `Mcp-Name`, and `Mcp-Param-*` headers and disable response buffering +for `/mcp`. Static bearer mode intentionally publishes no OAuth metadata; configure +the bearer header in the remote MCP client. + The upload path is a capability URL used to resume an upload. Disable or redact reverse-proxy access logging for `/uploads/`; VidXP cannot control logs written by an upstream proxy. +Video bytes do not travel through MCP. Create and resume the upload through the +HTTP/tus endpoints, wait for its `media_id`, and then use that ID with +`start_indexing`. + ## Start and verify ```bash diff --git a/pyproject.toml b/pyproject.toml index b6fd796..06ccba9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ vidxp-api = "vidxp.api_cli:main" vidxp-worker = "vidxp.workflow_worker:main" vidxp-database = "vidxp.database_cli:main" vidxp-hooks = "vidxp.hook_cli:main" +vidxp-mcp = "vidxp.mcp_cli:main" [project.urls] Repository = "https://github.com/grayhatdevelopers/vidxp" @@ -100,7 +101,11 @@ local-worker = { file = [ "src/vidxp/capabilities/scene/requirements.txt", "src/vidxp/capabilities/actor/requirements.txt", ] } -server = { file = ["src/vidxp/requirements/server.txt"] } +mcp = { file = ["src/vidxp/requirements/mcp.txt"] } +server = { file = [ + "src/vidxp/requirements/server.txt", + "src/vidxp/requirements/mcp.txt", +] } server-worker = { file = [ "src/vidxp/requirements/server.txt", "src/vidxp/requirements/server-storage.txt", diff --git a/src/vidxp/api.py b/src/vidxp/api.py index a89a68d..8431d5c 100644 --- a/src/vidxp/api.py +++ b/src/vidxp/api.py @@ -20,6 +20,7 @@ from vidxp.api_routes import create_api_router from vidxp.api_routes.dependencies import context from vidxp.composition import HttpApplicationContext, create_http_application +from vidxp.mcp import MCPTransportSecurityBoundary, create_remote_mcp from vidxp.settings import VidXPSettings @@ -42,12 +43,29 @@ def create_app( active_settings = active_context.settings active_settings.validate_http_server() owns_context = context is None + try: + remote_mcp = create_remote_mcp(active_context) + except Exception: + if owns_context: + active_context.close() + raise + mcp_paths = ("/mcp", "/mcp/") + delegated_auth_paths = ( + ( + *mcp_paths, + "/.well-known/oauth-protected-resource/mcp", + "/.well-known/oauth-protected-resource/mcp/", + ) + if remote_mcp.owns_authentication + else () + ) @asynccontextmanager async def lifespan(_app: FastAPI) -> AsyncIterator[None]: try: active_context.jobs.start() - yield + async with remote_mcp.server.session_manager.run(): + yield finally: if owns_context: active_context.close() @@ -101,6 +119,7 @@ def ready( create_api_router(), dependencies=api_dependencies, ) + app.mount("/", remote_mcp.app) app.add_exception_handler( RequestBodyTooLarge, _request_body_too_large_response, @@ -109,10 +128,12 @@ def ready( RequestBodyLimitMiddleware, json_limit=active_settings.http_max_json_body_bytes, upload_limit=active_settings.http_max_small_upload_bytes, + delegated_paths=mcp_paths, ) app.add_middleware( BearerAuthenticationMiddleware, authenticator=active_context.authenticator, + delegated_paths=delegated_auth_paths, ) if active_settings.http_allowed_origins: app.add_middleware( @@ -127,6 +148,10 @@ def ready( CorrelationIdMiddleware, header_name="X-Request-ID", ) + app.add_middleware( + MCPTransportSecurityBoundary, + settings=remote_mcp.transport_security, + ) return app diff --git a/src/vidxp/api_middleware.py b/src/vidxp/api_middleware.py index fdc55d9..c13ae4d 100644 --- a/src/vidxp/api_middleware.py +++ b/src/vidxp/api_middleware.py @@ -188,9 +188,16 @@ def _host_name(header: str) -> str: class BearerAuthenticationMiddleware: - def __init__(self, app: ASGIApp, *, authenticator: Authenticator) -> None: + def __init__( + self, + app: ASGIApp, + *, + authenticator: Authenticator, + delegated_paths: tuple[str, ...] = (), + ) -> None: self.app = app self.authenticator = authenticator + self.delegated_paths = frozenset(delegated_paths) async def __call__( self, @@ -201,6 +208,7 @@ async def __call__( if ( scope["type"] != "http" or str(scope.get("path", "")) in PUBLIC_HTTP_PATHS + or str(scope.get("path", "")) in self.delegated_paths ): await self.app(scope, receive, send) return @@ -230,10 +238,12 @@ def __init__( *, json_limit: int, upload_limit: int, + delegated_paths: tuple[str, ...] = (), ) -> None: self.app = app self.json_limit = json_limit self.upload_limit = upload_limit + self.delegated_paths = frozenset(delegated_paths) async def __call__( self, @@ -244,6 +254,9 @@ async def __call__( if scope["type"] != "http": await self.app(scope, receive, send) return + if str(scope.get("path", "")) in self.delegated_paths: + await self.app(scope, receive, send) + return limit = ( self.upload_limit + 1024 * 1024 if scope.get("path") == UPLOAD_PATH diff --git a/src/vidxp/api_models.py b/src/vidxp/api_models.py index f29fbaa..6f7f269 100644 --- a/src/vidxp/api_models.py +++ b/src/vidxp/api_models.py @@ -4,7 +4,6 @@ from vidxp.application_models import ( ApplicationModel, - CapabilityInfo, ErrorDetail, UploadIntent, ) @@ -23,10 +22,6 @@ class ReadinessResponse(ApplicationModel): status: Literal["ready", "not_ready"] -class CapabilityList(ApplicationModel): - items: tuple[CapabilityInfo, ...] = () - - class UploadIntentResponse(ApplicationModel): intent: UploadIntent creation_url: str diff --git a/src/vidxp/api_routes/dependencies.py b/src/vidxp/api_routes/dependencies.py index bcd07e8..226eb07 100644 --- a/src/vidxp/api_routes/dependencies.py +++ b/src/vidxp/api_routes/dependencies.py @@ -2,10 +2,8 @@ import os import tempfile -from hashlib import sha256 from pathlib import Path from typing import Annotated -from uuid import UUID from fastapi import Header, Request, Response, UploadFile from fastapi.responses import FileResponse @@ -19,16 +17,18 @@ from vidxp.composition import HttpApplicationContext from vidxp.authorization import AuthorizationPolicy, RepositoryPermission from vidxp.core.media import safe_media_suffix +from vidxp.idempotency import ( + IdempotencyKey, + scoped_job_id as derive_scoped_job_id, + scoped_request_key as derive_scoped_request_key, +) from vidxp.ports import LocalFileResource HttpIdempotencyKey = Annotated[ - str, + IdempotencyKey, Header( alias="Idempotency-Key", - min_length=8, - max_length=200, - pattern=r"^[\x21-\x7e]+$", ), ] @@ -86,16 +86,13 @@ def scoped_job_id( ) -> str: """Derive a non-reversible DBOS workflow ID from an HTTP request key.""" - digest = scoped_request_key( - service, - actor, + return derive_scoped_job_id( + repository_id=service.settings.repository_id, + principal=actor, + transport="http", operation=operation, idempotency_key=idempotency_key, ) - value = bytearray(bytes.fromhex(digest)[:16]) - value[6] = (value[6] & 0x0F) | 0x40 - value[8] = (value[8] & 0x3F) | 0x80 - return UUID(bytes=bytes(value)).hex def scoped_request_key( @@ -105,16 +102,13 @@ def scoped_request_key( operation: str, idempotency_key: str, ) -> str: - material = "\0".join( - ( - "vidxp-http-request-v1", - service.settings.repository_id, - actor.subject, - operation, - idempotency_key, - ) - ).encode() - return sha256(material).hexdigest() + return derive_scoped_request_key( + repository_id=service.settings.repository_id, + principal=actor, + transport="http", + operation=operation, + idempotency_key=idempotency_key, + ) def _etag_matches(request: Request, etag: str) -> bool: diff --git a/src/vidxp/api_routes/platform.py b/src/vidxp/api_routes/platform.py index 072fc61..59fc8fa 100644 --- a/src/vidxp/api_routes/platform.py +++ b/src/vidxp/api_routes/platform.py @@ -2,9 +2,12 @@ from fastapi import APIRouter, Depends -from vidxp.api_models import CapabilityList from vidxp.api_routes.dependencies import context, read_principal -from vidxp.application_models import CapabilityInfo, RuntimeReadiness +from vidxp.application_models import ( + CapabilityInfo, + CapabilityList, + RuntimeReadiness, +) from vidxp.composition import HttpApplicationContext diff --git a/src/vidxp/application.py b/src/vidxp/application.py index 3691d27..eec8a68 100644 --- a/src/vidxp/application.py +++ b/src/vidxp/application.py @@ -22,6 +22,7 @@ ImportMediaCommand, IndexSnapshotReference, MediaAsset, + ModelUnavailableError, PrepareModelsCommand, PrepareModelsResult, RemoveIndexCommand, @@ -105,11 +106,9 @@ def _capability_dependencies( capabilities, self.registry.install_hint(capabilities), ) from exc - except ( - ModuleNotFoundError, - CapabilityDependencyError, - ModelArtifactUnavailableError, - ) as exc: + except ModelArtifactUnavailableError as exc: + raise ModelUnavailableError(exc.capability) from exc + except (ModuleNotFoundError, CapabilityDependencyError) as exc: raise DependencyUnavailableError( capabilities, self.registry.install_hint(capabilities), @@ -124,7 +123,7 @@ def device(self) -> str: return self.runtime.backends.torch_device @application_boundary - def _active_config(self) -> tuple[IndexConfig, dict[str, Any]]: + def _active_config(self) -> IndexConfig: return self.index_backend.active_config( self.index_directory, device=self.device, @@ -306,7 +305,7 @@ def execute( request = contract.input_model.model_validate(payload) config = None if contract.requires_index: - config, _ = self._active_config() + config = self._active_config() self._require_indexed_capability(capability, config) with self._capability_dependencies((capability,)): @@ -497,7 +496,7 @@ def render_actor( config = ( self._config_for_snapshot(snapshot) if snapshot is not None - else self._active_config()[0] + else self._active_config() ) self._require_indexed_capability("actor", config) detections = [] diff --git a/src/vidxp/application_models.py b/src/vidxp/application_models.py index d6e9b0b..4516307 100644 --- a/src/vidxp/application_models.py +++ b/src/vidxp/application_models.py @@ -43,6 +43,7 @@ validate_display_filename, ) from vidxp.core.uploads import UploadState +from vidxp.index_state import INDEX_STATUS_MEDIA_ID_LIMIT T = TypeVar("T") SearchQuery: TypeAlias = Annotated[ @@ -180,6 +181,23 @@ def __init__( ) +class ModelUnavailableError(ApplicationError): + def __init__(self, capability: str) -> None: + super().__init__( + "model_unavailable", + ErrorCategory.unavailable, + f"Model artifacts for the {capability} capability are not " + "available locally.", + details={ + "capability": capability, + "remediation": ( + "Prepare the capability models before retrying or " + "enable model downloads." + ), + }, + ) + + class InvalidRequestError(ApplicationError): def __init__( self, @@ -233,16 +251,23 @@ class CapabilityOperationInfo(ApplicationModel): output_schema: dict[str, JsonValue] -class CapabilityInfo(ApplicationModel): +class CapabilitySummary(ApplicationModel): name: str = Field(min_length=1) description: str = Field(min_length=1) install_extra: str = Field(min_length=1) supports_indexing: bool prepares_models: bool - operations: tuple[CapabilityOperationInfo, ...] = () provenance: CapabilityProvenance | None = None +class CapabilityInfo(CapabilitySummary): + operations: tuple[CapabilityOperationInfo, ...] = () + + +class CapabilityList(ApplicationModel): + items: tuple[CapabilitySummary, ...] = () + + class ComponentReadiness(ApplicationModel): name: str = Field(min_length=1) ready: bool @@ -394,9 +419,25 @@ class IndexStatusSummary(ApplicationModel): index_schema_version: int = Field(ge=1) snapshot_id: IndexSnapshotId media_count: int = Field(ge=0) - media_ids: tuple[MediaId, ...] = () + media_ids: tuple[MediaId, ...] = Field( + default=(), + max_length=INDEX_STATUS_MEDIA_ID_LIMIT, + ) + media_ids_truncated: bool = False modalities: tuple[str, ...] = () + @model_validator(mode="after") + def _validate_media_id_window(self) -> "IndexStatusSummary": + if self.media_count < len(self.media_ids): + raise ValueError("media_count must cover every returned media ID") + if self.media_ids_truncated != ( + self.media_count > len(self.media_ids) + ): + raise ValueError( + "media_ids_truncated must reflect the returned media-ID window" + ) + return self + class SearchCommand(ApplicationModel): modality: Identifier diff --git a/src/vidxp/authentication.py b/src/vidxp/authentication.py index ac1afdb..8814914 100644 --- a/src/vidxp/authentication.py +++ b/src/vidxp/authentication.py @@ -1,7 +1,8 @@ from __future__ import annotations +from dataclasses import dataclass from hmac import compare_digest -from typing import Protocol +from typing import Any, Protocol from vidxp.application_models import ( ApplicationError, @@ -18,6 +19,14 @@ def authenticate(self, token: str | None) -> Principal: ... def readiness(self) -> ComponentReadiness: ... +@dataclass(frozen=True) +class AuthenticatedBearer: + principal: Principal + expires_at: int | None + resource: str | None + claims: dict[str, Any] + + def _ready() -> ComponentReadiness: return ComponentReadiness( name="authentication", @@ -77,6 +86,7 @@ def __init__( jwks_url: str, algorithms: tuple[str, ...], required_scopes: tuple[str, ...], + jwks_client: Any | None = None, ) -> None: import jwt @@ -85,15 +95,41 @@ def __init__( self._audience = audience self._algorithms = algorithms self._required_scopes = frozenset(required_scopes) - self._jwks = jwt.PyJWKClient( - jwks_url, - cache_keys=True, - cache_jwk_set=True, - lifespan=300, - timeout=5, + self._jwks = ( + jwks_client + if jwks_client is not None + else jwt.PyJWKClient( + jwks_url, + cache_keys=True, + cache_jwk_set=True, + lifespan=300, + timeout=5, + ) + ) + self._jwks_url = jwks_url + + def for_audience( + self, + audience: str, + *, + required_scopes: tuple[str, ...] = (), + ) -> "OIDCBearerAuthenticator": + return OIDCBearerAuthenticator( + issuer=self._issuer, + audience=audience, + jwks_url=self._jwks_url, + algorithms=self._algorithms, + required_scopes=required_scopes, + jwks_client=self._jwks, ) def authenticate(self, token: str | None) -> Principal: + return self.authenticate_bearer(token).principal + + def authenticate_bearer( + self, + token: str | None, + ) -> AuthenticatedBearer: if token is None: raise _authentication_error() try: @@ -122,11 +158,25 @@ def authenticate(self, token: str | None) -> Principal: if not self._required_scopes.issubset(scopes): raise _authorization_error() client_id = claims.get("client_id") - return Principal( + principal = Principal( subject=subject, client_id=client_id if isinstance(client_id, str) else None, scopes=scopes, ) + expires_at = claims.get("exp") + resource = claims.get("aud") + return AuthenticatedBearer( + principal=principal, + expires_at=( + expires_at if isinstance(expires_at, int) else None + ), + resource=( + resource + if isinstance(resource, str) + else self._audience + ), + claims=dict(claims), + ) def readiness(self) -> ComponentReadiness: try: @@ -183,7 +233,12 @@ def _token_scopes(claims: dict) -> frozenset[str]: return frozenset(value for value in values if value) -def create_authenticator(settings: VidXPSettings) -> Authenticator: +def create_authenticator( + settings: VidXPSettings, + *, + audience: str | None = None, + required_scopes: tuple[str, ...] | None = None, +) -> Authenticator: if settings.http_auth_mode == HttpAuthMode.none: return LocalAuthenticator() if settings.http_auth_mode == HttpAuthMode.static: @@ -196,8 +251,12 @@ def create_authenticator(settings: VidXPSettings) -> Authenticator: assert settings.http_oidc_jwks_url is not None return OIDCBearerAuthenticator( issuer=settings.http_oidc_issuer, - audience=settings.http_oidc_audience, + audience=audience or settings.http_oidc_audience, jwks_url=settings.http_oidc_jwks_url, algorithms=settings.http_oidc_algorithms, - required_scopes=settings.http_required_scopes, + required_scopes=( + settings.http_required_scopes + if required_scopes is None + else required_scopes + ), ) diff --git a/src/vidxp/capabilities/registry.py b/src/vidxp/capabilities/registry.py index 30fca6f..1186813 100644 --- a/src/vidxp/capabilities/registry.py +++ b/src/vidxp/capabilities/registry.py @@ -53,6 +53,7 @@ def __init__( plugins: Iterable[CapabilityPlugin], *, platform_runtime_checks: Iterable[RuntimeCheckBinding] = (), + storage_requirements: Iterable[Requirement] | None = None, ) -> None: indexed: dict[str, CapabilityPlugin] = {} for plugin in plugins: @@ -74,6 +75,16 @@ def __init__( indexed[name] = plugin self._plugins = MappingProxyType(indexed) self._platform_runtime_checks = tuple(platform_runtime_checks) + self._storage_requirements = tuple( + storage_requirements + if storage_requirements is not None + else active_requirements( + packaged_requirements( + "vidxp", + "requirements/storage.txt", + ) + ) + ) self._executors: dict[str, CapabilityExecutor] = {} self._executor_lock = RLock() @@ -225,16 +236,8 @@ def _requirement_bindings( selected = self.validate_names(names) bindings = [ _RequirementBinding("storage", None, requirement) - for requirement in ( - active_requirements( - packaged_requirements( - "vidxp", - "requirements/storage.txt", - ) - ) - if any(self.get(name).collection_name for name in selected) - else () - ) + for requirement in self._storage_requirements + if any(self.get(name).collection_name for name in selected) ] for name in selected: plugin = self._plugins[name] @@ -450,6 +453,7 @@ def create_capability_registry( external: bool = False, allowlist: tuple[str, ...] = (), platform_runtime_checks: tuple[RuntimeCheckBinding, ...] = (), + storage_requirements: Iterable[Requirement] | None = None, ) -> CapabilityRegistry: plugins = list(_builtin_plugins()) if external: @@ -499,4 +503,5 @@ def create_capability_registry( return CapabilityRegistry( plugins, platform_runtime_checks=platform_runtime_checks, + storage_requirements=storage_requirements, ) diff --git a/src/vidxp/capability_service.py b/src/vidxp/capability_service.py index a066f8b..9b3844b 100644 --- a/src/vidxp/capability_service.py +++ b/src/vidxp/capability_service.py @@ -3,6 +3,7 @@ from vidxp.application_models import ( CapabilityInfo, CapabilityOperationInfo, + CapabilitySummary, ) from vidxp.capabilities.registry import CapabilityRegistry @@ -13,20 +14,27 @@ class CapabilityService: def __init__(self, registry: CapabilityRegistry) -> None: self.registry = registry - def list(self) -> tuple[CapabilityInfo, ...]: - return tuple(self._info(name) for name in self.registry.names()) + def list(self) -> tuple[CapabilitySummary, ...]: + return tuple(self._summary(name) for name in self.registry.names()) def get(self, name: str) -> CapabilityInfo: return self._info(name) - def _info(self, name: str) -> CapabilityInfo: + def _summary(self, name: str) -> CapabilitySummary: definition = self.registry.get(name) - return CapabilityInfo( + return CapabilitySummary( name=definition.name, description=definition.description, install_extra=definition.extra, supports_indexing=definition.collection_name is not None, prepares_models=definition.prepares_models, + provenance=self.registry.provenance(name), + ) + + def _info(self, name: str) -> CapabilityInfo: + definition = self.registry.get(name) + return CapabilityInfo( + **self._summary(name).model_dump(), operations=tuple( CapabilityOperationInfo( name=operation_name, @@ -37,5 +45,4 @@ def _info(self, name: str) -> CapabilityInfo: for operation_name, operation in definition.operations.items() if operation.public ), - provenance=self.registry.provenance(name), ) diff --git a/src/vidxp/composition.py b/src/vidxp/composition.py index be20357..66fc9aa 100644 --- a/src/vidxp/composition.py +++ b/src/vidxp/composition.py @@ -17,8 +17,10 @@ create_capability_registry, ) from vidxp.control_plane import ControlPlaneApplication +from vidxp.dependencies import active_requirements, packaged_requirements from vidxp.infrastructure.local_index import ( LOCAL_INDEX_RUNTIME_CHECKS, + SERVER_INDEX_RUNTIME_CHECKS, LocalIndexBackend, LocalIndexReader, ) @@ -99,12 +101,10 @@ def close(self) -> None: jobs.close() -@dataclass(frozen=True) -class HttpApplicationContext: +@dataclass(frozen=True, kw_only=True) +class ControlPlaneContext: application: ControlPlaneApplication jobs: JobService - readiness: ReadinessService - authenticator: Authenticator authorization: AuthorizationPolicy settings: VidXPSettings catalog: SQLCatalog | None = None @@ -116,6 +116,12 @@ def close(self) -> None: self.catalog.close() +@dataclass(frozen=True, kw_only=True) +class HttpApplicationContext(ControlPlaneContext): + readiness: ReadinessService + authenticator: Authenticator + + @dataclass(frozen=True) class UploadHookContext: jobs: JobService @@ -149,10 +155,25 @@ def _create_control_plane_components( ): raise ValueError("Server applications require a remote Chroma URL.") settings.layout.ensure_local_directories() + server_mode = settings.mode == ApplicationMode.server registry = create_capability_registry( external=settings.external_capabilities, allowlist=settings.capability_allowlist, - platform_runtime_checks=LOCAL_INDEX_RUNTIME_CHECKS, + platform_runtime_checks=( + SERVER_INDEX_RUNTIME_CHECKS + if server_mode + else LOCAL_INDEX_RUNTIME_CHECKS + ), + storage_requirements=( + active_requirements( + packaged_requirements( + "vidxp", + "requirements/server-storage.txt", + ) + ) + if server_mode + else None + ), ) catalog = ( SQLCatalog( @@ -314,11 +335,10 @@ def create_job_service( ) -def create_http_application( +def create_control_plane_application( settings: VidXPSettings | None = None, -) -> HttpApplicationContext: +) -> ControlPlaneContext: active_settings = settings or VidXPSettings() - active_settings.validate_http_server() components = _create_control_plane_components(active_settings) application = ControlPlaneApplication( layout=active_settings.layout, @@ -345,16 +365,9 @@ def create_http_application( if active_settings.mode == ApplicationMode.server else None ) - authenticator = create_authenticator(active_settings) - return HttpApplicationContext( + return ControlPlaneContext( application=application, jobs=jobs, - readiness=ReadinessService( - application=application, - jobs=jobs, - authenticator=authenticator, - ), - authenticator=authenticator, authorization=AuthorizationPolicy(), settings=active_settings, catalog=components.catalog, @@ -362,6 +375,29 @@ def create_http_application( ) +def create_http_application( + settings: VidXPSettings | None = None, +) -> HttpApplicationContext: + active_settings = settings or VidXPSettings() + active_settings.validate_http_server() + control = create_control_plane_application(active_settings) + authenticator = create_authenticator(active_settings) + return HttpApplicationContext( + application=control.application, + jobs=control.jobs, + authorization=control.authorization, + settings=control.settings, + catalog=control.catalog, + uploads=control.uploads, + readiness=ReadinessService( + application=control.application, + jobs=control.jobs, + authenticator=authenticator, + ), + authenticator=authenticator, + ) + + def create_upload_hook_context( settings: VidXPSettings | None = None, ) -> UploadHookContext: diff --git a/src/vidxp/control_plane.py b/src/vidxp/control_plane.py index 4284aeb..d1e2526 100644 --- a/src/vidxp/control_plane.py +++ b/src/vidxp/control_plane.py @@ -7,6 +7,7 @@ from vidxp.application_models import ( Artifact, CapabilityInfo, + CapabilitySummary, ComponentReadiness, IndexStatus, InvalidRequestError, @@ -64,7 +65,7 @@ def import_uploaded_media( ) @application_boundary - def list_capabilities(self) -> tuple[CapabilityInfo, ...]: + def list_capabilities(self) -> tuple[CapabilitySummary, ...]: return self.capabilities.list() @application_boundary diff --git a/src/vidxp/idempotency.py b/src/vidxp/idempotency.py new file mode 100644 index 0000000..dce22e3 --- /dev/null +++ b/src/vidxp/idempotency.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from hashlib import sha256 +from typing import Annotated, Literal, TypeAlias +from uuid import UUID + +from pydantic import StringConstraints + +from vidxp.application_models import Principal + + +IdempotencyKey: TypeAlias = Annotated[ + str, + StringConstraints( + min_length=8, + max_length=200, + pattern=r"^[\x21-\x7e]+$", + ), +] +RequestTransport: TypeAlias = Literal["http", "mcp"] + + +def scoped_request_key( + *, + repository_id: str, + principal: Principal, + transport: RequestTransport, + operation: str, + idempotency_key: str, +) -> str: + """Derive a non-reversible request identity within one adapter namespace.""" + + material = "\0".join( + ( + f"vidxp-{transport}-request-v1", + repository_id, + principal.subject, + operation, + idempotency_key, + ) + ).encode() + return sha256(material).hexdigest() + + +def scoped_job_id( + *, + repository_id: str, + principal: Principal, + transport: RequestTransport, + operation: str, + idempotency_key: str, +) -> str: + """Project a scoped request identity into a valid deterministic job UUID.""" + + digest = scoped_request_key( + repository_id=repository_id, + principal=principal, + transport=transport, + operation=operation, + idempotency_key=idempotency_key, + ) + value = bytearray(bytes.fromhex(digest)[:16]) + value[6] = (value[6] & 0x0F) | 0x40 + value[8] = (value[8] & 0x3F) | 0x80 + return UUID(bytes=bytes(value)).hex diff --git a/src/vidxp/index_state.py b/src/vidxp/index_state.py index 27dfa45..af2bbd6 100644 --- a/src/vidxp/index_state.py +++ b/src/vidxp/index_state.py @@ -1,4 +1,13 @@ INDEX_STATUS_SCHEMA = 1 +INDEX_STATUS_MEDIA_ID_LIMIT = 100 + + +def bounded_media_ids(media_ids: list[str]) -> dict[str, object]: + selected = media_ids[:INDEX_STATUS_MEDIA_ID_LIMIT] + return { + "media_ids": selected, + "media_ids_truncated": len(media_ids) > len(selected), + } class IndexNotReadyError(RuntimeError): diff --git a/src/vidxp/infrastructure/local_index.py b/src/vidxp/infrastructure/local_index.py index 36bdda2..99920cc 100644 --- a/src/vidxp/infrastructure/local_index.py +++ b/src/vidxp/infrastructure/local_index.py @@ -30,24 +30,31 @@ from vidxp.runtime import ModelRuntime -LOCAL_INDEX_RUNTIME_CHECKS = ( - RuntimeCheckBinding( - capability="storage", - check=module_import_check( - "Chroma storage import", - "chromadb", - "PersistentClient", +def _index_runtime_checks( + chroma_client: str, +) -> tuple[RuntimeCheckBinding, ...]: + return ( + RuntimeCheckBinding( + capability="storage", + check=module_import_check( + "Chroma storage import", + "chromadb", + chroma_client, + ), ), - ), - RuntimeCheckBinding( - capability="storage", - check=module_import_check( - "Host resource monitor import", - "psutil", - "virtual_memory", + RuntimeCheckBinding( + capability="storage", + check=module_import_check( + "Host resource monitor import", + "psutil", + "virtual_memory", + ), ), - ), -) + ) + + +LOCAL_INDEX_RUNTIME_CHECKS = _index_runtime_checks("PersistentClient") +SERVER_INDEX_RUNTIME_CHECKS = _index_runtime_checks("HttpClient") class LocalIndexReader: @@ -77,17 +84,10 @@ def active_config( index_directory: Path, *, device: str, - ) -> tuple[IndexConfig, dict[str, Any]]: + ) -> IndexConfig: self._require_index_directory(index_directory) - config, snapshot = self.repository.active_config(device=device) - return config, { - "snapshot_id": snapshot.snapshot_id, - "generation_ids": [ - reference.generation_id - for reference in snapshot.generations.values() - ], - "media_ids": sorted(snapshot.generations), - } + config, _snapshot = self.repository.active_config(device=device) + return config def config_for_snapshot( self, diff --git a/src/vidxp/infrastructure/local_snapshots.py b/src/vidxp/infrastructure/local_snapshots.py index 2304724..43f95e2 100644 --- a/src/vidxp/infrastructure/local_snapshots.py +++ b/src/vidxp/infrastructure/local_snapshots.py @@ -22,7 +22,11 @@ GenerationReference, IndexSnapshot, ) -from vidxp.index_state import IndexNotReadyError, IndexingInProgressError +from vidxp.index_state import ( + IndexNotReadyError, + IndexingInProgressError, + bounded_media_ids, +) from vidxp.repository_layout import RepositoryLayout @@ -493,7 +497,7 @@ def status(self) -> dict[str, Any] | None: "index_schema_version": INDEX_SCHEMA_VERSION, "snapshot_id": snapshot.snapshot_id, "media_count": len(media_ids), - "media_ids": media_ids, + **bounded_media_ids(media_ids), "modalities": tuple( snapshot.configuration.get("enabled_modalities", ()) ), diff --git a/src/vidxp/infrastructure/sql_snapshots.py b/src/vidxp/infrastructure/sql_snapshots.py index b576c3a..cd7d6e4 100644 --- a/src/vidxp/infrastructure/sql_snapshots.py +++ b/src/vidxp/infrastructure/sql_snapshots.py @@ -17,7 +17,11 @@ from vidxp.core.generations import CompletedGenerationManifest from vidxp.core.manifest import MANIFEST_FILE, sha256_file from vidxp.core.snapshots import GenerationReference, IndexSnapshot -from vidxp.index_state import IndexNotReadyError, IndexingInProgressError +from vidxp.index_state import ( + IndexNotReadyError, + IndexingInProgressError, + bounded_media_ids, +) from vidxp.infrastructure.local_snapshots import LocalSnapshotRepository from vidxp.infrastructure.sql_tables import ( index_generations, @@ -426,7 +430,7 @@ def status(self) -> dict[str, Any] | None: "index_schema_version": INDEX_SCHEMA_VERSION, "snapshot_id": snapshot.snapshot_id, "media_count": len(media_ids), - "media_ids": media_ids, + **bounded_media_ids(media_ids), "modalities": tuple( snapshot.configuration.get("enabled_modalities", ()) ), diff --git a/src/vidxp/mcp.py b/src/vidxp/mcp.py new file mode 100644 index 0000000..2025532 --- /dev/null +++ b/src/vidxp/mcp.py @@ -0,0 +1,478 @@ +from __future__ import annotations + +import logging +import json +from contextvars import ContextVar +from dataclasses import dataclass +from functools import partial +from typing import Callable, TypeVar + +import anyio +from mcp.server import MCPServer +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import AccessToken +from mcp.server.auth.settings import AuthSettings +from mcp.server.transport_security import TransportSecuritySettings +from mcp.server.transport_security import TransportSecurityMiddleware +from mcp.shared.exceptions import MCPError +from mcp.server.mcpserver.exceptions import ToolError +from mcp.types import ToolAnnotations +from starlette.requests import Request +from starlette.types import ASGIApp, Receive, Scope, Send + +from vidxp import __version__ +from vidxp.application_models import ( + ApplicationError, + CapabilityInfo, + CapabilityList, + CreateIndexCommand, + ErrorCategory, + Identifier, + IndexStatus, + Job, + JobId, + Principal, + SearchCommand, +) +from vidxp.authentication import ( + OIDCBearerAuthenticator, + create_authenticator, +) +from vidxp.authorization import RepositoryPermission +from vidxp.composition import ControlPlaneContext, HttpApplicationContext +from vidxp.idempotency import IdempotencyKey, scoped_job_id +from vidxp.settings import HttpAuthMode + + +_LOGGER = logging.getLogger(__name__) +_T = TypeVar("_T") +_REQUEST_PRINCIPAL: ContextVar[Principal | None] = ContextVar( + "vidxp_mcp_principal", + default=None, +) +_ERROR_CODES = { + ErrorCategory.validation: -32602, + ErrorCategory.authentication: -32001, + ErrorCategory.authorization: -32003, + ErrorCategory.not_found: -32004, + ErrorCategory.conflict: -32009, + ErrorCategory.resource_limit: -32029, + ErrorCategory.cancelled: -32040, + ErrorCategory.unavailable: -32050, + ErrorCategory.internal: -32603, +} +_READ_ONLY = ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, +) +_SUBMIT = ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, +) +_CANCEL = ToolAnnotations( + read_only_hint=False, + destructive_hint=True, + idempotent_hint=True, + open_world_hint=False, +) + + +class PrincipalBridge: + """Carry the principal validated by the outer ASGI boundary into tools.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + principal = scope.get("vidxp.principal") + reset = None + if isinstance(principal, Principal): + reset = _REQUEST_PRINCIPAL.set(principal) + try: + await self.app(scope, receive, send) + finally: + if reset is not None: + _REQUEST_PRINCIPAL.reset(reset) + + +class VidXPTokenVerifier: + """Adapt the shared OIDC validator to the SDK resource-server contract.""" + + def __init__(self, authenticator: OIDCBearerAuthenticator) -> None: + self.authenticator = authenticator + + async def verify_token(self, token: str) -> AccessToken | None: + try: + verified = await anyio.to_thread.run_sync( + self.authenticator.authenticate_bearer, + token, + ) + except ApplicationError: + return None + principal = verified.principal + return AccessToken( + token=token, + client_id=principal.client_id or principal.subject, + subject=principal.subject, + scopes=sorted(principal.scopes), + expires_at=verified.expires_at, + resource=verified.resource, + claims=verified.claims, + ) + + +@dataclass(frozen=True) +class RemoteMCP: + server: MCPServer + app: ASGIApp + owns_authentication: bool + transport_security: TransportSecuritySettings + + +def _principal(default: Principal | None) -> Principal: + current = _REQUEST_PRINCIPAL.get() + if current is not None: + return current + token = get_access_token() + if token is not None: + return Principal( + subject=token.subject or token.client_id, + client_id=token.client_id, + scopes=frozenset(token.scopes), + ) + if default is not None: + return default + raise MCPError( + -32001, + "Valid bearer authentication is required.", + { + "code": "authentication_required", + "category": "authentication", + "retryable": False, + }, + ) + + +def _application_error(exc: ApplicationError) -> ToolError: + detail = exc.detail + data = { + "code": detail.code, + "category": detail.category.value, + "retryable": detail.retryable, + } + if detail.correlation_id is not None: + data["correlation_id"] = detail.correlation_id + for key in ("errors", "required_scope"): + if key in detail.details: + data[key] = detail.details[key] + return ToolError( + json.dumps( + { + "error": { + "protocol_code": _ERROR_CODES[detail.category], + "message": detail.message, + **data, + } + }, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ) + ) + + +def _invoke( + context: ControlPlaneContext, + *, + default_principal: Principal | None, + permission: RepositoryPermission, + operation: Callable[[Principal], _T], +) -> _T: + try: + principal = context.authorization.require( + _principal(default_principal), + permission, + ) + return operation(principal) + except MCPError: + raise + except ApplicationError as exc: + raise _application_error(exc) from exc + except Exception as exc: + _LOGGER.exception("Unexpected MCP tool failure.") + raise MCPError( + -32603, + "The MCP tool failed unexpectedly.", + { + "code": "internal_error", + "category": "internal", + "retryable": False, + }, + ) from exc + + +async def _invoke_async( + context: ControlPlaneContext, + *, + default_principal: Principal | None, + permission: RepositoryPermission, + operation: Callable[[Principal], _T], +) -> _T: + return await anyio.to_thread.run_sync( + partial( + _invoke, + context, + default_principal=default_principal, + permission=permission, + operation=operation, + ), + abandon_on_cancel=True, + ) + + +class MCPTransportSecurityBoundary: + """Run the SDK's transport checks before outer static authentication.""" + + def __init__( + self, + app: ASGIApp, + *, + settings: TransportSecuritySettings, + ) -> None: + self.app = app + self.security = TransportSecurityMiddleware(settings) + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + if ( + scope["type"] == "http" + and str(scope.get("path", "")) in {"/mcp", "/mcp/"} + ): + response = await self.security.validate_request( + Request(scope, receive=receive), + is_post=str(scope.get("method", "")).upper() == "POST", + ) + if response is not None: + await response(scope, receive, send) + return + await self.app(scope, receive, send) + + +def create_mcp_server( + context: ControlPlaneContext, + *, + default_principal: Principal | None = None, + oidc_authentication: bool = False, +) -> MCPServer: + settings = context.settings + token_verifier = None + auth = None + if oidc_authentication: + assert settings.http_oidc_issuer is not None + assert settings.mcp_public_url is not None + if ( + isinstance(context, HttpApplicationContext) + and isinstance( + context.authenticator, + OIDCBearerAuthenticator, + ) + ): + authenticator = context.authenticator.for_audience( + settings.mcp_public_url, + ) + else: + authenticator = create_authenticator( + settings, + audience=settings.mcp_public_url, + required_scopes=(), + ) + if not isinstance(authenticator, OIDCBearerAuthenticator): + raise ValueError("OIDC MCP requires the OIDC authenticator.") + token_verifier = VidXPTokenVerifier(authenticator) + auth = AuthSettings( + issuer_url=settings.http_oidc_issuer, + resource_server_url=settings.mcp_public_url, + required_scopes=list(settings.http_required_scopes), + ) + + server = MCPServer( + name="vidxp", + title="VidXP", + description="Index and search registered video media.", + instructions=( + "Register and upload video through the HTTP/tus API first. " + "Use the resulting media_id with these durable MCP tools." + ), + version=__version__, + token_verifier=token_verifier, + auth=auth, + ) + + @server.tool( + description="List installed VidXP capabilities.", + annotations=_READ_ONLY, + structured_output=True, + ) + async def list_capabilities() -> CapabilityList: + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=lambda _actor: CapabilityList( + items=context.application.list_capabilities() + ), + ) + + @server.tool( + description="Get one capability and its public operation schemas.", + annotations=_READ_ONLY, + structured_output=True, + ) + async def get_capability(name: Identifier) -> CapabilityInfo: + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=lambda _actor: context.application.get_capability(name), + ) + + @server.tool( + description="Inspect the active index state.", + annotations=_READ_ONLY, + structured_output=True, + ) + async def get_index_status() -> IndexStatus: + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=lambda _actor: context.application.index_status(), + ) + + @server.tool( + description="Submit durable indexing for a registered media ID.", + annotations=_SUBMIT, + structured_output=True, + ) + async def start_indexing( + command: CreateIndexCommand, + idempotency_key: IdempotencyKey, + ) -> Job: + def submit(actor: Principal) -> Job: + return context.jobs.submit_index( + command, + job_id=scoped_job_id( + repository_id=settings.repository_id, + principal=actor, + transport="mcp", + operation="index", + idempotency_key=idempotency_key, + ), + ) + + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.write, + operation=submit, + ) + + @server.tool( + description="Submit a durable search over the active index.", + annotations=_SUBMIT, + structured_output=True, + ) + async def search_moments( + command: SearchCommand, + idempotency_key: IdempotencyKey, + ) -> Job: + def submit(actor: Principal) -> Job: + return context.jobs.submit_search( + command, + job_id=scoped_job_id( + repository_id=settings.repository_id, + principal=actor, + transport="mcp", + operation="search", + idempotency_key=idempotency_key, + ), + ) + + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=submit, + ) + + @server.tool( + description="Poll a durable VidXP job and its typed result.", + annotations=_READ_ONLY, + structured_output=True, + ) + async def get_job(job_id: JobId) -> Job: + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=lambda _actor: context.jobs.get(job_id), + ) + + @server.tool( + description="Explicitly cancel an active durable job.", + annotations=_CANCEL, + structured_output=True, + ) + async def cancel_job(job_id: JobId) -> Job: + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.write, + operation=lambda _actor: context.jobs.cancel(job_id), + ) + + return server + + +def create_remote_mcp(context: ControlPlaneContext) -> RemoteMCP: + owns_authentication = ( + context.settings.http_auth_mode == HttpAuthMode.oidc + ) + server = create_mcp_server( + context, + oidc_authentication=owns_authentication, + ) + transport_security = TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=list(context.settings.mcp_allowed_hosts), + allowed_origins=list(context.settings.mcp_allowed_origins), + ) + app = server.streamable_http_app( + streamable_http_path="/mcp", + stateless_http=True, + json_response=False, + max_request_body_size=( + context.settings.mcp_max_request_body_bytes + ), + transport_security=transport_security, + host=context.settings.http_bind_host, + ) + return RemoteMCP( + server=server, + app=PrincipalBridge(app), + owns_authentication=owns_authentication, + transport_security=transport_security, + ) diff --git a/src/vidxp/mcp_cli.py b/src/vidxp/mcp_cli.py new file mode 100644 index 0000000..9ddb430 --- /dev/null +++ b/src/vidxp/mcp_cli.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import argparse +from typing import Sequence + +from vidxp.application_models import Principal +from vidxp.composition import ( + create_control_plane_application, + create_local_application, +) +from vidxp.mcp import create_mcp_server + + +def main(arguments: Sequence[str] | None = None) -> None: + parser = argparse.ArgumentParser( + description="Run the local VidXP MCP server over stdio." + ) + parser.add_argument("--registry") + parser.add_argument("--repository") + parser.add_argument("--index-directory") + parser.add_argument("--device") + options = parser.parse_args(arguments) + + local = create_local_application( + registry_path=options.registry, + repository_name=options.repository, + index_directory=options.index_directory, + device=options.device, + ) + context = create_control_plane_application(local.settings) + try: + create_mcp_server( + context, + default_principal=Principal( + subject="local", + client_id="stdio", + scopes=frozenset({"*"}), + ), + ).run("stdio") + finally: + context.close() + local.close() + + +if __name__ == "__main__": + main() diff --git a/src/vidxp/ports.py b/src/vidxp/ports.py index 3a48229..38745e1 100644 --- a/src/vidxp/ports.py +++ b/src/vidxp/ports.py @@ -321,7 +321,7 @@ def active_config( index_directory: Path, *, device: str, - ) -> tuple[IndexConfig, dict[str, Any]]: ... + ) -> IndexConfig: ... def config_for_snapshot( self, diff --git a/src/vidxp/read_job_planner.py b/src/vidxp/read_job_planner.py index 02c3f16..2012bb9 100644 --- a/src/vidxp/read_job_planner.py +++ b/src/vidxp/read_job_planner.py @@ -26,7 +26,7 @@ def __init__( self.index = index or LocalIndexReader(layout) def _active(self): - config, _ = self.index.active_config( + config = self.index.active_config( self.layout.indexes, device="cpu", ) diff --git a/src/vidxp/requirements/mcp.txt b/src/vidxp/requirements/mcp.txt new file mode 100644 index 0000000..12a0a3a --- /dev/null +++ b/src/vidxp/requirements/mcp.txt @@ -0,0 +1 @@ +mcp>=2.0,<3 diff --git a/src/vidxp/settings.py b/src/vidxp/settings.py index 079ca2d..e8550a5 100644 --- a/src/vidxp/settings.py +++ b/src/vidxp/settings.py @@ -116,6 +116,23 @@ class VidXPSettings(BaseSettings): gt=0, le=256 * 1024 * 1024, ) + mcp_public_url: str | None = Field( + default=None, + min_length=1, + max_length=2048, + ) + mcp_max_request_body_bytes: int = Field( + default=4 * 1024 * 1024, + gt=0, + le=16 * 1024 * 1024, + ) + mcp_allowed_hosts: tuple[str, ...] = ( + "127.0.0.1:*", + "[::1]:*", + "localhost:*", + "testserver", + ) + mcp_allowed_origins: tuple[str, ...] = () upload_public_endpoint: str | None = Field( default=None, min_length=1, @@ -191,6 +208,8 @@ def _clean_allowlist(cls, values: tuple[str, ...]) -> tuple[str, ...]: "http_required_scopes", "http_trusted_hosts", "http_allowed_origins", + "mcp_allowed_hosts", + "mcp_allowed_origins", ) @classmethod def _clean_http_lists(cls, values: tuple[str, ...]) -> tuple[str, ...]: @@ -219,6 +238,58 @@ def _validate_trusted_hosts( ) return normalized + @field_validator("mcp_allowed_hosts") + @classmethod + def _validate_mcp_hosts( + cls, + values: tuple[str, ...], + ) -> tuple[str, ...]: + normalized = tuple(value.lower() for value in values) + for value in normalized: + if "*" in value and not value.endswith(":*"): + raise ValueError( + "MCP host wildcards are supported only as host:*." + ) + if "/" in value or "://" in value: + raise ValueError( + "MCP allowed hosts must be Host header values." + ) + return normalized + + @field_validator("mcp_allowed_origins") + @classmethod + def _validate_mcp_origins( + cls, + values: tuple[str, ...], + ) -> tuple[str, ...]: + for value in values: + candidate = value[:-2] if value.endswith(":*") else value + parsed = urlsplit(candidate) + if ( + value == "null" + or parsed.scheme not in {"http", "https"} + or parsed.hostname is None + or parsed.username is not None + or parsed.password is not None + or parsed.path + or parsed.query + or parsed.fragment + ): + raise ValueError( + "MCP allowed origins must be serialized HTTP origins." + ) + try: + parsed.port + except ValueError as exc: + raise ValueError( + "An MCP allowed origin contains an invalid port." + ) from exc + if "*" in value and not value.endswith(":*"): + raise ValueError( + "MCP origin wildcards are supported only as origin:*." + ) + return values + @field_validator("http_oidc_algorithms") @classmethod def _validate_oidc_algorithms( @@ -339,6 +410,36 @@ def _validate_service_url( raise ValueError(f"{info.field_name} must end with a slash.") return value + @field_validator("mcp_public_url") + @classmethod + def _validate_mcp_public_url(cls, value: str | None) -> str | None: + if value is None: + return None + if value != value.strip() or "\\" in value: + raise ValueError("mcp_public_url contains unsafe characters.") + parsed = urlsplit(value) + if ( + parsed.scheme not in {"http", "https"} + or parsed.hostname is None + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or parsed.path.rstrip("/") != "/mcp" + ): + raise ValueError( + "mcp_public_url must be a plain HTTP URL ending in /mcp." + ) + if ( + parsed.scheme != "https" + and parsed.hostname.lower() + not in {"localhost", "127.0.0.1", "::1"} + ): + raise ValueError( + "mcp_public_url must use HTTPS outside loopback." + ) + return value.rstrip("/") + @field_validator("trusted_local_import_roots") @classmethod def _clean_import_roots( @@ -436,6 +537,15 @@ def validate_http_server(self) -> None: ) if not self.http_trusted_hosts: raise ValueError("At least one trusted HTTP host is required.") + if not self.mcp_allowed_hosts: + raise ValueError("At least one allowed MCP host is required.") + if ( + self.http_auth_mode == HttpAuthMode.oidc + and self.mcp_public_url is None + ): + raise ValueError( + "OIDC MCP authentication requires mcp_public_url." + ) @property def layout(self) -> RepositoryLayout: diff --git a/tests/test_api.py b/tests/test_api.py index b00316d..ccd9911 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -10,6 +10,8 @@ from unittest.mock import patch import httpx +import jwt +from cryptography.hazmat.primitives.asymmetric import rsa from fastapi.testclient import TestClient from vidxp.api import create_app @@ -90,6 +92,7 @@ def context( auth: HttpAuthMode = HttpAuthMode.none, upload_limit: int = 256 * 1024 * 1024, json_limit: int = 4 * 1024 * 1024, + mcp_limit: int = 4 * 1024 * 1024, allowed_origins: tuple[str, ...] = (), remote_uploads: bool = False, ) -> HttpApplicationContext: @@ -99,8 +102,34 @@ def context( minimum_available_memory_mb=0, http_auth_mode=auth, http_static_bearer_token=TOKEN if auth == HttpAuthMode.static else None, + http_oidc_issuer=( + "https://issuer.example" + if auth == HttpAuthMode.oidc + else None + ), + http_oidc_audience=( + "https://api.example" + if auth == HttpAuthMode.oidc + else None + ), + http_oidc_jwks_url=( + "https://issuer.example/jwks" + if auth == HttpAuthMode.oidc + else None + ), + http_required_scopes=( + ("vidxp.read",) + if auth == HttpAuthMode.oidc + else () + ), + mcp_public_url=( + "https://api.example/mcp" + if auth == HttpAuthMode.oidc + else None + ), http_max_small_upload_bytes=upload_limit, http_max_json_body_bytes=json_limit, + mcp_max_request_body_bytes=mcp_limit, http_allowed_origins=allowed_origins, upload_public_endpoint=( "http://localhost:8080/uploads/" @@ -799,18 +828,25 @@ def test_http_composition_does_not_construct_model_runtime(self): finally: context.close() - def test_reserved_mcp_namespace_uses_api_auth_and_body_policy(self): + def test_mcp_namespace_uses_static_auth_and_sdk_body_policy(self): with TemporaryDirectory() as directory: context = self.context( Path(directory), auth=HttpAuthMode.static, - json_limit=32, + mcp_limit=32, ) with TestClient(create_app(context=context)) as client: - unauthorized = client.post("/mcp", content=b"x" * 64) + unauthorized = client.post( + "/mcp", + headers={"Content-Type": "application/json"}, + content=b"{}", + ) oversized = client.post( "/mcp", - headers=self.auth(), + headers={ + **self.auth(), + "Content-Type": "application/json", + }, content=b"x" * 64, ) metadata = client.get( @@ -823,16 +859,122 @@ def test_reserved_mcp_namespace_uses_api_auth_and_body_policy(self): "authentication_required", ) self.assertEqual(oversized.status_code, 413) - self.assertEqual( - oversized.json()["error"]["code"], - "request_body_too_large", - ) self.assertEqual(metadata.status_code, 401) self.assertEqual( metadata.json()["error"]["code"], "authentication_required", ) + def test_oidc_mcp_metadata_is_public_and_sdk_auth_challenges(self): + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + auth=HttpAuthMode.oidc, + ) + with TestClient(create_app(context=context)) as client: + metadata = client.get( + "/.well-known/oauth-protected-resource/mcp" + ) + challenged = client.post( + "/mcp", + headers={ + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2026-07-28", + "capabilities": {}, + "clientInfo": { + "name": "test", + "version": "1", + }, + }, + }, + ) + + self.assertEqual(metadata.status_code, 200) + self.assertEqual( + metadata.json()["resource"], + "https://api.example/mcp", + ) + self.assertEqual( + metadata.json()["authorization_servers"], + ["https://issuer.example"], + ) + self.assertEqual(challenged.status_code, 401) + self.assertIn( + "/.well-known/oauth-protected-resource/mcp", + challenged.headers["www-authenticate"], + ) + + def test_oidc_mcp_valid_token_with_missing_scope_returns_403(self): + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + public_jwk = jwt.PyJWK.from_dict( + { + **jwt.algorithms.RSAAlgorithm.to_jwk( + private_key.public_key(), + as_dict=True, + ), + "kid": "mcp-key", + "alg": "RS256", + "use": "sig", + } + ) + now = datetime.now(timezone.utc) + token = jwt.encode( + { + "sub": "user-1", + "iss": "https://issuer.example", + "aud": "https://api.example/mcp", + "scope": "other", + "iat": now, + "exp": now.replace(year=now.year + 1), + }, + private_key, + algorithm="RS256", + headers={"kid": "mcp-key"}, + ) + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + auth=HttpAuthMode.oidc, + ) + context.authenticator._jwks = Mock() + context.authenticator._jwks.get_signing_key_from_jwt.return_value = ( + public_jwk + ) + with TestClient(create_app(context=context)) as client: + response = client.post( + "/mcp", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2026-07-28", + "capabilities": {}, + "clientInfo": { + "name": "test", + "version": "1", + }, + }, + }, + ) + + self.assertEqual(response.status_code, 403) + def test_typed_application_error_status_mapping(self): with TemporaryDirectory() as directory: context = self.context(Path(directory)) diff --git a/tests/test_application.py b/tests/test_application.py index 16e9576..d66a228 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -19,6 +19,7 @@ DependencyUnavailableError, IndexResult, IndexSnapshotReference, + ModelUnavailableError, PrepareModelsCommand, RemoveIndexCommand, SearchCommand, @@ -26,6 +27,7 @@ from vidxp.core.media import MediaUnavailableError from vidxp.core.contracts import IndexConfig, IndexSchemaError from vidxp.infrastructure.local_index import LocalIndexBackend +from vidxp.model_contracts import ModelArtifactUnavailableError from vidxp.capabilities.contracts import ( CapabilityDefinition, CapabilityExecutor, @@ -125,12 +127,9 @@ def indexed_application( "unused", registry=registry, ) - backend.active_config.return_value = ( - IndexConfig.local( - enabled_modalities=("indexed",), - collection_names={"indexed": "indexed"}, - ), - {}, + backend.active_config.return_value = IndexConfig.local( + enabled_modalities=("indexed",), + collection_names={"indexed": "indexed"}, ) backend.open_store.return_value = manager return application @@ -590,12 +589,9 @@ def test_downstream_missing_file_is_not_misclassified_as_media(self): def test_open_store_dependency_failure_is_stable(self): application, backend = self.application("unused") - backend.active_config.return_value = ( - IndexConfig.local( - enabled_modalities=("scene",), - collection_names={"scene": "scene"}, - ), - {}, + backend.active_config.return_value = IndexConfig.local( + enabled_modalities=("scene",), + collection_names={"scene": "scene"}, ) backend.open_store.side_effect = ModuleNotFoundError("chromadb") @@ -656,6 +652,31 @@ def test_prepare_dependency_failure_is_stable(self): json.dumps(raised.exception.to_dict()), ) + def test_missing_model_is_not_misclassified_as_a_package_dependency(self): + application, backend = self.application("unused") + application.media.require_record.return_value = Mock( + original_filename="video.mp4", + sha256="1" * 64, + ) + application.media.content.return_value = Mock( + path=Path("video.mp4") + ) + backend.create.side_effect = ModelArtifactUnavailableError("scene") + + with self.assertRaises(ModelUnavailableError) as raised: + application.create_index( + CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + ) + ) + + self.assertEqual(raised.exception.code, "model_unavailable") + self.assertNotIn( + "pip install", + json.dumps(raised.exception.to_dict()), + ) + def test_unexpected_handler_error_is_not_misclassified(self): failure = RuntimeError("implementation bug") definition = CapabilityDefinition( diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 3aee420..401cbca 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -4,6 +4,7 @@ import jwt from cryptography.hazmat.primitives.asymmetric import rsa +from pydantic import ValidationError from vidxp.application_models import ApplicationError from vidxp.authentication import ( @@ -234,6 +235,62 @@ def test_oidc_settings_preserve_uppercase_scheme_for_exact_issuer(self): "HTTPS://issuer.example", ) + def test_oidc_http_server_requires_canonical_mcp_resource(self): + settings = VidXPSettings( + runtime_backend="cpu", + http_auth_mode="oidc", + http_oidc_issuer="https://issuer.example", + http_oidc_audience="https://api.example", + http_oidc_jwks_url="https://issuer.example/jwks", + http_required_scopes=("vidxp.read",), + ) + + with self.assertRaisesRegex(ValueError, "mcp_public_url"): + settings.validate_http_server() + + def test_mcp_public_resource_and_host_policy_are_strict(self): + with self.assertRaisesRegex(ValidationError, "ending in /mcp"): + VidXPSettings(mcp_public_url="https://api.example/not-mcp") + with self.assertRaisesRegex( + ValidationError, + "host wildcards", + ): + VidXPSettings(mcp_allowed_hosts=("*.example.com",)) + for origin in ( + "null", + "file://local", + "https://user@example.com", + "https://client.example/path", + "https://client.example?query", + ): + with self.subTest(origin=origin), self.assertRaisesRegex( + ValidationError, + "serialized HTTP origins", + ): + VidXPSettings(mcp_allowed_origins=(origin,)) + + settings = VidXPSettings( + mcp_public_url="https://api.example/mcp/", + mcp_allowed_hosts=("API.EXAMPLE.COM",), + mcp_allowed_origins=("http://localhost:*",), + ) + self.assertEqual( + settings.mcp_public_url, + "https://api.example/mcp", + ) + self.assertEqual( + settings.mcp_allowed_hosts, + ("api.example.com",), + ) + self.assertEqual( + settings.mcp_allowed_origins, + ("http://localhost:*",), + ) + + disabled = VidXPSettings(mcp_allowed_hosts=()) + with self.assertRaisesRegex(ValueError, "allowed MCP host"): + disabled.validate_http_server() + def test_oidc_rejects_empty_query_and_fragment_delimiters(self): for field, value in ( ("http_oidc_issuer", "https://issuer.example?"), diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 474ef75..0c26b7f 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -79,6 +79,21 @@ def test_internal_actor_cluster_lookup_is_not_advertised(self): self.assertIn("clusters", operations) self.assertIn("detections", operations) + def test_capability_list_is_summary_only(self): + service = CapabilityService(self.registry) + + summaries = service.list() + detail = service.get("scene") + + self.assertEqual( + tuple(summary.name for summary in summaries), + self.registry.names(), + ) + self.assertTrue(detail.operations) + self.assertTrue( + all(not hasattr(summary, "operations") for summary in summaries) + ) + def test_executor_handlers_match_declared_operations(self): for name, definition in self.registry.definitions.items(): self.assertEqual( diff --git a/tests/test_mcp.py b/tests/test_mcp.py new file mode 100644 index 0000000..0e0cafc --- /dev/null +++ b/tests/test_mcp.py @@ -0,0 +1,386 @@ +import unittest +import sys +import asyncio +import socket +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import Mock + +import httpx2 +import uvicorn +from fastapi.testclient import TestClient +from mcp.client import Client +from mcp.client.stdio import StdioServerParameters, stdio_client +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.exceptions import MCPError + +from vidxp.application_models import ( + ApplicationError, + ErrorCategory, + IndexStatus, + Job, + JobKind, + JobQueue, + JobState, + Principal, +) +from vidxp.authentication import ( + AuthenticatedBearer, + OIDCBearerAuthenticator, + create_authenticator, +) +from vidxp.api import create_app +from vidxp.authorization import AuthorizationPolicy +from vidxp.composition import HttpApplicationContext +from vidxp.control_plane import ControlPlaneApplication +from vidxp.job_service import JobService +from vidxp.mcp import VidXPTokenVerifier, create_mcp_server +from vidxp.settings import VidXPSettings + + +MEDIA_ID = "123456781234423481234567890abcde" +JOB_ID = "223456781234423481234567890abcde" + + +def queued_job() -> Job: + return Job( + job_id=JOB_ID, + kind=JobKind.index, + state=JobState.queued, + queue=JobQueue.cpu, + ) + + +class MCPTests(unittest.IsolatedAsyncioTestCase): + def context( + self, + root: Path, + *, + static_token: str | None = None, + http_trusted_hosts: tuple[str, ...] = ( + "127.0.0.1", + "testserver", + ), + mcp_allowed_hosts: tuple[str, ...] = ("127.0.0.1:*",), + mcp_allowed_origins: tuple[str, ...] = (), + ) -> HttpApplicationContext: + settings = VidXPSettings( + repository_root=root, + runtime_backend="cpu", + minimum_available_memory_mb=0, + http_auth_mode="static" if static_token is not None else "none", + http_static_bearer_token=static_token, + http_trusted_hosts=http_trusted_hosts, + mcp_allowed_hosts=mcp_allowed_hosts, + mcp_allowed_origins=mcp_allowed_origins, + ) + application = Mock(spec=ControlPlaneApplication) + application.list_capabilities.return_value = () + application.index_status.return_value = IndexStatus( + schema_version=1, + state="missing", + stage="status", + message="No index.", + ) + jobs = Mock(spec=JobService) + readiness = Mock() + readiness.ready.return_value = True + return HttpApplicationContext( + application=application, + jobs=jobs, + readiness=readiness, + authenticator=create_authenticator(settings), + authorization=AuthorizationPolicy(), + settings=settings, + ) + + async def test_curated_tools_have_structured_output_schemas(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + server = create_mcp_server( + context, + default_principal=Principal( + subject="local", + scopes=frozenset({"*"}), + ), + ) + async with Client(server) as client: + discovered = await client.list_tools() + result = await client.call_tool("list_capabilities", {}) + + self.assertEqual( + [tool.name for tool in discovered.tools], + [ + "list_capabilities", + "get_capability", + "get_index_status", + "start_indexing", + "search_moments", + "get_job", + "cancel_job", + ], + ) + self.assertTrue( + all(tool.output_schema is not None for tool in discovered.tools) + ) + self.assertEqual(result.structured_content, {"items": []}) + self.assertFalse(result.is_error) + + async def test_index_submission_uses_shared_stable_idempotency(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.jobs.submit_index.return_value = queued_job() + server = create_mcp_server( + context, + default_principal=Principal( + subject="agent", + scopes=frozenset({"vidxp.write"}), + ), + ) + arguments = { + "command": { + "media_id": MEDIA_ID, + "modalities": ["scene"], + }, + "idempotency_key": "agent-request-0001", + } + async with Client(server) as client: + first = await client.call_tool("start_indexing", arguments) + second = await client.call_tool("start_indexing", arguments) + + self.assertEqual( + first.structured_content, + second.structured_content, + ) + calls = context.jobs.submit_index.call_args_list + self.assertEqual(len(calls), 2) + self.assertEqual( + calls[0].kwargs["job_id"], + calls[1].kwargs["job_id"], + ) + + async def test_application_errors_are_machine_readable_and_safe(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.application.index_status.side_effect = ApplicationError( + "index_busy", + ErrorCategory.conflict, + "The index is busy.", + retryable=True, + ) + server = create_mcp_server( + context, + default_principal=Principal( + subject="agent", + scopes=frozenset({"vidxp.read"}), + ), + ) + async with Client(server) as client: + result = await client.call_tool("get_index_status", {}) + + self.assertTrue(result.is_error) + error_text = result.content[0].text + self.assertIn('"code":"index_busy"', error_text) + self.assertIn('"protocol_code":-32009', error_text) + self.assertIn('"retryable":true', error_text) + + async def test_unexpected_errors_do_not_leak_exception_text(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.application.index_status.side_effect = RuntimeError( + "database-password" + ) + server = create_mcp_server( + context, + default_principal=Principal( + subject="agent", + scopes=frozenset({"vidxp.read"}), + ), + ) + async with Client(server) as client: + with self.assertRaises(MCPError) as caught: + await client.call_tool("get_index_status", {}) + + self.assertEqual(caught.exception.code, -32603) + self.assertNotIn("database-password", caught.exception.message) + self.assertEqual(caught.exception.data["code"], "internal_error") + + async def test_write_tools_enforce_repository_scope(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + server = create_mcp_server( + context, + default_principal=Principal( + subject="reader", + scopes=frozenset({"vidxp.read"}), + ), + ) + async with Client(server) as client: + result = await client.call_tool( + "start_indexing", + { + "command": { + "media_id": MEDIA_ID, + "modalities": ["scene"], + }, + "idempotency_key": "agent-request-0001", + }, + ) + + self.assertTrue(result.is_error) + self.assertIn('"protocol_code":-32003', result.content[0].text) + self.assertIn('"required_scope":"vidxp.write"', result.content[0].text) + context.jobs.submit_index.assert_not_called() + + async def test_stdio_entrypoint_serves_the_same_curated_surface(self): + with TemporaryDirectory() as directory: + parameters = StdioServerParameters( + command=sys.executable, + args=[ + "-m", + "vidxp.mcp_cli", + "--index-directory", + directory, + "--device", + "cpu", + ], + cwd=Path(__file__).parents[1], + ) + async with Client(stdio_client(parameters)) as client: + discovered = await client.list_tools() + result = await client.call_tool("list_capabilities", {}) + + self.assertEqual( + [tool.name for tool in discovered.tools], + [ + "list_capabilities", + "get_capability", + "get_index_status", + "start_indexing", + "search_moments", + "get_job", + "cancel_job", + ], + ) + self.assertEqual( + [item["name"] for item in result.structured_content["items"]], + ["dialogue", "scene", "actor"], + ) + + async def test_streamable_http_works_with_the_official_remote_client(self): + token = "s" * 32 + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + listener.close() + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + static_token=token, + ) + server = uvicorn.Server( + uvicorn.Config( + create_app(context=context), + host="127.0.0.1", + port=port, + log_level="critical", + ) + ) + serving = asyncio.create_task(server.serve()) + try: + for _attempt in range(200): + if server.started: + break + if serving.done(): + await serving + await asyncio.sleep(0.01) + else: + self.fail("The MCP HTTP fixture did not start.") + async with httpx2.AsyncClient( + headers={"Authorization": f"Bearer {token}"} + ) as http_client: + transport = streamable_http_client( + f"http://127.0.0.1:{port}/mcp", + http_client=http_client, + ) + async with Client(transport) as client: + discovered = await client.list_tools() + result = await client.call_tool( + "list_capabilities", + {}, + ) + finally: + server.should_exit = True + await serving + + self.assertEqual(len(discovered.tools), 7) + self.assertEqual(result.structured_content, {"items": []}) + + async def test_oidc_verifier_projects_the_shared_validated_token(self): + authenticator = Mock(spec=OIDCBearerAuthenticator) + authenticator.authenticate_bearer.return_value = AuthenticatedBearer( + principal=Principal( + subject="user-1", + client_id="client-1", + scopes=frozenset({"vidxp.read"}), + ), + expires_at=1_800_000_000, + resource="https://api.example/mcp", + claims={"iss": "https://issuer.example"}, + ) + + token = await VidXPTokenVerifier(authenticator).verify_token("token") + + self.assertEqual(token.subject, "user-1") + self.assertEqual(token.client_id, "client-1") + self.assertEqual(token.scopes, ["vidxp.read"]) + self.assertEqual(token.expires_at, 1_800_000_000) + self.assertEqual(token.resource, "https://api.example/mcp") + + def test_sdk_transport_security_rejects_host_and_origin(self): + token = "s" * 32 + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + static_token=token, + http_trusted_hosts=("*",), + mcp_allowed_hosts=("mcp.example",), + mcp_allowed_origins=("https://client.example",), + ) + with TestClient(create_app(context=context)) as client: + bad_host = client.post( + "/mcp", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Host": "other.example", + }, + json={}, + ) + bad_origin = client.post( + "/mcp", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Host": "mcp.example", + "Origin": "https://other.example", + }, + json={}, + ) + unauthenticated_bad_origin = client.post( + "/mcp", + headers={ + "Content-Type": "application/json", + "Host": "mcp.example", + "Origin": "https://other.example", + }, + json={}, + ) + + self.assertEqual(bad_host.status_code, 421) + self.assertEqual(bad_origin.status_code, 403) + self.assertEqual(unauthenticated_bad_origin.status_code, 403) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_models.py b/tests/test_models.py index 1951687..17d3a10 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -25,8 +25,15 @@ from vidxp.capabilities.registry import create_capability_registry from vidxp.application_models import DependencyKind from vidxp.core.contracts import IndexConfig, VideoSource -from vidxp.dependencies import inspect_requirement -from vidxp.infrastructure.local_index import LOCAL_INDEX_RUNTIME_CHECKS +from vidxp.dependencies import ( + active_requirements, + inspect_requirement, + packaged_requirements, +) +from vidxp.infrastructure.local_index import ( + LOCAL_INDEX_RUNTIME_CHECKS, + SERVER_INDEX_RUNTIME_CHECKS, +) from vidxp.model_contracts import ModelArtifactUnavailableError, ModelKey from vidxp.ports import ResourceLimitError from vidxp.runtime import ModelRuntime, resolve_backends @@ -335,6 +342,25 @@ def test_platform_runtime_checks_are_storage_owned_and_not_duplicated(self): ], ) + def test_server_storage_checks_match_the_remote_chroma_client(self): + registry = create_capability_registry( + platform_runtime_checks=SERVER_INDEX_RUNTIME_CHECKS, + storage_requirements=active_requirements( + packaged_requirements( + "vidxp", + "requirements/server-storage.txt", + ) + ), + ) + + requirements = { + requirement.name + for requirement in registry.requirements_for(("scene",)) + } + + self.assertIn("chromadb-client", requirements) + self.assertNotIn("chromadb", requirements) + def test_transcript_only_excludes_transcription_provider(self): registry = create_capability_registry() source = VideoSource( diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 4666501..1db0d38 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -111,8 +111,10 @@ def test_base_dependencies_exclude_capability_runtimes(self): def test_install_profiles_keep_server_free_of_ml_dependencies(self): pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8") self.assertIn("local-worker = { file = [", pyproject) + self.assertIn("mcp = { file = [", pyproject) + self.assertIn("server = { file = [", pyproject) self.assertIn( - 'server = { file = ["src/vidxp/requirements/server.txt"] }', + '"src/vidxp/requirements/mcp.txt"', pyproject, ) server = ( @@ -138,6 +140,12 @@ def test_install_profiles_keep_server_free_of_ml_dependencies(self): self.assertNotIn("fastapi-mcp", server) self.assertNotIn("\nmcp", server) self.assertNotIn("httpx2", server) + mcp_requirements = ( + ROOT / "src" / "vidxp" / "requirements" / "mcp.txt" + ).read_text(encoding="utf-8") + self.assertIn("mcp>=2.0,<3", mcp_requirements) + self.assertNotIn("fastmcp", mcp_requirements) + self.assertNotIn("fastapi-mcp", mcp_requirements) test_requirements = ( ROOT / "src" / "vidxp" / "requirements" / "test.txt" ).read_text(encoding="utf-8") diff --git a/tests/test_read_job_planner.py b/tests/test_read_job_planner.py index b1e2a21..265a2ea 100644 --- a/tests/test_read_job_planner.py +++ b/tests/test_read_job_planner.py @@ -27,10 +27,7 @@ def setUp(self): collection_names={"scene": "scene", "actor": "actor"}, ) self.index = Mock() - self.index.active_config.return_value = ( - self.config, - {"snapshot_id": SNAPSHOT_ID}, - ) + self.index.active_config.return_value = self.config self.planner = LocalReadJobPlanner( layout=self.layout, index=self.index, diff --git a/uv.lock b/uv.lock index d984bb4..08b911d 100644 --- a/uv.lock +++ b/uv.lock @@ -1073,6 +1073,19 @@ 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 = "httpcore2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, +] + [[package]] name = "httptools" version = "0.8.0" @@ -1131,6 +1144,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, +] + [[package]] name = "huggingface-hub" version = "1.25.1" @@ -1354,6 +1383,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mcp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -2794,6 +2861,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -3424,6 +3510,19 @@ version = "3.5.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/66/b7/4a1bc231e0681ebf339337b0cd05b91dc6a0d701fa852bb812e244b7a030/srt-3.5.3.tar.gz", hash = "sha256:4884315043a4f0740fd1f878ed6caa376ac06d70e135f306a6dc44632eed0cc0", size = 28296, upload-time = "2023-03-28T02:35:44.007Z" } +[[package]] +name = "sse-starlette" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/10/a34c656829ffc1c4b22ef36d70d9ebb6b99c020e2aeb17cee5485099f028/sse_starlette-3.4.6.tar.gz", hash = "sha256:725f8a1bd6d26ae1b2c9610c0ef5065dfdd496f3988d28adcf8c4b49dc25c627", size = 32542, upload-time = "2026-07-20T14:16:32.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/36/e10c1d1b7ca881d2625db2ec28508578499187bb1c389952c398474e1834/sse_starlette-3.4.6-py3-none-any.whl", hash = "sha256:56217ab4c9a9f9c5db7b21e08732d3e7c2b807f45231ad23de0551a24c4a41f6", size = 16516, upload-time = "2026-07-20T14:16:30.978Z" }, +] + [[package]] name = "starlette" version = "1.3.1" @@ -3642,6 +3741,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/67/8d85ca2323233ae3c0365a659c4e52ee1f587b440e4bc577e7d8e4416d0f/transformers-5.14.1-py3-none-any.whl", hash = "sha256:9db974c4079ede2d1a3ea7ca5a240df33f2cc26fc2b36ba64c5f2a4f43b6e725", size = 11625234, upload-time = "2026-07-16T09:41:54.143Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typer" version = "0.27.0" @@ -3837,6 +3945,9 @@ local-worker = [ { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "transformers" }, ] +mcp = [ + { name = "mcp" }, +] scene = [ { name = "chromadb" }, { name = "huggingface-hub" }, @@ -3853,6 +3964,7 @@ server = [ { name = "alembic" }, { name = "asgi-correlation-id" }, { name = "fastapi" }, + { name = "mcp" }, { name = "psutil" }, { name = "psycopg", extra = ["binary"] }, { name = "pyjwt", extra = ["crypto"] }, @@ -3917,6 +4029,8 @@ requires-dist = [ { name = "huggingface-hub", marker = "extra == 'local-worker'", specifier = ">=1.25.1,<2" }, { name = "huggingface-hub", marker = "extra == 'scene'", specifier = ">=1.25.1,<2" }, { name = "huggingface-hub", marker = "extra == 'server-worker'", specifier = ">=1.25.1,<2" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=2.0,<3" }, + { name = "mcp", marker = "extra == 'server'", specifier = ">=2.0,<3" }, { name = "numpy", marker = "extra == 'actor'", specifier = ">=2.3,<3" }, { name = "numpy", marker = "extra == 'all'", specifier = ">=2.3,<3" }, { name = "numpy", marker = "extra == 'local-worker'", specifier = ">=2.3,<3" }, @@ -3978,7 +4092,7 @@ requires-dist = [ { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.51,<0.52" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'server-worker'", specifier = ">=0.51,<0.52" }, ] -provides-extras = ["storage", "dialogue", "scene", "actor", "all", "local-worker", "server", "server-worker", "test", "frontend", "benchmarks"] +provides-extras = ["storage", "dialogue", "scene", "actor", "all", "local-worker", "mcp", "server", "server-worker", "test", "frontend", "benchmarks"] [[package]] name = "watchdog" From 5830c8d3f476d14da5dba202c4b5049c7fcf2efc Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 12:14:17 +0500 Subject: [PATCH 12/46] feat: add grounded multimodal queries --- README.md | 5 +- .../durable-search-compatibility.bugfix.md | 1 + changes/media-scoped-search.bugfix.md | 1 + changes/multimodal-query.feature.md | 1 + compose.coolify.yaml | 15 + docs/architecture/platform.md | 20 +- docs/benchmarking/results.md | 18 + docs/deployment/coolify.md | 27 +- pyproject.toml | 3 + src/vidxp/api_routes/jobs.py | 30 ++ src/vidxp/application.py | 246 ++++++++++-- src/vidxp/application_models.py | 250 +++++++++++- src/vidxp/capabilities/actor/operations.py | 1 + src/vidxp/capabilities/actor/results.py | 9 +- src/vidxp/capabilities/actor/schemas.py | 1 + src/vidxp/capabilities/dialogue/operations.py | 2 +- src/vidxp/capabilities/scene/operations.py | 2 +- src/vidxp/capabilities/schemas.py | 2 + src/vidxp/cli.py | 2 + src/vidxp/cli_commands/query.py | 82 ++++ src/vidxp/cli_commands/search.py | 6 +- src/vidxp/cli_support.py | 51 ++- src/vidxp/composition.py | 14 + src/vidxp/frontend.py | 97 ++++- src/vidxp/infrastructure/dbos_jobs.py | 43 +- src/vidxp/infrastructure/dbos_workflows.py | 61 ++- src/vidxp/infrastructure/ollama_query.py | 106 +++++ src/vidxp/job_service.py | 17 + src/vidxp/mcp.py | 32 ++ src/vidxp/ports.py | 19 + src/vidxp/query_service.py | 291 ++++++++++++++ src/vidxp/read_job_planner.py | 12 +- src/vidxp/requirements/slm.txt | 1 + src/vidxp/search_fusion.py | 160 ++++++++ src/vidxp/settings.py | 43 +- src/vidxp/workflow_contracts.py | 31 +- tests/test_api.py | 36 +- tests/test_application.py | 170 +++++++- tests/test_cli.py | 75 +++- tests/test_dbos_jobs.py | 238 ++++++++++- tests/test_frontend.py | 51 ++- tests/test_job_contracts.py | 58 ++- tests/test_mcp.py | 48 ++- tests/test_models.py | 45 +++ tests/test_ollama_query.py | 136 +++++++ tests/test_packaging.py | 29 ++ tests/test_query_service.py | 377 ++++++++++++++++++ tests/test_read_job_planner.py | 16 +- tests/test_search_fusion.py | 114 ++++++ uv.lock | 257 +++++++++++- 50 files changed, 3197 insertions(+), 155 deletions(-) create mode 100644 changes/durable-search-compatibility.bugfix.md create mode 100644 changes/media-scoped-search.bugfix.md create mode 100644 changes/multimodal-query.feature.md create mode 100644 src/vidxp/cli_commands/query.py create mode 100644 src/vidxp/infrastructure/ollama_query.py create mode 100644 src/vidxp/query_service.py create mode 100644 src/vidxp/requirements/slm.txt create mode 100644 src/vidxp/search_fusion.py create mode 100644 tests/test_ollama_query.py create mode 100644 tests/test_query_service.py create mode 100644 tests/test_search_fusion.py diff --git a/README.md b/README.md index f6f0995..894bb71 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ Search the completed index: ```bash vidxp search dialogue "the bread just came out of the oven" vidxp search scene "a yellow taxi on a city street" --top-k 5 +vidxp query "What happens after the taxi arrives?" --media-id vidxp actors list vidxp actors render --json vidxp artifacts snippet 30 45 --json @@ -204,7 +205,9 @@ The API process is a model-free control plane. Under `/api/v1`, it exposes bounded media import and delivery, capability metadata, index status, durable job submission and control, artifact delivery, and authenticated readiness. Indexing and other model work execute only through DBOS workers. Remote search -is submitted as a durable job so model work never runs in the API/MCP process. +and grounded natural-language queries are submitted as durable jobs so model +work never runs in the API/MCP process. `POST /api/v1/jobs/query` uses the same +typed command and result as `vidxp query` and the MCP `query_video` tool. Large remote media uses an upload intent plus the deployment profile's `tusd` service. Create the intent at `/api/v1/media/uploads`, then use the returned diff --git a/changes/durable-search-compatibility.bugfix.md b/changes/durable-search-compatibility.bugfix.md new file mode 100644 index 0000000..ce2c1c3 --- /dev/null +++ b/changes/durable-search-compatibility.bugfix.md @@ -0,0 +1 @@ +Version fused search workflows separately while keeping legacy atomic-search jobs readable, recoverable, and retryable. diff --git a/changes/media-scoped-search.bugfix.md b/changes/media-scoped-search.bugfix.md new file mode 100644 index 0000000..f275778 --- /dev/null +++ b/changes/media-scoped-search.bugfix.md @@ -0,0 +1 @@ +Keep interactive and public searches within the requested media item while preserving immutable snapshot scope. diff --git a/changes/multimodal-query.feature.md b/changes/multimodal-query.feature.md new file mode 100644 index 0000000..d9500f9 --- /dev/null +++ b/changes/multimodal-query.feature.md @@ -0,0 +1 @@ +Add durable multimodal search and grounded natural-language queries across the CLI, browser UI, HTTP API, and MCP server. diff --git a/compose.coolify.yaml b/compose.coolify.yaml index 3997b11..2e1015a 100644 --- a/compose.coolify.yaml +++ b/compose.coolify.yaml @@ -139,6 +139,8 @@ services: environment: <<: *vidxp-environment VIDXP_ALLOW_MODEL_DOWNLOADS: ${VIDXP_ALLOW_MODEL_DOWNLOADS:-true} + VIDXP_SLM_BASE_URL: ${VIDXP_SLM_BASE_URL:-} + VIDXP_SLM_MODEL: ${VIDXP_SLM_MODEL:-} command: ["vidxp-worker", "--role", "cpu"] volumes: - content-data:/var/lib/vidxp/data @@ -151,6 +153,18 @@ services: condition: service_completed_successfully restart: unless-stopped + ollama: + image: ollama/ollama:0.32.5@sha256:4dea9fb511947e24a84237bb636b0203abcb2ff0d3fbc7b4ff865deb91362131 + profiles: ["slm"] + volumes: + - ollama-models:/root/.ollama + healthcheck: + test: ["CMD", "ollama", "list"] + interval: 10s + timeout: 5s + retries: 20 + restart: unless-stopped + tusd: image: tusproject/tusd:v2.10.0@sha256:9610f0f8edf4cceeb26f1b3ac9fa4bb4803d61a878e222c1573cda63c9e23374 command: @@ -200,3 +214,4 @@ volumes: content-data: upload-quarantine: model-cache: + ollama-models: diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md index d3c989f..c9eb938 100644 --- a/docs/architecture/platform.md +++ b/docs/architecture/platform.md @@ -689,17 +689,18 @@ Flow: 2. Ask an injected local SLM planner for a strictly typed `QueryPlan`. 3. Validate that the plan uses registered operations and safe parameters only. 4. Execute retrieval through application search/capability operations. -5. Normalize, fuse and optionally rerank evidence. +5. Fuse overlapping intervals with deterministic reciprocal-rank fusion. 6. Ask the answer synthesizer for a grounded answer. 7. Return `QueryAnswer` with timestamped citations and supporting hits. Pydantic AI is the selected typed-call layer. The first local provider is self-hosted -Ollama >=0.5, pinned to a tested image digest, through pinned +Ollama 0.32.5, pinned to a tested multi-architecture image digest, through `pydantic-ai-slim[openai]` and `OllamaModel`. Ollama Cloud is excluded because it -does not enforce the local JSON-Schema path required here. `QueryPlan` and -`QueryAnswer` use `NativeOutput`, strict Pydantic models, a bounded request/output -retry budget, and explicit time/token limits. The model receives no executable -tools. Pydantic AI does not replace VidXP's capability registry or search storage. +does not enforce the local JSON-Schema path required here. `QueryPlan` and the +model-only `DraftAnswer` use `NativeOutput`, strict Pydantic models, a bounded +request/output retry budget, and explicit time/token limits. VidXP validates the +draft and constructs `QueryAnswer`; the model receives no executable tools. +Pydantic AI does not replace VidXP's capability registry or search storage. When the SLM is unavailable or produces an invalid plan, a deterministic fallback searches applicable modalities and returns evidence. Generated text can never become @@ -711,10 +712,11 @@ the retrieved hit ID, media ID, generation, and time interval. Missing, invented out-of-range citations invalidate the generated answer and trigger the deterministic evidence fallback. Typed JSON alone is never treated as proof of grounding. -The result records planner/model version and plan for reproducibility. +The result records the configured provider/model identity and plan for +reproducibility. -The exact SLM model ID, immutable revision/digest, and quantization are intentionally -blocked until candidate models pass: +The default SLM model ID, immutable revision/digest, and quantization are +intentionally unset until candidate models pass: - both output schemas - adversarial-plan rejection diff --git a/docs/benchmarking/results.md b/docs/benchmarking/results.md index a2da9a9..673f690 100644 --- a/docs/benchmarking/results.md +++ b/docs/benchmarking/results.md @@ -17,6 +17,24 @@ Detailed artifacts, hashes, commands, and evaluator behavior remain in the | HiREST | Official validation: 193 known-video searches | R@0.5 **78.24%**, R@0.7 **44.56%** | Validation result; the prediction-window setting was selected on this same validation set | | HiREST | Released test: 776 known-video searches | Predictions generated, no score | Public test boundaries are placeholders, so local scoring would be meaningless | +## Multimodal comparison contract + +Natural-language answer prose is not an official benchmark prediction format. +Benchmark runs continue to preserve atomic scene and dialogue hits, raw +distances, and the existing dataset serializers. When a dataset contains both +eligible modalities, reports must show three fixed rows: + +| Retrieval path | What is compared | +|---|---| +| Scene only | The existing visual retrieval output | +| Dialogue only | The existing transcript retrieval output | +| Fixed RRF fusion | Overlap-connected intervals ranked with `rrf_v1`, `k=60` | + +No fused benchmark score is reported until the same frozen dataset inputs and +evaluator used by the atomic rows have been run. Generated `QueryAnswer` claims +remain a separate grounding evaluation and cannot replace these retrieval +comparisons. + ## What the measurements mean ### Rank@1 and Rank@5 diff --git a/docs/deployment/coolify.md b/docs/deployment/coolify.md index 5786beb..6829998 100644 --- a/docs/deployment/coolify.md +++ b/docs/deployment/coolify.md @@ -10,8 +10,8 @@ contexts and does not download models in the API process. Chroma server. - `VIDXP_WORKER_IMAGE`: the VidXP `worker` target with CPU capabilities and Chroma's HTTP-only client. -- PostgreSQL 18.3, Chroma 1.5.9, and tusd 2.10.0 are pinned by digest in the - Compose file. +- PostgreSQL 18.3, Chroma 1.5.9, tusd 2.10.0, and the optional self-hosted + Ollama 0.32.5 service are pinned by digest in the Compose file. Use immutable VidXP release tags or digests for both first-party image variables. @@ -46,6 +46,29 @@ Video bytes do not travel through MCP. Create and resume the upload through the HTTP/tus endpoints, wait for its `media_id`, and then use that ID with `start_indexing`. +## Optional grounded query model + +Grounded retrieval works without a language model and returns timestamped +evidence. To enable generated claims, choose a model only after evaluating its +schema adherence, resource use, license, and grounding behavior. Set both: + +```dotenv +VIDXP_SLM_BASE_URL=http://ollama:11434/v1 +VIDXP_SLM_MODEL= +``` + +Then start the optional service and explicitly prepare the selected model: + +```bash +docker compose -f compose.coolify.yaml --profile slm up -d ollama +docker compose -f compose.coolify.yaml --profile slm exec ollama \ + ollama pull +docker compose -f compose.coolify.yaml up -d worker +``` + +The Compose deployment never pulls an SLM implicitly. Ollama is internal and +should not be published through the proxy. + ## Start and verify ```bash diff --git a/pyproject.toml b/pyproject.toml index 06ccba9..4ba50ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,11 +97,13 @@ all = { file = [ ] } local-worker = { file = [ "src/vidxp/requirements/storage.txt", + "src/vidxp/requirements/slm.txt", "src/vidxp/capabilities/dialogue/requirements.txt", "src/vidxp/capabilities/scene/requirements.txt", "src/vidxp/capabilities/actor/requirements.txt", ] } mcp = { file = ["src/vidxp/requirements/mcp.txt"] } +slm = { file = ["src/vidxp/requirements/slm.txt"] } server = { file = [ "src/vidxp/requirements/server.txt", "src/vidxp/requirements/mcp.txt", @@ -109,6 +111,7 @@ server = { file = [ server-worker = { file = [ "src/vidxp/requirements/server.txt", "src/vidxp/requirements/server-storage.txt", + "src/vidxp/requirements/slm.txt", "src/vidxp/capabilities/dialogue/requirements.txt", "src/vidxp/capabilities/scene/requirements.txt", "src/vidxp/capabilities/actor/requirements.txt", diff --git a/src/vidxp/api_routes/jobs.py b/src/vidxp/api_routes/jobs.py index f9e239d..fb4484c 100644 --- a/src/vidxp/api_routes/jobs.py +++ b/src/vidxp/api_routes/jobs.py @@ -20,6 +20,7 @@ ListJobsCommand, Principal, PrepareModelsCommand, + QueryVideoCommand, SearchCommand, ) from vidxp.composition import HttpApplicationContext @@ -87,6 +88,35 @@ def submit_search( ) +@router.post( + "/query", + response_model=Job, + status_code=202, + operation_id="queryVideo", + summary="Ask a grounded question about indexed media", + dependencies=[Depends(read_principal)], +) +def submit_query( + command: QueryVideoCommand, + response: Response, + service: Annotated[HttpApplicationContext, Depends(context)], + actor: Annotated[Principal, Depends(read_principal)], + idempotency_key: HttpIdempotencyKey, +) -> Job: + return accepted( + response, + service.jobs.submit_query( + command, + job_id=scoped_job_id( + service, + actor, + operation="query", + idempotency_key=idempotency_key, + ), + ), + ) + + @router.post( "/snippet", response_model=Job, diff --git a/src/vidxp/application.py b/src/vidxp/application.py index eec8a68..fb1d264 100644 --- a/src/vidxp/application.py +++ b/src/vidxp/application.py @@ -18,6 +18,7 @@ DependencyCheckResult, DependencyUnavailableError, ErrorCategory, + FusedSearchResult, IndexResult, ImportMediaCommand, IndexSnapshotReference, @@ -28,7 +29,10 @@ RemoveIndexCommand, ResourceNotFoundError, RuntimeReadiness, + QueryAnswer, + QueryVideoCommand, SearchCommand, + SearchMomentsPlanStep, ) from vidxp.capabilities.actor.schemas import ( ActorClusterSummary, @@ -48,7 +52,9 @@ IndexConfig, ) from vidxp.execution import ExecutionContext, execution_context -from vidxp.ports import IndexBackend, ModelRuntimePort +from vidxp.ports import IndexBackend, ModelRuntimePort, QueryModelPort +from vidxp.query_service import GroundedQueryService +from vidxp.search_fusion import fuse_search_results from vidxp.model_contracts import ModelArtifactUnavailableError from vidxp.repository_layout import RepositoryLayout from vidxp.settings import VidXPSettings @@ -76,10 +82,12 @@ def __init__( artifacts: ArtifactService, index_status: Callable[[], dict[str, Any] | None], completed_upload_importer: Callable[[str], MediaAsset] | None = None, + query_model: QueryModelPort | None = None, ) -> None: self.registry = registry self.runtime = runtime self.index_backend = index_backend + self.query = GroundedQueryService(query_model) self._completed_upload_importer = completed_upload_importer super().__init__( layout=layout, @@ -391,37 +399,212 @@ def search( command: SearchCommand, *, snapshot: IndexSnapshotReference | None = None, - ) -> SearchResult: - if snapshot is None: - return cast( - SearchResult, - self.execute( - command.modality, - "search", - {"query": command.query, "top_k": command.top_k}, - ), + ) -> FusedSearchResult: + config = ( + self._config_for_snapshot(snapshot) + if snapshot is not None + else self._active_config() + ) + selected, _ = self._resolve_query_capabilities( + command.modalities, + config, + include_actor=False, + ) + with self._capability_dependencies(selected): + with self.index_backend.open_store(config) as storage: + context = CapabilityContext( + config=config, + runtime=self.runtime, + storage=storage, + ) + with self.runtime.scheduler.inference(): + results = tuple( + self._search_capability( + modality, + query=command.query, + media_id=command.media_id, + top_k=command.top_k, + context=context, + ) + for modality in selected + ) + return fuse_search_results( + query=command.query, + requested_modalities=selected, + results=results, + media_id=command.media_id, + top_k=command.top_k, + ) + + def _resolve_query_capabilities( + self, + requested: tuple[str, ...], + config: IndexConfig, + *, + include_actor: bool, + ) -> tuple[tuple[str, ...], bool]: + explicit = bool(requested) + candidates = ( + self.registry.validate_names(requested) + if explicit + else tuple(config.enabled_modalities) + ) + unavailable = tuple( + name + for name in candidates + if name not in config.enabled_modalities + ) + if unavailable: + raise CapabilityRequestError( + "Query capabilities are not present in the pinned index: " + + ", ".join(unavailable) + + "." + ) + + searchable = tuple( + name + for name in candidates + if "search" in self.registry.get(name).operations + ) + actor_overview = ( + include_actor + and "actor" in candidates + and "clusters" in self.registry.get("actor").operations + ) + supported = set(searchable) + if actor_overview: + supported.add("actor") + unsupported = tuple( + name for name in candidates if name not in supported + ) + if explicit and unsupported: + operation = "Query" if include_actor else "Search" + raise CapabilityRequestError( + f"{operation} does not support these indexed capabilities: " + + ", ".join(unsupported) + + "." ) + if not searchable and not actor_overview: + raise CapabilityRequestError( + "The pinned index has no queryable capabilities." + ) + return searchable, actor_overview + + def _search_capability( + self, + modality: str, + *, + query: str, + media_id: str | None, + top_k: int, + context: CapabilityContext, + ) -> SearchResult: + return cast( + SearchResult, + self._invoke_operation( + modality, + "search", + { + "query": query, + "media_id": media_id, + "top_k": top_k, + }, + context=context, + ), + ) + + @application_boundary + def query_video( + self, + command: QueryVideoCommand, + *, + snapshot: IndexSnapshotReference, + execution: ExecutionContext | None = None, + ) -> QueryAnswer: + active_execution = execution_context(execution) config = self._config_for_snapshot(snapshot) - self._require_indexed_capability(command.modality, config) - with self._capability_dependencies((command.modality,)): + search_modalities, actor_overview = ( + self._resolve_query_capabilities( + command.modalities, + config, + include_actor=True, + ) + ) + if len(search_modalities) + int(actor_overview) > 8: + raise CapabilityRequestError( + "Query supports at most eight capability operations." + ) + + active_execution.checkpoint() + plan, planning_fallback = self.query.plan( + command, + search_modalities=search_modalities, + actor_overview=actor_overview, + ) + active_execution.checkpoint() + results: list[SearchResult] = [] + actors: tuple[ActorClusterSummary, ...] = () + dependencies = search_modalities + ( + ("actor",) if actor_overview else () + ) + with self._capability_dependencies(dependencies): with self.index_backend.open_store(config) as storage: + context = CapabilityContext( + config=config, + runtime=self.runtime, + storage=storage, + ) with self.runtime.scheduler.inference(): - return cast( - SearchResult, - self._invoke_operation( - command.modality, - "search", - { - "query": command.query, - "top_k": command.top_k, - }, - context=CapabilityContext( - config=config, - runtime=self.runtime, - storage=storage, - ), - ), - ) + for step in plan.steps: + active_execution.checkpoint() + if isinstance(step, SearchMomentsPlanStep): + results.append( + self._search_capability( + step.modality, + query=step.query, + media_id=command.media_id, + top_k=command.top_k, + context=context, + ) + ) + else: + page = cast( + ActorClustersOutput, + self._invoke_operation( + "actor", + "clusters", + { + "page_size": min(command.top_k, 100), + "media_id": command.media_id, + }, + context=context, + ), + ) + actors = page.clusters + active_execution.checkpoint() + atomic = tuple(results) + fused = fuse_search_results( + query=command.question, + requested_modalities=search_modalities, + results=atomic, + media_id=command.media_id, + top_k=command.top_k, + ) + evidence = self.query.evidence( + snapshot=snapshot, + fused=fused, + actors=actors, + ) + active_execution.checkpoint() + answer = self.query.answer( + command, + plan=plan, + planning_fallback=planning_fallback, + evidence=evidence, + fused=fused, + ) + active_execution.checkpoint() + return answer @application_boundary def actor_clusters( @@ -429,13 +612,18 @@ def actor_clusters( *, page_size: int = 50, cursor: str | None = None, + media_id: str | None = None, ) -> ActorClustersOutput: return cast( ActorClustersOutput, self.execute( "actor", "clusters", - {"page_size": page_size, "cursor": cursor}, + { + "page_size": page_size, + "cursor": cursor, + "media_id": media_id, + }, ), ) diff --git a/src/vidxp/application_models.py b/src/vidxp/application_models.py index 4516307..b40f265 100644 --- a/src/vidxp/application_models.py +++ b/src/vidxp/application_models.py @@ -97,6 +97,7 @@ class JobKind(StrEnum): media_import = "media_import" index = "index" search = "search" + query = "query" snippet = "snippet" actor_overlay = "actor_overlay" prepare_models = "prepare_models" @@ -439,11 +440,26 @@ def _validate_media_id_window(self) -> "IndexStatusSummary": return self +class FusionProfile(StrEnum): + reciprocal_rank = "rrf_v1" + + class SearchCommand(ApplicationModel): - modality: Identifier query: SearchQuery + modalities: tuple[Identifier, ...] = () + media_id: MediaId | None = None top_k: int = Field(default=10, gt=0, le=100) + @field_validator("modalities") + @classmethod + def _unique_modalities( + cls, + values: tuple[Identifier, ...], + ) -> tuple[Identifier, ...]: + if len(values) != len(set(values)): + raise ValueError("Search modalities must be unique.") + return values + class SearchHit(ApplicationModel): rank: int = Field(gt=0) @@ -516,6 +532,216 @@ def to_prediction(self) -> dict[str, list[dict[str, Any]]]: } +class FusionProvenance(ApplicationModel): + profile: Literal[FusionProfile.reciprocal_rank] = ( + FusionProfile.reciprocal_rank + ) + rank_constant: int = Field(default=60, gt=0) + overlap_rule: Literal["connected_intervals"] = "connected_intervals" + requested_modalities: tuple[Identifier, ...] = () + searched_modalities: tuple[Identifier, ...] = () + + +class FusedMoment(ApplicationModel): + rank: int = Field(gt=0) + score: float = Field(gt=0) + media_id: MediaId + start: float = Field(ge=0) + end: float = Field(gt=0) + modalities: tuple[Identifier, ...] + hits: tuple[SearchHit, ...] = Field(min_length=1) + + @model_validator(mode="after") + def _validate_fused_moment(self) -> "FusedMoment": + if self.end <= self.start: + raise ValueError("end must be greater than start") + if any(hit.media_id != self.media_id for hit in self.hits): + raise ValueError("Fused moment hits must belong to one media item.") + if set(self.modalities) != {hit.modality for hit in self.hits}: + raise ValueError("Fused moment modalities must match its hits.") + return self + + +class FusedSearchResult(ApplicationModel): + schema_version: int = INDEX_SCHEMA_VERSION + query_id: str = Field(min_length=1) + query: SearchQuery + modalities: tuple[Identifier, ...] + moments: tuple[FusedMoment, ...] = () + fusion: FusionProvenance + + +class QueryVideoCommand(ApplicationModel): + question: SearchQuery + media_id: MediaId | None = None + modalities: tuple[Identifier, ...] = Field(default=(), max_length=8) + top_k: int = Field(default=10, gt=0, le=50) + + @field_validator("modalities") + @classmethod + def _unique_modalities( + cls, + values: tuple[Identifier, ...], + ) -> tuple[Identifier, ...]: + if len(values) != len(set(values)): + raise ValueError("Query modalities must be unique.") + return values + + +class SearchMomentsPlanStep(ApplicationModel): + kind: Literal["search_moments"] = "search_moments" + modality: Identifier + query: SearchQuery + + +class ActorOverviewPlanStep(ApplicationModel): + kind: Literal["actor_overview"] = "actor_overview" + + +QueryPlanStep = Annotated[ + SearchMomentsPlanStep | ActorOverviewPlanStep, + Field(discriminator="kind"), +] + + +class QueryPlan(ApplicationModel): + steps: tuple[QueryPlanStep, ...] = Field(min_length=1, max_length=8) + + +class QueryPlanningRequest(ApplicationModel): + question: SearchQuery + allowed_modalities: tuple[Identifier, ...] + actor_overview_allowed: bool = False + + +class QueryModelIdentity(ApplicationModel): + provider: Literal["ollama"] + model: str = Field(min_length=1, max_length=255) + + +class MomentEvidence(ApplicationModel): + kind: Literal["moment"] = "moment" + evidence_id: Sha256 + snapshot_id: IndexSnapshotId + media_id: MediaId + generation_id: IndexGenerationId + modality: Identifier + source_id: str = Field(min_length=1, max_length=512) + start: float = Field(ge=0) + end: float = Field(gt=0) + display_text: str | None = Field(default=None, max_length=4096) + hit: SearchHit + + @model_validator(mode="after") + def _validate_hit_identity(self) -> "MomentEvidence": + if ( + self.media_id != self.hit.media_id + or self.generation_id != self.hit.generation_id + or self.modality != self.hit.modality + or self.source_id != self.hit.source_id + or self.start != self.hit.start + or self.end != self.hit.end + ): + raise ValueError("Evidence identity must match its search hit.") + return self + + +class ActorEvidence(ApplicationModel): + kind: Literal["actor"] = "actor" + evidence_id: Sha256 + snapshot_id: IndexSnapshotId + media_id: MediaId + generation_id: IndexGenerationId + modality: Literal["actor"] = "actor" + cluster_id: ActorClusterId + start: float = Field(ge=0) + end: float = Field(ge=0) + detection_count: int = Field(ge=0) + display_text: str = Field(min_length=1, max_length=4096) + + +Evidence = Annotated[ + MomentEvidence | ActorEvidence, + Field(discriminator="kind"), +] + + +class DraftClaim(ApplicationModel): + text: str = Field(min_length=1, max_length=4096) + evidence_ids: tuple[Sha256, ...] = Field(min_length=1, max_length=10) + + @field_validator("evidence_ids") + @classmethod + def _unique_evidence_ids( + cls, + values: tuple[Sha256, ...], + ) -> tuple[Sha256, ...]: + if len(values) != len(set(values)): + raise ValueError("Draft evidence IDs must be unique.") + return values + + +class DraftAnswer(ApplicationModel): + claims: tuple[DraftClaim, ...] = Field(min_length=1, max_length=20) + + +class QuerySynthesisRequest(ApplicationModel): + question: SearchQuery + evidence: tuple[Evidence, ...] = Field(min_length=1, max_length=200) + + +class GroundedClaim(ApplicationModel): + text: str = Field(min_length=1, max_length=4096) + evidence_ids: tuple[Sha256, ...] = Field(min_length=1, max_length=10) + + @field_validator("evidence_ids") + @classmethod + def _unique_evidence_ids( + cls, + values: tuple[Sha256, ...], + ) -> tuple[Sha256, ...]: + if len(values) != len(set(values)): + raise ValueError("Claim evidence IDs must be unique.") + return values + + +class QueryAnswerMode(StrEnum): + generated = "generated" + evidence_only = "evidence_only" + no_evidence = "no_evidence" + + +class QueryAnswer(ApplicationModel): + schema_version: int = INDEX_SCHEMA_VERSION + question: SearchQuery + mode: QueryAnswerMode + plan: QueryPlan + model: QueryModelIdentity | None = None + claims: tuple[GroundedClaim, ...] = () + evidence: tuple[Evidence, ...] = Field(default=(), max_length=200) + moments: tuple[FusedMoment, ...] = () + fusion: FusionProvenance + fallback_reason: str | None = Field(default=None, max_length=512) + + @model_validator(mode="after") + def _validate_answer_grounding(self) -> "QueryAnswer": + evidence_ids = {item.evidence_id for item in self.evidence} + cited_ids = { + evidence_id + for claim in self.claims + for evidence_id in claim.evidence_ids + } + if not cited_ids.issubset(evidence_ids): + raise ValueError("Every claim citation must resolve to evidence.") + if self.mode == QueryAnswerMode.generated and not self.claims: + raise ValueError("Generated answers require grounded claims.") + if self.mode != QueryAnswerMode.generated and self.claims: + raise ValueError("Fallback answers must not contain generated claims.") + if self.mode == QueryAnswerMode.no_evidence and self.evidence: + raise ValueError("No-evidence answers cannot contain evidence.") + return self + + class PrepareModelsCommand(ApplicationModel): modalities: tuple[str, ...] capability_options: Mapping[str, Mapping[str, JsonValue]] = Field( @@ -540,7 +766,8 @@ class PrepareModelsResult(ApplicationModel): runtime: RuntimeProfile -JOB_SCHEMA_VERSION = 1 +JOB_SCHEMA_VERSION = 2 +JOB_PROGRESS_SCHEMA_VERSION = 1 class IndexSnapshotReference(ApplicationModel): @@ -564,6 +791,12 @@ class SearchJobRequest(ApplicationModel): snapshot: IndexSnapshotReference +class QueryJobRequest(ApplicationModel): + kind: Literal[JobKind.query] = JobKind.query + command: QueryVideoCommand + snapshot: IndexSnapshotReference + + class SnippetJobRequest(ApplicationModel): kind: Literal[JobKind.snippet] = JobKind.snippet command: CreateSnippetCommand @@ -584,6 +817,7 @@ class PrepareModelsJobRequest(ApplicationModel): MediaImportJobRequest | IndexJobRequest | SearchJobRequest + | QueryJobRequest | SnippetJobRequest | ActorOverlayJobRequest | PrepareModelsJobRequest, @@ -603,7 +837,12 @@ class MediaImportJobResult(ApplicationModel): class SearchJobResult(ApplicationModel): kind: Literal[JobKind.search] = JobKind.search - result: SearchResult + result: FusedSearchResult + + +class QueryJobResult(ApplicationModel): + kind: Literal[JobKind.query] = JobKind.query + result: QueryAnswer class ArtifactJobResult(ApplicationModel): @@ -620,6 +859,7 @@ class PrepareModelsJobResult(ApplicationModel): MediaImportJobResult | IndexJobResult | SearchJobResult + | QueryJobResult | ArtifactJobResult | PrepareModelsJobResult, Field(discriminator="kind"), @@ -627,7 +867,9 @@ class PrepareModelsJobResult(ApplicationModel): class JobProgress(ApplicationModel): - schema_version: Literal[JOB_SCHEMA_VERSION] = JOB_SCHEMA_VERSION + schema_version: Literal[JOB_PROGRESS_SCHEMA_VERSION] = ( + JOB_PROGRESS_SCHEMA_VERSION + ) stage: str = Field(min_length=1, max_length=128) message: str = Field(min_length=1, max_length=512) current: int | None = Field(default=None, ge=0) diff --git a/src/vidxp/capabilities/actor/operations.py b/src/vidxp/capabilities/actor/operations.py index 8e96642..4846994 100644 --- a/src/vidxp/capabilities/actor/operations.py +++ b/src/vidxp/capabilities/actor/operations.py @@ -36,6 +36,7 @@ def clusters_operation( storage=context.require_storage(), page_size=_request.page_size, cursor=_request.cursor, + media_id=_request.media_id, ) diff --git a/src/vidxp/capabilities/actor/results.py b/src/vidxp/capabilities/actor/results.py index 114c4a4..fbdf17f 100644 --- a/src/vidxp/capabilities/actor/results.py +++ b/src/vidxp/capabilities/actor/results.py @@ -145,14 +145,20 @@ def actor_clusters( storage: IndexReader, page_size: int, cursor: str | None, + media_id: str | None = None, ) -> ActorClustersOutput: - scope = f"actor:clusters:{_snapshot_scope(config)}" + scope = ( + f"actor:clusters:{_snapshot_scope(config)}:" + f"{media_id or '*'}" + ) offset = _decode_cursor(cursor, scope) + media_scope = {"video_id": media_id} if media_id is not None else {} records = storage.records( "actor", filters={"record_kind": "cluster_summary"}, limit=page_size + 1, offset=offset, + **media_scope, ) if records: has_more = len(records) > page_size @@ -183,6 +189,7 @@ def actor_clusters( "actor", limit=1000, offset=storage_offset, + **media_scope, ) for record in records: cluster_id = str(record["cluster_id"]) diff --git a/src/vidxp/capabilities/actor/schemas.py b/src/vidxp/capabilities/actor/schemas.py index c59223e..9867003 100644 --- a/src/vidxp/capabilities/actor/schemas.py +++ b/src/vidxp/capabilities/actor/schemas.py @@ -14,6 +14,7 @@ class ActorClustersInput(CapabilityInput): page_size: int = Field(default=50, gt=0, le=100) cursor: str | None = Field(default=None, min_length=1, max_length=512) + media_id: MediaId | None = None class ActorClusterInput(CapabilityInput): diff --git a/src/vidxp/capabilities/dialogue/operations.py b/src/vidxp/capabilities/dialogue/operations.py index 6645e3a..7c6e2e9 100644 --- a/src/vidxp/capabilities/dialogue/operations.py +++ b/src/vidxp/capabilities/dialogue/operations.py @@ -116,7 +116,7 @@ def search_operation( request.query, config=config, top_k=request.top_k, - video_id=config.video_id, + video_id=request.media_id or config.video_id, runtime=context.runtime, storage=context.require_storage(), ) diff --git a/src/vidxp/capabilities/scene/operations.py b/src/vidxp/capabilities/scene/operations.py index 9a3d776..29b1a9b 100644 --- a/src/vidxp/capabilities/scene/operations.py +++ b/src/vidxp/capabilities/scene/operations.py @@ -86,7 +86,7 @@ def search_operation( request.query, config=config, top_k=request.top_k, - video_id=config.video_id, + video_id=request.media_id or config.video_id, runtime=context.runtime, storage=context.require_storage(), ) diff --git a/src/vidxp/capabilities/schemas.py b/src/vidxp/capabilities/schemas.py index 953648e..601818a 100644 --- a/src/vidxp/capabilities/schemas.py +++ b/src/vidxp/capabilities/schemas.py @@ -4,11 +4,13 @@ from vidxp.application_models import SearchHit, SearchQuery, SearchResult from vidxp.capabilities.contracts import CapabilityInput +from vidxp.core.identifiers import MediaId class SearchInput(CapabilityInput): query: SearchQuery top_k: int = Field(default=10, gt=0, le=100) + media_id: MediaId | None = None __all__ = ["SearchHit", "SearchInput", "SearchResult"] diff --git a/src/vidxp/cli.py b/src/vidxp/cli.py index edb7b0f..54d8e84 100644 --- a/src/vidxp/cli.py +++ b/src/vidxp/cli.py @@ -19,6 +19,7 @@ from vidxp.cli_commands.repositories import app as repositories_app from vidxp.cli_commands.runtime import doctor, prepare, ui from vidxp.cli_commands.search import search +from vidxp.cli_commands.query import query from vidxp.cli_support import CLIState, OutputFormat from vidxp.composition import create_local_application @@ -32,6 +33,7 @@ app.add_typer(media_app, name="media") app.add_typer(artifacts_app, name="artifacts") app.command("search")(search) +app.command("query")(query) app.add_typer(repositories_app, name="repositories") app.add_typer(actor_app, name="actors") diff --git a/src/vidxp/cli_commands/query.py b/src/vidxp/cli_commands/query.py new file mode 100644 index 0000000..0499f81 --- /dev/null +++ b/src/vidxp/cli_commands/query.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from typing import Annotated + +import typer + +from vidxp.application_models import ( + QueryAnswer, + QueryJobResult, + QueryVideoCommand, +) +from vidxp.cli_support import ( + CLIState, + effective_output_format, + emit_query, + state_from_context, +) + + +def run_query( + state: CLIState, + question: str, + *, + media_id: str | None, + modalities: tuple[str, ...], + top_k: int, + json_output: bool, +) -> QueryAnswer: + job = state.jobs.submit_query( + QueryVideoCommand( + question=question, + media_id=media_id, + modalities=modalities, + top_k=top_k, + ) + ) + completed = state.jobs.wait(job.job_id) + if not isinstance(completed.result, QueryJobResult): + raise RuntimeError("The completed query job has no query result.") + result = completed.result.result + emit_query( + result, + output_format=effective_output_format(state, json_output), + ) + return result + + +def query( + ctx: typer.Context, + question: Annotated[ + str, + typer.Argument(help="Natural-language question about indexed media."), + ], + media_id: Annotated[ + str | None, + typer.Option("--media-id", help="Restrict evidence to one media ID."), + ] = None, + modality: Annotated[ + list[str] | None, + typer.Option( + "--modality", + "-m", + help="Restrict query evidence; repeat for multiple capabilities.", + ), + ] = None, + top_k: Annotated[ + int, + typer.Option("--top-k", "-k", min=1, max=50), + ] = 10, + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + run_query( + state_from_context(ctx), + question, + media_id=media_id, + modalities=tuple(dict.fromkeys(modality or ())), + top_k=top_k, + json_output=json_output, + ) diff --git a/src/vidxp/cli_commands/search.py b/src/vidxp/cli_commands/search.py index e30748d..9866096 100644 --- a/src/vidxp/cli_commands/search.py +++ b/src/vidxp/cli_commands/search.py @@ -5,7 +5,7 @@ import typer from vidxp.application_models import SearchCommand, SearchJobResult -from vidxp.capabilities.schemas import SearchResult +from vidxp.application_models import FusedSearchResult from vidxp.cli_support import ( CLIState, effective_output_format, @@ -21,10 +21,10 @@ def run_search( *, top_k: int, json_output: bool, -) -> SearchResult: +) -> FusedSearchResult: job = state.jobs.submit_search( SearchCommand( - modality=capability, + modalities=(capability,), query=query, top_k=top_k, ) diff --git a/src/vidxp/cli_support.py b/src/vidxp/cli_support.py index edd63a3..6f325e8 100644 --- a/src/vidxp/cli_support.py +++ b/src/vidxp/cli_support.py @@ -16,7 +16,7 @@ ) from rich.table import Table -from vidxp.capabilities.schemas import SearchResult +from vidxp.application_models import FusedSearchResult, QueryAnswer from vidxp.repositories import RepositoryConfig, RepositoryRegistry from typing import TYPE_CHECKING @@ -79,33 +79,60 @@ def emit_json(payload: Any) -> None: def emit_search( - result: SearchResult, + result: FusedSearchResult, *, output_format: OutputFormat, ) -> None: if output_format == OutputFormat.json: - emit_json(result.to_dict()) + emit_json(result.model_dump(mode="json")) return - if not result.hits: - typer.echo(f"No {result.modality} matches found.") + if not result.moments: + typer.echo("No matching moments found.") return - table = Table(title=f"{result.modality.title()} search results") + table = Table(title="Fused search results") table.add_column("Rank", justify="right") table.add_column("Start", justify="right") table.add_column("End", justify="right") table.add_column("Score", justify="right") table.add_column("Video") - for hit in result.hits: + table.add_column("Modalities") + for moment in result.moments: table.add_row( - str(hit.rank), - f"{hit.start:.3f}s", - f"{hit.end:.3f}s", - f"{hit.score:.6f}", - hit.video_id, + str(moment.rank), + f"{moment.start:.3f}s", + f"{moment.end:.3f}s", + f"{moment.score:.6f}", + moment.media_id, + ", ".join(moment.modalities), ) Console().print(table) +def emit_query( + result: QueryAnswer, + *, + output_format: OutputFormat, +) -> None: + if output_format == OutputFormat.json: + emit_json(result.model_dump(mode="json")) + return + console = Console() + console.print(f"Mode: {result.mode.value}") + if result.claims: + for claim in result.claims: + console.print(f"• {claim.text}") + console.print(f" Evidence: {', '.join(claim.evidence_ids)}") + elif result.evidence: + console.print( + f"No generated answer; returning {len(result.evidence)} " + "evidence item(s)." + ) + else: + console.print("No supporting evidence found.") + if result.fallback_reason: + console.print(f"Fallback: {result.fallback_reason}") + + def emit_status( status: dict[str, Any], *, diff --git a/src/vidxp/composition.py b/src/vidxp/composition.py index 66fc9aa..058cb9d 100644 --- a/src/vidxp/composition.py +++ b/src/vidxp/composition.py @@ -267,6 +267,19 @@ def create_application( if active_settings.mode == ApplicationMode.server else None ) + query_model = None + if ( + active_settings.slm_base_url is not None + and active_settings.slm_model is not None + ): + from vidxp.infrastructure.ollama_query import OllamaQueryModel + + query_model = OllamaQueryModel( + base_url=active_settings.slm_base_url, + model_name=active_settings.slm_model, + timeout_seconds=active_settings.slm_timeout_seconds, + output_retries=active_settings.slm_output_retries, + ) return VidXPApplication( settings=active_settings, layout=active_settings.layout, @@ -281,6 +294,7 @@ def create_application( if upload_service is not None else None ), + query_model=query_model, ) diff --git a/src/vidxp/frontend.py b/src/vidxp/frontend.py index beae6a5..e430fa4 100644 --- a/src/vidxp/frontend.py +++ b/src/vidxp/frontend.py @@ -18,6 +18,7 @@ ErrorCategory, ImportMediaCommand, JobState, + QueryVideoCommand, SearchCommand, ) from vidxp.composition import create_application, create_job_service @@ -320,7 +321,7 @@ def _run_indexing(uploaded_video, status, modalities): st.rerun() -def _run_search(search_type, query): +def _run_search(search_type, query, media_id=None): service = _configured_service() try: status = service.index_status().model_dump(mode="json") @@ -341,13 +342,23 @@ def _run_search(search_type, query): "job_id": job.job_id, } - job = _configured_jobs().submit_search( - SearchCommand( - modality=search_type, - query=query, - top_k=1, + if search_type == "natural-language": + job = _configured_jobs().submit_query( + QueryVideoCommand( + question=query, + media_id=media_id, + top_k=10, + ) + ) + else: + job = _configured_jobs().submit_search( + SearchCommand( + modalities=(search_type,), + query=query, + media_id=media_id, + top_k=1, + ) ) - ) _remember_job( job_id=job.job_id, query_param=SEARCH_JOB_QUERY_PARAM, @@ -381,6 +392,8 @@ def poll_search_job(): label = ( "actor overlay" if result["type"] == "actor" + else "natural-language query" + if result["type"] == "natural-language" else f"{result['type']} search" ) try: @@ -435,20 +448,42 @@ def poll_search_job(): "query": result["query"], "artifact_id": completed.artifact_id, } - elif not completed.hits: + elif result["type"] == "natural-language": + first_moment = ( + completed.moments[0] if completed.moments else None + ) + first_evidence = ( + completed.evidence[0] if completed.evidence else None + ) + resolved = { + "type": result["type"], + "query": completed.question, + "answer": completed.model_dump(mode="json"), + "media_id": ( + first_moment.media_id + if first_moment is not None + else getattr(first_evidence, "media_id", None) + ), + "timestamp": ( + first_moment.start + if first_moment is not None + else getattr(first_evidence, "start", 0) + ), + } + elif not completed.moments: resolved = { "type": result["type"], "query": completed.query, "error": f"No {result['type']} match was found.", } else: - hit = completed.hits[0] + moment = completed.moments[0] resolved = { "type": result["type"], "query": completed.query, - "timestamp": hit.start, - "hit": hit.to_dict(), - "media_id": hit.media_id, + "timestamp": moment.start, + "moment": moment.model_dump(mode="json"), + "media_id": moment.media_id, } st.session_state[SEARCH_RESULT_KEY] = resolved st.rerun() @@ -456,6 +491,26 @@ def poll_search_job(): poll_search_job() return + if result["type"] == "natural-language": + answer = result["answer"] + if answer["claims"]: + for claim in answer["claims"]: + st.markdown(f"- {claim['text']}") + st.caption( + "Evidence: " + ", ".join(claim["evidence_ids"]) + ) + elif answer["evidence"]: + st.info( + "The query returned evidence without generating unsupported " + "claims." + ) + else: + st.info("No supporting evidence was found.") + if answer.get("fallback_reason"): + st.caption(f"Fallback: {answer['fallback_reason']}") + if result.get("media_id") is None: + return + service = _configured_service() try: resource = ( @@ -541,15 +596,23 @@ def _search_controls(ready, uploaded_video, available_modalities): with type_column: search_type = st.selectbox( "Search type", - list(available_modalities), + ["natural-language", *available_modalities], disabled=not ready, ) with query_column: query = st.text_input( - "Actor cluster ID" if search_type == "actor" else "Search query", + ( + "Actor cluster ID" + if search_type == "actor" + else "Question" + if search_type == "natural-language" + else "Search query" + ), placeholder=( "For example: 1" if search_type == "actor" + else "For example: What happens after the taxi arrives?" + if search_type == "natural-language" else "For example: Chef makes pizza and cuts it up." ), disabled=not ready, @@ -738,7 +801,11 @@ def poll_index_status(): available_modalities, ) if search_clicked: - st.session_state[SEARCH_RESULT_KEY] = _run_search(search_type, query) + st.session_state[SEARCH_RESULT_KEY] = _run_search( + search_type, + query, + selected_media_id, + ) _render_search_result(st.session_state.get(SEARCH_RESULT_KEY)) if requested: diff --git a/src/vidxp/infrastructure/dbos_jobs.py b/src/vidxp/infrastructure/dbos_jobs.py index 6c89fb8..75559ab 100644 --- a/src/vidxp/infrastructure/dbos_jobs.py +++ b/src/vidxp/infrastructure/dbos_jobs.py @@ -28,9 +28,12 @@ MediaImportJobResult, PrepareModelsJobResult, PrepareModelsResult, + QueryAnswer, + QueryJobResult, SearchJobResult, - SearchResult, + FusedSearchResult, ) +from vidxp.capabilities.schemas import SearchResult from vidxp.core.identifiers import JobId from vidxp.core.cursors import ( MAX_CURSOR_OFFSET, @@ -43,6 +46,7 @@ InvalidJobBackendRequestError, JobIdempotencyConflictError, ) +from vidxp.search_fusion import fuse_search_results from vidxp.workflow_contracts import ( ERROR_EVENT, PROGRESS_EVENT, @@ -52,11 +56,11 @@ WORKFLOW_INSTANCE_NAME, WORKFLOW_KINDS, WORKFLOW_NAMES, + decode_workflow_request, ) _JOB_ID_ADAPTER = TypeAdapter(JobId) -_JOB_REQUEST_ADAPTER = TypeAdapter(JobRequest) _LIST_SCOPE = "vidxp:jobs" _WINDOW_END_FIELD = "window_end_ms" _STATUS_STATES = { @@ -89,6 +93,19 @@ def _now_ms() -> int: return time_ns() // 1_000_000 +def _decode_search_result(output: Any) -> FusedSearchResult: + try: + return FusedSearchResult.model_validate(output) + except ValidationError: + legacy = SearchResult.model_validate(output) + return fuse_search_results( + query=legacy.query, + requested_modalities=(legacy.modality,), + results=(legacy,), + top_k=max(1, len(legacy.hits)), + ) + + def _window_end_iso(value: int) -> str: return datetime.fromtimestamp(value / 1000, timezone.utc).isoformat( timespec="milliseconds" @@ -224,9 +241,7 @@ def _status( def _idempotent_job(self, status: Any, request: JobRequest) -> Job: try: - stored = _JOB_REQUEST_ADAPTER.validate_python( - status.input["args"][0] - ) + stored = decode_workflow_request(status.input["args"][0]) except (KeyError, IndexError, TypeError, ValidationError) as exc: raise JobIdempotencyConflictError from exc if ( @@ -314,14 +329,12 @@ def retry( current = self.get(job_id) if current is None: return None - if retry_id is not None: - status = self._status(current.job_id, load_input=True) - if status is None: - return None + status = self._status(current.job_id, load_input=True) + if status is None: + return None + if retry_id is not None or status.name == "vidxp.search.v1": try: - request = _JOB_REQUEST_ADAPTER.validate_python( - status.input["args"][0] - ) + request = decode_workflow_request(status.input["args"][0]) except (KeyError, IndexError, TypeError, ValidationError) as exc: raise InvalidJobBackendRequestError from exc return self.submit( @@ -391,7 +404,11 @@ def _job(self, status: Any) -> Job: ) elif kind == JobKind.search: result = SearchJobResult( - result=SearchResult.model_validate(status.output) + result=_decode_search_result(status.output) + ) + elif kind == JobKind.query: + result = QueryJobResult( + result=QueryAnswer.model_validate(status.output) ) elif kind in {JobKind.snippet, JobKind.actor_overlay}: result = ArtifactJobResult( diff --git a/src/vidxp/infrastructure/dbos_workflows.py b/src/vidxp/infrastructure/dbos_workflows.py index 0e724ba..898c4a9 100644 --- a/src/vidxp/infrastructure/dbos_workflows.py +++ b/src/vidxp/infrastructure/dbos_workflows.py @@ -21,6 +21,7 @@ MediaImportJobRequest, SnippetJobRequest, PrepareModelsJobRequest, + QueryJobRequest, SearchJobRequest, ) from vidxp.core.contracts import CancellationToken @@ -32,6 +33,7 @@ WORKFLOW_CLASS_NAME, WORKFLOW_INSTANCE_NAME, WORKFLOW_NAMES, + decode_workflow_request, ) @@ -211,10 +213,11 @@ def execute() -> dict[str, Any]: return _step_boundary(execute) - @DBOS.step(name="vidxp.run_search.v1") - def run_search_step(self, payload: dict[str, Any]) -> dict[str, Any]: + def _run_search(self, payload: dict[str, Any]) -> dict[str, Any]: def execute() -> dict[str, Any]: - request = SearchJobRequest.model_validate(payload) + request = decode_workflow_request(payload) + if not isinstance(request, SearchJobRequest): + raise ValueError("A search workflow requires a search request.") execution = _execution() _publish_progress( { @@ -240,6 +243,47 @@ def execute() -> dict[str, Any]: return _step_boundary(execute) + @DBOS.step(name="vidxp.run_search.v1") + def run_legacy_search_step( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + return self._run_search(payload) + + @DBOS.step(name="vidxp.run_search.v2") + def run_search_step(self, payload: dict[str, Any]) -> dict[str, Any]: + return self._run_search(payload) + + @DBOS.step(name="vidxp.run_query.v1") + def run_query_step(self, payload: dict[str, Any]) -> dict[str, Any]: + def execute() -> dict[str, Any]: + request = QueryJobRequest.model_validate(payload) + execution = _execution() + _publish_progress( + { + "stage": "querying", + "message": "Querying the committed index.", + } + ) + execution.checkpoint() + result = self.application.query_video( + request.command, + snapshot=request.snapshot, + execution=execution, + ) + execution.checkpoint() + _publish_progress( + { + "stage": "complete", + "message": "The grounded query result is ready.", + "current": 1, + "total": 1, + } + ) + return result.model_dump(mode="json") + + return _step_boundary(execute) + @DBOS.step(name="vidxp.prepare_models_step.v1") def prepare_models_step( self, @@ -289,6 +333,17 @@ def snippet_workflow(self, payload: dict[str, Any]) -> dict[str, Any]: def search_workflow(self, payload: dict[str, Any]) -> dict[str, Any]: return self.run_search_step(payload) + @DBOS.workflow(name="vidxp.search.v1") + def legacy_search_workflow( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + return self.run_legacy_search_step(payload) + + @DBOS.workflow(name=WORKFLOW_NAMES[JobKind.query]) + def query_workflow(self, payload: dict[str, Any]) -> dict[str, Any]: + return self.run_query_step(payload) + @DBOS.workflow(name=WORKFLOW_NAMES[JobKind.actor_overlay]) def actor_overlay_workflow( self, diff --git a/src/vidxp/infrastructure/ollama_query.py b/src/vidxp/infrastructure/ollama_query.py new file mode 100644 index 0000000..9347d04 --- /dev/null +++ b/src/vidxp/infrastructure/ollama_query.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import httpx +from openai import OpenAIError +from pydantic import ValidationError +from pydantic_ai import Agent, NativeOutput +from pydantic_ai.exceptions import AgentRunError +from pydantic_ai.models.ollama import OllamaModel +from pydantic_ai.providers.ollama import OllamaProvider + +from vidxp.application_models import ( + DraftAnswer, + QueryModelIdentity, + QueryPlan, + QueryPlanningRequest, + QuerySynthesisRequest, +) +from vidxp.ports import QueryProviderError + + +_PLANNING_INSTRUCTIONS = """ +Return exactly one search_moments step for every allowed modality, using a +short retrieval query derived from the question. Include actor_overview +exactly once when actor_overview_allowed is true. Do not invent modalities, +filters, identifiers, limits, paths, model names, or operations. +""".strip() + +_SYNTHESIS_INSTRUCTIONS = """ +Write concise factual claims supported only by the supplied evidence. Every +claim must cite one or more evidence_id values exactly as supplied. Do not +infer visual facts from similarity scores, introduce outside knowledge, or +mention evidence that was not supplied. +""".strip() + + +class OllamaQueryModel: + """Structured-output adapter for a configured self-hosted Ollama server.""" + + def __init__( + self, + *, + base_url: str, + model_name: str, + timeout_seconds: float, + output_retries: int, + http_client: httpx.AsyncClient | None = None, + ) -> None: + model = OllamaModel( + model_name, + provider=OllamaProvider( + base_url=base_url, + http_client=http_client, + ), + ) + retries = {"output": output_retries} + self._identity = QueryModelIdentity( + provider="ollama", + model=model_name, + ) + self._planner = Agent( + model, + output_type=NativeOutput(QueryPlan), + instructions=_PLANNING_INSTRUCTIONS, + retries=retries, + model_settings={ + "temperature": 0, + "max_tokens": 1024, + "timeout": timeout_seconds, + }, + ) + self._synthesizer = Agent( + model, + output_type=NativeOutput(DraftAnswer), + instructions=_SYNTHESIS_INSTRUCTIONS, + retries=retries, + model_settings={ + "temperature": 0, + "max_tokens": 2048, + "timeout": timeout_seconds, + }, + ) + + @property + def identity(self) -> QueryModelIdentity: + return self._identity + + def plan(self, request: QueryPlanningRequest) -> QueryPlan: + return self._run(self._planner, request.model_dump_json()) + + def synthesize(self, request: QuerySynthesisRequest) -> DraftAnswer: + return self._run(self._synthesizer, request.model_dump_json()) + + @staticmethod + def _run(agent: Agent, prompt: str): + try: + return agent.run_sync(prompt).output + except ( + AgentRunError, + OpenAIError, + httpx.HTTPError, + TimeoutError, + ValidationError, + ) as exc: + raise QueryProviderError( + "The configured Ollama query model is unavailable." + ) from exc diff --git a/src/vidxp/job_service.py b/src/vidxp/job_service.py index b89fe2f..dcad2df 100644 --- a/src/vidxp/job_service.py +++ b/src/vidxp/job_service.py @@ -24,6 +24,8 @@ MediaImportJobRequest, PrepareModelsCommand, PrepareModelsJobRequest, + QueryJobRequest, + QueryVideoCommand, ResourceNotFoundError, SearchCommand, SearchJobRequest, @@ -42,6 +44,8 @@ class ReadJobPlanner(Protocol): def plan_search(self, command: SearchCommand) -> SearchJobRequest: ... + def plan_query(self, command: QueryVideoCommand) -> QueryJobRequest: ... + def plan_actor_overlay( self, command: CreateActorOverlayCommand, @@ -129,6 +133,19 @@ def submit_search( job_id=job_id, ) + @job_boundary + def submit_query( + self, + command: QueryVideoCommand, + *, + job_id: str | None = None, + ) -> Job: + return self.backend.submit( + self._read_job_planner().plan_query(command), + queue=self._model_queue(), + job_id=job_id, + ) + @job_boundary def submit_snippet( self, diff --git a/src/vidxp/mcp.py b/src/vidxp/mcp.py index 2025532..c20037c 100644 --- a/src/vidxp/mcp.py +++ b/src/vidxp/mcp.py @@ -32,6 +32,7 @@ Job, JobId, Principal, + QueryVideoCommand, SearchCommand, ) from vidxp.authentication import ( @@ -418,6 +419,37 @@ def submit(actor: Principal) -> Job: operation=submit, ) + @server.tool( + description=( + "Submit a durable grounded natural-language query over indexed " + "moments and actor evidence." + ), + annotations=_SUBMIT, + structured_output=True, + ) + async def query_video( + command: QueryVideoCommand, + idempotency_key: IdempotencyKey, + ) -> Job: + def submit(actor: Principal) -> Job: + return context.jobs.submit_query( + command, + job_id=scoped_job_id( + repository_id=settings.repository_id, + principal=actor, + transport="mcp", + operation="query", + idempotency_key=idempotency_key, + ), + ) + + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=submit, + ) + @server.tool( description="Poll a durable VidXP job and its typed result.", annotations=_READ_ONLY, diff --git a/src/vidxp/ports.py b/src/vidxp/ports.py index 38745e1..b3d7d6a 100644 --- a/src/vidxp/ports.py +++ b/src/vidxp/ports.py @@ -12,11 +12,16 @@ ) from vidxp.application_models import ( + DraftAnswer, Job, JobPage, JobQueue, JobRequest, ListJobsCommand, + QueryModelIdentity, + QueryPlan, + QueryPlanningRequest, + QuerySynthesisRequest, RuntimeProfile, ) from vidxp.core.artifacts import ( @@ -51,6 +56,20 @@ class JobIdempotencyConflictError(RuntimeError): """Raised when one workflow ID is reused for a different request.""" +class QueryProviderError(RuntimeError): + """Raised when the optional language-model provider cannot respond.""" + + +@runtime_checkable +class QueryModelPort(Protocol): + @property + def identity(self) -> QueryModelIdentity: ... + + def plan(self, request: QueryPlanningRequest) -> QueryPlan: ... + + def synthesize(self, request: QuerySynthesisRequest) -> DraftAnswer: ... + + class LocalFileResource: """Authorized local delivery handle; never serialize this object.""" diff --git a/src/vidxp/query_service.py b/src/vidxp/query_service.py new file mode 100644 index 0000000..c8d0aaf --- /dev/null +++ b/src/vidxp/query_service.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +import hashlib +import json + +from vidxp.application_models import ( + ActorEvidence, + DraftAnswer, + Evidence, + FusedSearchResult, + GroundedClaim, + IndexSnapshotReference, + MomentEvidence, + QueryAnswer, + QueryAnswerMode, + QueryPlan, + QueryPlanningRequest, + QuerySynthesisRequest, + QueryVideoCommand, + SearchMomentsPlanStep, + ActorOverviewPlanStep, +) +from vidxp.capabilities.actor.schemas import ActorClusterSummary +from vidxp.ports import QueryModelPort, QueryProviderError + + +def _evidence_id(payload: dict[str, object]) -> str: + encoded = json.dumps( + payload, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _default_plan( + command: QueryVideoCommand, + *, + search_modalities: tuple[str, ...], + actor_overview: bool, +) -> QueryPlan: + steps = [ + SearchMomentsPlanStep( + modality=modality, + query=command.question, + ) + for modality in search_modalities + ] + if actor_overview: + steps.append(ActorOverviewPlanStep()) + return QueryPlan(steps=tuple(steps)) + + +def _valid_plan( + plan: QueryPlan, + *, + search_modalities: tuple[str, ...], + actor_overview: bool, +) -> bool: + searches = [ + step.modality + for step in plan.steps + if isinstance(step, SearchMomentsPlanStep) + ] + actor_steps = sum( + isinstance(step, ActorOverviewPlanStep) for step in plan.steps + ) + return ( + len(searches) == len(set(searches)) + and set(searches) == set(search_modalities) + and actor_steps == int(actor_overview) + ) + + +class GroundedQueryService: + """Apply bounded model assistance to application-owned retrieval evidence.""" + + def __init__(self, model: QueryModelPort | None = None) -> None: + self.model = model + + def plan( + self, + command: QueryVideoCommand, + *, + search_modalities: tuple[str, ...], + actor_overview: bool, + ) -> tuple[QueryPlan, str | None]: + fallback = _default_plan( + command, + search_modalities=search_modalities, + actor_overview=actor_overview, + ) + if self.model is None: + return fallback, "query_model_not_configured" + try: + proposed = self.model.plan( + QueryPlanningRequest( + question=command.question, + allowed_modalities=search_modalities, + actor_overview_allowed=actor_overview, + ) + ) + except QueryProviderError: + return fallback, "query_model_unavailable" + if not _valid_plan( + proposed, + search_modalities=search_modalities, + actor_overview=actor_overview, + ): + return fallback, "query_plan_rejected" + return proposed, None + + @staticmethod + def evidence( + *, + snapshot: IndexSnapshotReference, + fused: FusedSearchResult, + actors: tuple[ActorClusterSummary, ...], + ) -> tuple[Evidence, ...]: + items: list[Evidence] = [] + evidence_ids: set[str] = set() + for moment in fused.moments: + for hit in moment.hits: + display_text = hit.metadata.get("text") + if not isinstance(display_text, str) or not display_text.strip(): + display_text = None + identity = { + "kind": "moment", + "snapshot_id": snapshot.snapshot_id, + "media_id": hit.media_id, + "generation_id": hit.generation_id, + "modality": hit.modality, + "source_id": hit.source_id, + "start": hit.start, + "end": hit.end, + } + evidence_id = _evidence_id(identity) + if evidence_id in evidence_ids: + continue + evidence_ids.add(evidence_id) + items.append( + MomentEvidence( + evidence_id=evidence_id, + snapshot_id=snapshot.snapshot_id, + display_text=display_text, + hit=hit, + **{ + key: value + for key, value in identity.items() + if key not in {"kind", "snapshot_id"} + }, + ) + ) + if len(items) == 200: + return tuple(items) + for actor in actors: + identity = { + "kind": "actor", + "snapshot_id": snapshot.snapshot_id, + "media_id": actor.media_id, + "generation_id": actor.generation_id, + "cluster_id": actor.cluster_id, + "start": actor.first_timestamp, + "end": actor.last_timestamp, + } + evidence_id = _evidence_id(identity) + if evidence_id in evidence_ids: + continue + evidence_ids.add(evidence_id) + items.append( + ActorEvidence( + evidence_id=evidence_id, + snapshot_id=snapshot.snapshot_id, + media_id=actor.media_id, + generation_id=actor.generation_id, + cluster_id=actor.cluster_id, + start=actor.first_timestamp, + end=actor.last_timestamp, + detection_count=actor.detection_count, + display_text=( + f"Actor cluster {actor.cluster_id} appears " + f"{actor.detection_count} times from " + f"{actor.first_timestamp:.3f}s to " + f"{actor.last_timestamp:.3f}s." + ), + ) + ) + if len(items) == 200: + break + return tuple(items) + + def answer( + self, + command: QueryVideoCommand, + *, + plan: QueryPlan, + planning_fallback: str | None, + evidence: tuple[Evidence, ...], + fused: FusedSearchResult, + ) -> QueryAnswer: + common = { + "question": command.question, + "plan": plan, + "model": self.model.identity if self.model is not None else None, + "evidence": evidence, + "moments": fused.moments, + "fusion": fused.fusion, + } + if not evidence: + return QueryAnswer( + mode=QueryAnswerMode.no_evidence, + fallback_reason="no_evidence", + **common, + ) + + citable = tuple( + item + for item in evidence + if isinstance(item, ActorEvidence) + or ( + isinstance(item, MomentEvidence) + and item.display_text is not None + ) + )[:200] + if self.model is None: + return QueryAnswer( + mode=QueryAnswerMode.evidence_only, + fallback_reason=planning_fallback, + **common, + ) + if planning_fallback == "query_model_unavailable": + return QueryAnswer( + mode=QueryAnswerMode.evidence_only, + fallback_reason=planning_fallback, + **common, + ) + if not citable: + return QueryAnswer( + mode=QueryAnswerMode.evidence_only, + fallback_reason="no_textual_evidence", + **common, + ) + try: + draft = self.model.synthesize( + QuerySynthesisRequest( + question=command.question, + evidence=citable, + ) + ) + except QueryProviderError: + return QueryAnswer( + mode=QueryAnswerMode.evidence_only, + fallback_reason="query_model_unavailable", + **common, + ) + claims = self._validated_claims(draft, citable) + if claims is None: + return QueryAnswer( + mode=QueryAnswerMode.evidence_only, + fallback_reason="query_citations_rejected", + **common, + ) + return QueryAnswer( + mode=QueryAnswerMode.generated, + claims=claims, + fallback_reason=planning_fallback, + **common, + ) + + @staticmethod + def _validated_claims( + draft: DraftAnswer, + evidence: tuple[Evidence, ...], + ) -> tuple[GroundedClaim, ...] | None: + allowed = {item.evidence_id for item in evidence} + claims = tuple( + GroundedClaim( + text=claim.text, + evidence_ids=claim.evidence_ids, + ) + for claim in draft.claims + ) + cited = { + evidence_id + for claim in claims + for evidence_id in claim.evidence_ids + } + if not cited.issubset(allowed): + return None + return claims diff --git a/src/vidxp/read_job_planner.py b/src/vidxp/read_job_planner.py index 2012bb9..19079b8 100644 --- a/src/vidxp/read_job_planner.py +++ b/src/vidxp/read_job_planner.py @@ -5,6 +5,8 @@ ActorOverlayJobRequest, CreateActorOverlayCommand, IndexSnapshotReference, + QueryJobRequest, + QueryVideoCommand, SearchCommand, SearchJobRequest, ) @@ -49,9 +51,17 @@ def _require_capability(capability: str, config) -> None: @application_boundary def plan_search(self, command: SearchCommand) -> SearchJobRequest: config, reference = self._active() - self._require_capability(command.modality, config) + for capability in command.modalities: + self._require_capability(capability, config) return SearchJobRequest(command=command, snapshot=reference) + @application_boundary + def plan_query(self, command: QueryVideoCommand) -> QueryJobRequest: + config, reference = self._active() + for capability in command.modalities: + self._require_capability(capability, config) + return QueryJobRequest(command=command, snapshot=reference) + @application_boundary def plan_actor_overlay( self, diff --git a/src/vidxp/requirements/slm.txt b/src/vidxp/requirements/slm.txt new file mode 100644 index 0000000..d4d765f --- /dev/null +++ b/src/vidxp/requirements/slm.txt @@ -0,0 +1 @@ +pydantic-ai-slim[openai]>=2.13,<3 diff --git a/src/vidxp/search_fusion.py b/src/vidxp/search_fusion.py new file mode 100644 index 0000000..b2f4828 --- /dev/null +++ b/src/vidxp/search_fusion.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import hashlib + +from vidxp.application_models import ( + FusedMoment, + FusedSearchResult, + FusionProvenance, + SearchHit, + SearchResult, +) + + +RRF_RANK_CONSTANT = 60 + + +def _query_id( + query: str, + modalities: tuple[str, ...], + media_id: str | None, + atomic_query_ids: tuple[str, ...], +) -> str: + identity = "\0".join( + ( + "rrf_v1", + query, + ",".join(modalities), + media_id or "*", + *atomic_query_ids, + ) + ) + return "fused:" + hashlib.sha256(identity.encode("utf-8")).hexdigest() + + +def _connected_components( + hits: tuple[SearchHit, ...], +) -> list[list[SearchHit]]: + ordered = sorted( + hits, + key=lambda hit: ( + hit.media_id, + hit.start, + hit.end, + hit.modality, + hit.rank, + hit.source_id, + ), + ) + components: list[list[SearchHit]] = [] + current: list[SearchHit] = [] + current_media: str | None = None + current_end = 0.0 + for hit in ordered: + if ( + not current + or hit.media_id != current_media + or hit.start > current_end + ): + if current: + components.append(current) + current = [hit] + current_media = hit.media_id + current_end = hit.end + else: + current.append(hit) + current_end = max(current_end, hit.end) + if current: + components.append(current) + return components + + +def _score(hits: list[SearchHit]) -> float: + best_ranks: dict[str, int] = {} + for hit in hits: + best_ranks[hit.modality] = min( + hit.rank, + best_ranks.get(hit.modality, hit.rank), + ) + return sum( + 1.0 / (RRF_RANK_CONSTANT + rank) + for rank in best_ranks.values() + ) + + +def fuse_search_results( + *, + query: str, + requested_modalities: tuple[str, ...], + results: tuple[SearchResult, ...], + media_id: str | None = None, + top_k: int = 10, +) -> FusedSearchResult: + by_modality = {result.modality: result for result in results} + if len(by_modality) != len(results): + raise ValueError("Fusion accepts one result per modality.") + searched_modalities = tuple( + modality + for modality in requested_modalities + if modality in by_modality + ) + tuple( + sorted(set(by_modality) - set(requested_modalities)) + ) + ordered_results = tuple( + by_modality[modality] for modality in searched_modalities + ) + flattened = tuple( + hit for result in ordered_results for hit in result.hits + ) + candidates = [] + for hits in _connected_components(flattened): + ordered_hits = tuple( + sorted( + hits, + key=lambda hit: ( + hit.modality, + hit.rank, + hit.source_id, + ), + ) + ) + candidates.append( + { + "score": _score(hits), + "media_id": hits[0].media_id, + "start": min(hit.start for hit in hits), + "end": max(hit.end for hit in hits), + "modalities": tuple( + sorted({hit.modality for hit in hits}) + ), + "hits": ordered_hits, + } + ) + candidates.sort( + key=lambda item: ( + -item["score"], + item["media_id"], + item["start"], + item["end"], + tuple(hit.source_id for hit in item["hits"]), + ) + ) + moments = tuple( + FusedMoment(rank=rank, **candidate) + for rank, candidate in enumerate(candidates[:top_k], start=1) + ) + return FusedSearchResult( + query_id=_query_id( + query, + searched_modalities, + media_id, + tuple(result.query_id for result in ordered_results), + ), + query=query, + modalities=searched_modalities, + moments=moments, + fusion=FusionProvenance( + requested_modalities=requested_modalities, + searched_modalities=searched_modalities, + ), + ) diff --git a/src/vidxp/settings.py b/src/vidxp/settings.py index e8550a5..59d2817 100644 --- a/src/vidxp/settings.py +++ b/src/vidxp/settings.py @@ -168,12 +168,32 @@ class VidXPSettings(BaseSettings): min_length=1, max_length=2048, ) + slm_base_url: str | None = Field( + default=None, + min_length=1, + max_length=2048, + ) + slm_model: str | None = Field( + default=None, + min_length=1, + max_length=255, + ) + slm_timeout_seconds: float = Field(default=60, gt=0, le=600) + slm_output_retries: int = Field(default=1, ge=0, le=3) trusted_local_import_roots: tuple[Path, ...] = () ffprobe_executable: str = Field(default="ffprobe", min_length=1) ffmpeg_executable: str = Field(default="ffmpeg", min_length=1) external_capabilities: bool = False capability_allowlist: tuple[str, ...] = () + @field_validator("slm_base_url", "slm_model", mode="before") + @classmethod + def _normalize_empty_optional_slm( + cls, + value: str | None, + ) -> str | None: + return None if value == "" else value + @field_validator("runtime_backend") @classmethod def _validate_runtime_backend(cls, value: str) -> str: @@ -362,6 +382,7 @@ def _validate_oidc_url( "upload_public_endpoint", "upload_internal_endpoint", "chroma_server_url", + "slm_base_url", ) @classmethod def _validate_service_url( @@ -406,7 +427,17 @@ def _validate_service_url( and parsed.path not in {"", "/"} ): raise ValueError("chroma_server_url must not contain a path.") - if info.field_name != "chroma_server_url" and not value.endswith("/"): + if info.field_name == "slm_base_url": + if parsed.path.rstrip("/") != "/v1": + raise ValueError("slm_base_url must end with /v1.") + if parsed.hostname.lower() in {"ollama.com", "www.ollama.com"}: + raise ValueError( + "slm_base_url must use a self-hosted Ollama service." + ) + if ( + info.field_name not in {"chroma_server_url", "slm_base_url"} + and not value.endswith("/") + ): raise ValueError(f"{info.field_name} must end with a slash.") return value @@ -517,6 +548,12 @@ def _require_explicit_server_backend(self) -> "VidXPSettings": "Remote uploads require an internal tusd endpoint and " "a cleanup token of at least 32 characters." ) + if (self.slm_base_url is None) != (self.slm_model is None): + raise ValueError( + "slm_base_url and slm_model must be configured together." + ) + if self.slm_model is not None and self.slm_model.endswith("-cloud"): + raise ValueError("slm_model must be a self-hosted Ollama model.") return self def validate_http_server(self) -> None: @@ -582,6 +619,10 @@ class LocalExecutionSettings(BaseModel): ffmpeg_executable: str external_capabilities: bool capability_allowlist: tuple[str, ...] + slm_base_url: str | None + slm_model: str | None + slm_timeout_seconds: float + slm_output_retries: int @classmethod def from_settings( diff --git a/src/vidxp/workflow_contracts.py b/src/vidxp/workflow_contracts.py index 4bff1cc..e49fe75 100644 --- a/src/vidxp/workflow_contracts.py +++ b/src/vidxp/workflow_contracts.py @@ -1,6 +1,10 @@ from __future__ import annotations -from vidxp.application_models import JobKind, JobQueue +from typing import Any + +from pydantic import TypeAdapter + +from vidxp.application_models import JobKind, JobQueue, JobRequest APPLICATION_NAME = "vidxp" @@ -12,12 +16,35 @@ WORKFLOW_NAMES: dict[JobKind, str] = { JobKind.media_import: "vidxp.media_import.v1", JobKind.index: "vidxp.index.v1", - JobKind.search: "vidxp.search.v1", + JobKind.search: "vidxp.search.v2", + JobKind.query: "vidxp.query.v1", JobKind.snippet: "vidxp.snippet.v1", JobKind.actor_overlay: "vidxp.actor_overlay.v1", JobKind.prepare_models: "vidxp.prepare_models.v1", } WORKFLOW_KINDS = {name: kind for kind, name in WORKFLOW_NAMES.items()} +WORKFLOW_KINDS["vidxp.search.v1"] = JobKind.search +_JOB_REQUEST_ADAPTER = TypeAdapter(JobRequest) + + +def decode_workflow_request(payload: Any) -> JobRequest: + """Validate current jobs while upgrading the retired search-v1 shape.""" + if isinstance(payload, dict) and payload.get("kind") == JobKind.search: + command = payload.get("command") + if ( + isinstance(command, dict) + and "modality" in command + and "modalities" not in command + ): + modality = command["modality"] + command = { + key: value + for key, value in command.items() + if key != "modality" + } + command["modalities"] = (modality,) + payload = {**payload, "command": command} + return _JOB_REQUEST_ADAPTER.validate_python(payload) QUEUE_NAMES: dict[JobQueue, str] = { JobQueue.cpu: "vidxp-cpu", diff --git a/tests/test_api.py b/tests/test_api.py index ccd9911..d736cc6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -27,6 +27,7 @@ MediaAsset, Principal, SearchCommand, + QueryVideoCommand, UploadIntent, ) from vidxp.composition import ( @@ -420,7 +421,7 @@ def test_search_submission_uses_the_durable_query_boundary(self): "/api/v1/jobs/search", headers={"Idempotency-Key": IDEMPOTENCY_KEY}, json={ - "modality": "scene", + "modalities": ["scene"], "query": "yellow taxi", "top_k": 3, }, @@ -432,12 +433,43 @@ def test_search_submission_uses_the_durable_query_boundary(self): self.assertEqual( command, SearchCommand( - modality="scene", + modalities=("scene",), query="yellow taxi", top_k=3, ), ) + def test_grounded_query_submission_uses_the_durable_boundary(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.jobs.submit_query.return_value = queued_job().model_copy( + update={"kind": JobKind.query} + ) + with TestClient(create_app(context=context)) as client: + response = client.post( + "/api/v1/jobs/query", + headers={"Idempotency-Key": IDEMPOTENCY_KEY}, + json={ + "question": "What happens after the taxi arrives?", + "media_id": MEDIA_ID, + "modalities": ["scene", "dialogue"], + "top_k": 5, + }, + ) + + self.assertEqual(response.status_code, 202) + context.jobs.submit_query.assert_called_once() + command = context.jobs.submit_query.call_args.args[0] + self.assertEqual( + command, + QueryVideoCommand( + question="What happens after the taxi arrives?", + media_id=MEDIA_ID, + modalities=("scene", "dialogue"), + top_k=5, + ), + ) + def test_idempotency_payload_mismatch_returns_422(self): with TemporaryDirectory() as directory: context = self.context(Path(directory)) diff --git a/tests/test_application.py b/tests/test_application.py index d66a228..7257a38 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -17,10 +17,13 @@ DependencyCheckCommand, DependencyCheckResult, DependencyUnavailableError, + FusedSearchResult, IndexResult, IndexSnapshotReference, ModelUnavailableError, PrepareModelsCommand, + QueryAnswerMode, + QueryVideoCommand, RemoveIndexCommand, SearchCommand, ) @@ -42,6 +45,8 @@ from vidxp.capabilities.actor.schemas import ( ActorClusterInput, ActorClusterSummary, + ActorClustersInput, + ActorClustersOutput, ActorDetection, ActorDetectionsInput, ActorDetectionsOutput, @@ -249,7 +254,7 @@ def handler(context, request): backend.config_for_snapshot.return_value = pinned result = application.search( - SearchCommand(modality="indexed", query="query"), + SearchCommand(modalities=("indexed",), query="query"), snapshot=IndexSnapshotReference( snapshot_id=SNAPSHOT_ID, snapshot_sha256=SNAPSHOT_SHA256, @@ -267,6 +272,44 @@ def handler(context, request): backend.open_store.assert_called_once_with(pinned) self.assertIs(contexts[0].storage, manager.__enter__.return_value) + def test_query_reuses_one_pinned_store_and_preserves_media_scope(self): + requests = [] + + def handler(_context, request): + requests.append(request) + return SearchResult( + query_id="indexed:query", + query=request.query, + modality="indexed", + ) + + manager = MagicMock() + manager.__enter__.return_value = Mock(spec=IndexStore) + application = self.indexed_application(handler, manager) + pinned = IndexConfig.local( + enabled_modalities=("indexed",), + collection_names={"indexed": "indexed"}, + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ) + application.index_backend.config_for_snapshot.return_value = pinned + + result = application.query_video( + QueryVideoCommand( + question="What happens?", + media_id=MEDIA_ID, + modalities=("indexed",), + ), + snapshot=IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ), + ) + + self.assertEqual(result.mode, QueryAnswerMode.no_evidence) + self.assertEqual(requests[0].media_id, MEDIA_ID) + application.index_backend.open_store.assert_called_once_with(pinned) + def test_actor_render_reuses_one_pinned_store_and_context(self): contexts = [] calls = [] @@ -458,30 +501,127 @@ def test_operation_definition_is_metadata_and_executor_owns_handler(self): self.assertEqual(result.query, "bundle") - def test_search_is_only_a_typed_projection_over_execute(self): - application, _ = self.application("unused") - expected = SearchResult( - query_id="scene:1", - query="yellow taxi", - modality="scene", - ) - application.execute = Mock(return_value=expected) + def test_search_reuses_the_registered_capability_operation(self): + calls = [] + + def handler(context, request): + calls.append((context, request)) + return SearchResult( + query_id="indexed:1", + query=request.query, + modality="indexed", + ) + + manager = MagicMock() + manager.__enter__.return_value = Mock(spec=IndexStore) + application = self.indexed_application(handler, manager) result = application.search( SearchCommand( - modality="scene", + modalities=("indexed",), query="yellow taxi", top_k=7, ) ) - self.assertIs(result, expected) - application.execute.assert_called_once_with( - "scene", - "search", - {"query": "yellow taxi", "top_k": 7}, + self.assertIsInstance(result, FusedSearchResult) + self.assertEqual(result.modalities, ("indexed",)) + self.assertEqual(calls[0][1].query, "yellow taxi") + self.assertEqual(calls[0][1].top_k, 7) + self.assertIs( + calls[0][0].storage, + manager.__enter__.return_value, ) + def test_default_search_filters_indexed_non_search_capabilities(self): + searched: list[str] = [] + + def search_plugin(name: str) -> CapabilityPlugin: + definition = CapabilityDefinition( + name=name, + description=f"{name} search.", + extra=name, + collection_name=name, + index_stage=name, + execution_group=name, + operations={ + "search": OperationDefinition( + input_model=SearchInput, + output_model=SearchResult, + ) + }, + ) + + def handler(_context, request): + searched.append(name) + return SearchResult( + query_id=f"{name}:query", + query=request.query, + modality=name, + ) + + return CapabilityPlugin( + definition=definition, + executor_factory=lambda: CapabilityExecutor( + indexer=Mock(), + operations={"search": handler}, + ), + ) + + actor = CapabilityPlugin( + definition=CapabilityDefinition( + name="actor", + description="Actor clusters.", + extra="actor", + collection_name="actor", + index_stage="actor", + execution_group="actor", + operations={ + "clusters": OperationDefinition( + input_model=ActorClustersInput, + output_model=ActorClustersOutput, + ) + }, + ), + executor_factory=lambda: CapabilityExecutor( + indexer=Mock(), + operations={ + "clusters": Mock( + side_effect=AssertionError( + "Search must not execute actor clusters." + ) + ) + }, + ), + ) + registry = CapabilityRegistry( + ( + search_plugin("scene"), + search_plugin("dialogue"), + actor, + ) + ) + manager = MagicMock() + manager.__enter__.return_value = Mock(spec=IndexStore) + application, backend = self.application( + "repository", + registry=registry, + ) + backend.active_config.return_value = IndexConfig.local( + enabled_modalities=("scene", "dialogue", "actor"), + collection_names={ + "scene": "scene", + "dialogue": "dialogue", + "actor": "actor", + }, + ) + backend.open_store.return_value = manager + + result = application.search(SearchCommand(query="taxi")) + + self.assertEqual(searched, ["scene", "dialogue"]) + self.assertEqual(result.modalities, ("scene", "dialogue")) + def test_application_boundary_returns_stable_validation_error(self): application, _ = self.application("unused") diff --git a/tests/test_cli.py b/tests/test_cli.py index 009d560..a5aef2c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,6 +13,8 @@ from vidxp.application_models import ( CreateIndexCommand, DependencyCheckResult, + FusedSearchResult, + FusionProvenance, IndexJobResult, IndexResult, IndexStatus, @@ -23,14 +25,19 @@ MediaAsset, PrepareModelsResult, PrepareModelsJobResult, + QueryAnswer, + QueryAnswerMode, + QueryJobResult, + QueryPlan, + QueryVideoCommand, RemoveIndexCommand, SearchCommand, SearchJobResult, + SearchMomentsPlanStep, ) from vidxp.core.media import MediaState, MediaStream from vidxp.capabilities.registry import create_capability_registry from vidxp.capability_service import CapabilityService -from vidxp.capabilities.schemas import SearchResult from vidxp.repositories import RepositoryConfig, RepositoryRegistry @@ -100,10 +107,14 @@ def test_snippet_rejects_an_inverted_time_range_before_submission(self): self.jobs.submit_snippet.assert_not_called() def test_search_constructs_shared_command(self): - search_result = SearchResult( - query_id="scene:1", + search_result = FusedSearchResult( + query_id="fused:1", query="yellow taxi", - modality="scene", + modalities=("scene",), + fusion=FusionProvenance( + requested_modalities=("scene",), + searched_modalities=("scene",), + ), ) self.jobs.submit_search.return_value = Job( job_id=JOB_ID, @@ -126,13 +137,67 @@ def test_search_constructs_shared_command(self): self.assertEqual(result.exit_code, 0, result.output) self.jobs.submit_search.assert_called_once_with( SearchCommand( - modality="scene", + modalities=("scene",), query="yellow taxi", top_k=7, ) ) self.assertEqual(json.loads(result.output)["query"], "yellow taxi") + def test_query_constructs_shared_command_and_emits_typed_answer(self): + answer = QueryAnswer( + question="What happens?", + mode=QueryAnswerMode.no_evidence, + plan=QueryPlan( + steps=( + SearchMomentsPlanStep( + modality="scene", + query="What happens?", + ), + ) + ), + fusion=FusionProvenance( + requested_modalities=("scene",), + searched_modalities=("scene",), + ), + fallback_reason="no_evidence", + ) + self.jobs.submit_query.return_value = Job( + job_id=JOB_ID, + kind=JobKind.query, + state=JobState.queued, + queue=JobQueue.cpu, + ) + self.jobs.wait.return_value = Job( + job_id=JOB_ID, + kind=JobKind.query, + state=JobState.succeeded, + queue=JobQueue.cpu, + result=QueryJobResult(result=answer), + ) + + result = self.invoke( + [ + "query", + "What happens?", + "--media-id", + MEDIA_ID, + "--modality", + "scene", + "--json", + ] + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.jobs.submit_query.assert_called_once_with( + QueryVideoCommand( + question="What happens?", + media_id=MEDIA_ID, + modalities=("scene",), + ) + ) + self.assertEqual(json.loads(result.output)["mode"], "no_evidence") + def test_index_constructs_shared_command(self): result_value = IndexResult( media_id=MEDIA_ID, diff --git a/tests/test_dbos_jobs.py b/tests/test_dbos_jobs.py index 20fc1cf..fb977cf 100644 --- a/tests/test_dbos_jobs.py +++ b/tests/test_dbos_jobs.py @@ -3,7 +3,7 @@ from tempfile import TemporaryDirectory from threading import Event from time import sleep -from unittest.mock import Mock +from unittest.mock import ANY, Mock from dbos import DBOS @@ -11,22 +11,40 @@ ApplicationError, CreateIndexCommand, ErrorCategory, + FusedSearchResult, + FusionProvenance, IndexSnapshotReference, IndexResult, JobKind, JobQueue, JobState, ListJobsCommand, + QueryAnswer, + QueryAnswerMode, + QueryJobRequest, + QueryPlan, + QueryVideoCommand, SearchCommand, + SearchHit, SearchJobRequest, + SearchMomentsPlanStep, SearchResult, ) from vidxp.core.cursors import MAX_CURSOR_OFFSET, encode_cursor -from vidxp.infrastructure.dbos_jobs import DBOSJobBackend +from vidxp.infrastructure.dbos_jobs import ( + DBOSJobBackend, + _decode_search_result, +) from vidxp.job_service import JobService from vidxp.ports import InvalidJobBackendRequestError from vidxp.settings import VidXPSettings -from vidxp.workflow_contracts import QUEUE_NAMES +from vidxp.workflow_contracts import ( + QUEUE_NAMES, + WORKFLOW_CLASS_NAME, + WORKFLOW_INSTANCE_NAME, + WORKFLOW_KINDS, + decode_workflow_request, +) MEDIA_ID = "123456781234423481234567890abcde" @@ -65,10 +83,31 @@ def setUp(self): active_media_count=1, record_counts={"scene": 1}, ) - self.application.search.return_value = SearchResult( - query_id="scene:taxi", + self.application.search.return_value = FusedSearchResult( + query_id="fused:taxi", query="taxi", - modality="scene", + modalities=("scene",), + fusion=FusionProvenance( + requested_modalities=("scene",), + searched_modalities=("scene",), + ), + ) + self.application.query_video.return_value = QueryAnswer( + question="What happens?", + mode=QueryAnswerMode.no_evidence, + plan=QueryPlan( + steps=( + SearchMomentsPlanStep( + modality="scene", + query="What happens?", + ), + ) + ), + fusion=FusionProvenance( + requested_modalities=("scene",), + searched_modalities=("scene",), + ), + fallback_reason="no_evidence", ) self.worker.application = self.application DBOS.reset_system_database() @@ -98,7 +137,16 @@ def setUp(self): snapshot_sha256=SNAPSHOT_SHA256, ), ) - ) + ), + plan_query=Mock( + side_effect=lambda command: QueryJobRequest( + command=command, + snapshot=IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ), + ) + ), ), ) @@ -156,7 +204,11 @@ def test_health_requires_owned_executor_health(self): def test_model_search_runs_in_worker_and_returns_typed_result(self): submitted = self.jobs.submit_search( - SearchCommand(modality="scene", query="taxi", top_k=1) + SearchCommand( + modalities=("scene",), + query="taxi", + top_k=1, + ) ) completed = self.jobs.wait(submitted.job_id) @@ -164,13 +216,181 @@ def test_model_search_runs_in_worker_and_returns_typed_result(self): self.assertEqual(completed.kind, JobKind.search) self.assertEqual(completed.result.result.query, "taxi") self.application.search.assert_called_once_with( - SearchCommand(modality="scene", query="taxi", top_k=1), + SearchCommand( + modalities=("scene",), + query="taxi", + top_k=1, + ), snapshot=IndexSnapshotReference( snapshot_id=SNAPSHOT_ID, snapshot_sha256=SNAPSHOT_SHA256, ), ) + def test_grounded_query_runs_in_worker_and_returns_typed_result(self): + command = QueryVideoCommand( + question="What happens?", + modalities=("scene",), + ) + + submitted = self.jobs.submit_query(command) + completed = self.jobs.wait(submitted.job_id) + + self.assertEqual(completed.state, JobState.succeeded) + self.assertEqual(completed.kind, JobKind.query) + self.assertEqual( + completed.result.result.mode, + QueryAnswerMode.no_evidence, + ) + self.application.query_video.assert_called_once_with( + command, + snapshot=IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ), + execution=ANY, + ) + + def test_legacy_atomic_search_output_remains_readable(self): + legacy = SearchResult( + query_id="scene:legacy", + query="taxi", + modality="scene", + hits=( + SearchHit( + rank=1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, + start=1, + end=2, + score=-0.1, + raw_distance=0.1, + modality="scene", + source_id="scene:1", + ), + ), + ) + + decoded = _decode_search_result(legacy.model_dump(mode="json")) + + self.assertEqual(decoded.query, "taxi") + self.assertEqual(decoded.moments[0].hits[0].source_id, "scene:1") + self.assertEqual(WORKFLOW_KINDS["vidxp.search.v1"], JobKind.search) + + def test_legacy_search_request_upgrades_to_the_v2_command(self): + upgraded = decode_workflow_request( + { + "kind": "search", + "command": { + "modality": "scene", + "query": "taxi", + "top_k": 3, + }, + "snapshot": { + "snapshot_id": SNAPSHOT_ID, + "snapshot_sha256": SNAPSHOT_SHA256, + }, + } + ) + + self.assertIsInstance(upgraded, SearchJobRequest) + self.assertEqual(upgraded.command.modalities, ("scene",)) + self.assertEqual(upgraded.command.top_k, 3) + self.assertTrue(hasattr(self.worker, "legacy_search_workflow")) + self.assertTrue(hasattr(self.worker, "run_legacy_search_step")) + + def test_registered_legacy_search_workflow_executes_upgraded_request(self): + legacy_id = "923456781234423481234567890abcde" + self.backend.client.enqueue( + { + "workflow_name": "vidxp.search.v1", + "queue_name": QUEUE_NAMES[JobQueue.cpu], + "workflow_id": legacy_id, + "app_version": "test-v1", + "attributes": {"vidxp_queue": JobQueue.cpu.value}, + "class_name": WORKFLOW_CLASS_NAME, + "instance_name": WORKFLOW_INSTANCE_NAME, + }, + { + "kind": "search", + "command": { + "modality": "scene", + "query": "taxi", + "top_k": 1, + }, + "snapshot": { + "snapshot_id": SNAPSHOT_ID, + "snapshot_sha256": SNAPSHOT_SHA256, + }, + }, + ) + + completed = self.jobs.wait(legacy_id) + + self.assertEqual(completed.state, JobState.succeeded) + self.application.search.assert_called_once_with( + SearchCommand( + modalities=("scene",), + query="taxi", + top_k=1, + ), + snapshot=IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ), + ) + + def test_failed_legacy_search_retries_as_a_v2_workflow(self): + legacy_id = "a23456781234423481234567890abcde" + self.application.search.side_effect = ( + ApplicationError( + "temporary_search_failure", + ErrorCategory.unavailable, + "The temporary search dependency is unavailable.", + retryable=True, + ), + self.application.search.return_value, + ) + payload = { + "kind": "search", + "command": { + "modality": "scene", + "query": "taxi", + "top_k": 1, + }, + "snapshot": { + "snapshot_id": SNAPSHOT_ID, + "snapshot_sha256": SNAPSHOT_SHA256, + }, + } + self.backend.client.enqueue( + { + "workflow_name": "vidxp.search.v1", + "queue_name": QUEUE_NAMES[JobQueue.cpu], + "workflow_id": legacy_id, + "app_version": "test-v1", + "attributes": {"vidxp_queue": JobQueue.cpu.value}, + "class_name": WORKFLOW_CLASS_NAME, + "instance_name": WORKFLOW_INSTANCE_NAME, + }, + payload, + ) + with self.assertRaises(ApplicationError): + self.jobs.wait(legacy_id) + + retried = self.jobs.retry( + legacy_id, + retry_id=IDEMPOTENCY_KEY, + ) + completed = self.jobs.wait(retried.job_id) + + self.assertEqual(retried.job_id, IDEMPOTENCY_KEY) + self.assertEqual(completed.state, JobState.succeeded) + self.assertEqual(completed.result.result.query, "taxi") + status = self.backend._status(retried.job_id) + self.assertEqual(status.name, "vidxp.search.v2") + def test_huge_cursor_offset_is_rejected_before_database_access(self): cursor = encode_cursor( "vidxp:jobs", diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 9752ae1..cb6ef5f 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -10,10 +10,12 @@ DependencyCheckResult, CreateActorOverlayCommand, ErrorCategory, + FusedMoment, + FusedSearchResult, + FusionProvenance, IndexStatus, JobState, SearchHit, - SearchResult, ) from vidxp.capabilities.registry import create_capability_registry from vidxp.capability_service import CapabilityService @@ -216,7 +218,7 @@ def test_search_queues_shared_command_without_loading_models(self): self.assertEqual(result["job_id"], "job-1") command = jobs.submit_search.call_args.args[0] - self.assertEqual(command.modality, "scene") + self.assertEqual(command.modalities, ("scene",)) self.assertEqual(command.query, "taxi") service.search.assert_not_called() @@ -434,24 +436,37 @@ def test_actor_poll_persists_cancelled_job_error(self): rerun.assert_called_once_with() def test_search_poll_persists_the_best_typed_hit(self): - completed = SearchResult( - query_id="scene:taxi", - query="taxi", + hit = SearchHit( + rank=1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, + start=12.5, + end=13.0, + score=-0.1, + raw_distance=0.1, modality="scene", - hits=( - SearchHit( + source_id="scene:1", + ) + completed = FusedSearchResult( + query_id="fused:taxi", + query="taxi", + modalities=("scene",), + moments=( + FusedMoment( rank=1, + score=1 / 61, media_id=MEDIA_ID, - video_id=MEDIA_ID, - generation_id=GENERATION_ID, start=12.5, end=13.0, - score=-0.1, - raw_distance=0.1, - modality="scene", - source_id="scene:1", + modalities=("scene",), + hits=(hit,), ), ), + fusion=FusionProvenance( + requested_modalities=("scene",), + searched_modalities=("scene",), + ), ) job = Mock(state=JobState.succeeded, error=None) job.result.result = completed @@ -471,10 +486,14 @@ def test_search_poll_persists_the_best_typed_hit(self): rerun.assert_called_once_with() def test_restored_search_uses_completed_query_when_no_hit_exists(self): - completed = SearchResult( - query_id="scene:taxi", + completed = FusedSearchResult( + query_id="fused:taxi", query="yellow taxi", - modality="scene", + modalities=("scene",), + fusion=FusionProvenance( + requested_modalities=("scene",), + searched_modalities=("scene",), + ), ) job = Mock(state=JobState.succeeded, error=None) job.result.result = completed diff --git a/tests/test_job_contracts.py b/tests/test_job_contracts.py index a3e4ddf..3612fc5 100644 --- a/tests/test_job_contracts.py +++ b/tests/test_job_contracts.py @@ -19,6 +19,8 @@ InvalidRequestError, IndexSnapshotReference, ListJobsCommand, + QueryJobRequest, + QueryVideoCommand, SearchCommand, SearchJobRequest, ) @@ -57,7 +59,14 @@ def test_progress_is_typed_and_position_is_bounded(self): ) def test_public_job_contract_has_no_path_or_storage_fields(self): + job = Job( + job_id=JOB_ID, + kind=JobKind.index, + state=JobState.queued, + queue=JobQueue.cpu, + ) schema = json.dumps(Job.model_json_schema()) + self.assertEqual(job.schema_version, 2) self.assertNotIn("storage_key", schema) self.assertNotIn('"path"', schema) self.assertNotIn("model_cache", schema) @@ -102,7 +111,7 @@ def test_search_uses_the_same_model_worker_queue(self): backend = Mock() planner = Mock() command = SearchCommand( - modality="scene", + modalities=("scene",), query="taxi", top_k=2, ) @@ -139,6 +148,43 @@ def test_search_uses_the_same_model_worker_queue(self): JobQueue.gpu, ) + def test_query_uses_the_same_pinned_model_worker_boundary(self): + backend = Mock() + planner = Mock() + command = QueryVideoCommand(question="What happens next?") + planner.plan_query.return_value = QueryJobRequest( + command=command, + snapshot=IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ), + ) + backend.submit.return_value = Job( + job_id=JOB_ID, + kind=JobKind.query, + state=JobState.queued, + queue=JobQueue.gpu, + ) + service = JobService( + settings=VidXPSettings( + repository_root=Path("repository"), + runtime_backend="cuda:0", + ), + backend=backend, + read_planner=planner, + ) + + service.submit_query(command) + + request = backend.submit.call_args.args[0] + self.assertEqual(request.kind, JobKind.query) + self.assertEqual(request.snapshot.snapshot_id, SNAPSHOT_ID) + planner.plan_query.assert_called_once_with(command) + self.assertEqual( + backend.submit.call_args.kwargs["queue"], + JobQueue.gpu, + ) + def test_actor_job_retains_pinned_snapshot_identity(self): backend = Mock() planner = Mock() @@ -173,16 +219,16 @@ def test_actor_job_retains_pinned_snapshot_identity(self): def test_search_and_actor_identifiers_are_bounded(self): command = SearchCommand( - modality=" scene ", + modalities=(" scene ",), query=" a chef prepares pizza ", ) - self.assertEqual(command.modality, "scene") + self.assertEqual(command.modalities, ("scene",)) self.assertEqual(command.query, "a chef prepares pizza") for values in ( - {"modality": "scene/video", "query": "taxi"}, - {"modality": "scene", "query": "x" * 4097}, - {"modality": "scene", "query": " "}, + {"modalities": ["scene/video"], "query": "taxi"}, + {"modalities": ["scene"], "query": "x" * 4097}, + {"modalities": ["scene"], "query": " "}, ): with self.assertRaises(ValidationError): SearchCommand(**values) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 0e0cafc..81bdb14 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -23,6 +23,7 @@ JobQueue, JobState, Principal, + QueryVideoCommand, ) from vidxp.authentication import ( AuthenticatedBearer, @@ -77,7 +78,7 @@ def context( application = Mock(spec=ControlPlaneApplication) application.list_capabilities.return_value = () application.index_status.return_value = IndexStatus( - schema_version=1, + schema_version=2, state="missing", stage="status", message="No index.", @@ -116,6 +117,7 @@ async def test_curated_tools_have_structured_output_schemas(self): "get_index_status", "start_indexing", "search_moments", + "query_video", "get_job", "cancel_job", ], @@ -159,6 +161,47 @@ async def test_index_submission_uses_shared_stable_idempotency(self): calls[1].kwargs["job_id"], ) + async def test_query_video_submits_the_shared_durable_command(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.jobs.submit_query.return_value = queued_job().model_copy( + update={"kind": JobKind.query} + ) + server = create_mcp_server( + context, + default_principal=Principal( + subject="agent", + scopes=frozenset({"vidxp.read"}), + ), + ) + async with Client(server) as client: + result = await client.call_tool( + "query_video", + { + "command": { + "question": "What happens after the taxi arrives?", + "media_id": MEDIA_ID, + "modalities": ["scene", "dialogue"], + }, + "idempotency_key": "agent-query-0001", + }, + ) + + self.assertFalse(result.is_error) + command = context.jobs.submit_query.call_args.args[0] + self.assertEqual( + command, + QueryVideoCommand( + question="What happens after the taxi arrives?", + media_id=MEDIA_ID, + modalities=("scene", "dialogue"), + ), + ) + self.assertIn( + "job_id", + context.jobs.submit_query.call_args.kwargs, + ) + async def test_application_errors_are_machine_readable_and_safe(self): with TemporaryDirectory() as directory: context = self.context(Path(directory)) @@ -258,6 +301,7 @@ async def test_stdio_entrypoint_serves_the_same_curated_surface(self): "get_index_status", "start_indexing", "search_moments", + "query_video", "get_job", "cancel_job", ], @@ -313,7 +357,7 @@ async def test_streamable_http_works_with_the_official_remote_client(self): server.should_exit = True await serving - self.assertEqual(len(discovered.tools), 7) + self.assertEqual(len(discovered.tools), 8) self.assertEqual(result.structured_content, {"items": []}) async def test_oidc_verifier_projects_the_shared_validated_token(self): diff --git a/tests/test_models.py b/tests/test_models.py index 17d3a10..7559093 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,4 +1,5 @@ import hashlib +import os import sys import types import unittest @@ -429,6 +430,50 @@ def test_server_runtime_and_external_allowlist_are_explicit(self): self.assertEqual(settings.runtime_backend, "cuda:0") with self.assertRaises(ValidationError): VidXPSettings(chroma_server_url="http://localhost:8000/prefix") + with self.assertRaises(ValidationError): + VidXPSettings(slm_base_url="http://localhost:11434/v1") + with self.assertRaises(ValidationError): + VidXPSettings( + slm_base_url="https://ollama.com/v1", + slm_model="qwen3", + ) + with self.assertRaises(ValidationError): + VidXPSettings( + slm_base_url="http://localhost:11434/v1", + slm_model="qwen3-cloud", + ) + slm = VidXPSettings( + slm_base_url="http://localhost:11434/v1", + slm_model="evaluated-model", + ) + self.assertEqual(slm.slm_model, "evaluated-model") + + def test_only_optional_slm_environment_values_ignore_empty_strings(self): + with patch.dict( + os.environ, + { + "VIDXP_SLM_BASE_URL": "", + "VIDXP_SLM_MODEL": "", + }, + clear=True, + ): + settings = VidXPSettings(_env_file=None) + + self.assertIsNone(settings.slm_base_url) + self.assertIsNone(settings.slm_model) + + with ( + patch.dict( + os.environ, + { + "VIDXP_HTTP_AUTH_MODE": "static", + "VIDXP_HTTP_STATIC_BEARER_TOKEN": "", + }, + clear=True, + ), + self.assertRaises(ValidationError), + ): + VidXPSettings(_env_file=None) def test_auto_runtime_remains_cpu_until_acceleration_parity_is_enabled(self): with patch( diff --git a/tests/test_ollama_query.py b/tests/test_ollama_query.py new file mode 100644 index 0000000..61cf331 --- /dev/null +++ b/tests/test_ollama_query.py @@ -0,0 +1,136 @@ +import asyncio +import json +import unittest + +import httpx + +from vidxp.application_models import ( + DraftAnswer, + MomentEvidence, + QueryPlanningRequest, + QuerySynthesisRequest, + SearchHit, +) +from vidxp.infrastructure.ollama_query import OllamaQueryModel + + +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" +SNAPSHOT_ID = "323456781234423481234567890abcde" +EVIDENCE_ID = "a" * 64 + + +class OllamaQueryModelTests(unittest.TestCase): + def test_structured_planning_and_synthesis_use_the_provider_contract(self): + outputs = [ + { + "steps": [ + { + "kind": "search_moments", + "modality": "dialogue", + "query": "taxi arrival", + } + ] + }, + { + "claims": [ + { + "text": "The taxi arrived.", + "evidence_ids": [EVIDENCE_ID], + } + ] + }, + ] + requests: list[dict[str, object]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(json.loads(request.content)) + return httpx.Response( + 200, + request=request, + json={ + "id": f"chatcmpl-{len(requests)}", + "object": "chat.completion", + "created": 1, + "model": "contract-model", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": json.dumps(outputs.pop(0)), + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 10, + "total_tokens": 20, + }, + }, + ) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + model = OllamaQueryModel( + base_url="http://ollama.test/v1", + model_name="contract-model", + timeout_seconds=2, + output_retries=0, + http_client=client, + ) + hit = SearchHit( + rank=1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, + start=1, + end=2, + score=-0.1, + raw_distance=0.1, + modality="dialogue", + source_id="dialogue:1", + metadata={"text": "the taxi arrived"}, + ) + evidence = MomentEvidence( + evidence_id=EVIDENCE_ID, + snapshot_id=SNAPSHOT_ID, + media_id=MEDIA_ID, + generation_id=GENERATION_ID, + modality="dialogue", + source_id="dialogue:1", + start=1, + end=2, + display_text="the taxi arrived", + hit=hit, + ) + try: + plan = model.plan( + QueryPlanningRequest( + question="When did the taxi arrive?", + allowed_modalities=("dialogue",), + ) + ) + answer = model.synthesize( + QuerySynthesisRequest( + question="When did the taxi arrive?", + evidence=(evidence,), + ) + ) + finally: + asyncio.run(client.aclose()) + + self.assertEqual(plan.steps[0].query, "taxi arrival") + self.assertIsInstance(answer, DraftAnswer) + self.assertEqual(answer.claims[0].evidence_ids, (EVIDENCE_ID,)) + self.assertEqual(len(requests), 2) + for request in requests: + self.assertEqual( + request["response_format"]["type"], + "json_schema", + ) + self.assertEqual(request["model"], "contract-model") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 1db0d38..b069e7b 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -146,12 +146,41 @@ def test_install_profiles_keep_server_free_of_ml_dependencies(self): self.assertIn("mcp>=2.0,<3", mcp_requirements) self.assertNotIn("fastmcp", mcp_requirements) self.assertNotIn("fastapi-mcp", mcp_requirements) + slm_requirements = ( + ROOT / "src" / "vidxp" / "requirements" / "slm.txt" + ).read_text(encoding="utf-8") + self.assertIn( + "pydantic-ai-slim[openai]>=2.13,<3", + slm_requirements, + ) + self.assertNotIn("pydantic-ai", server) test_requirements = ( ROOT / "src" / "vidxp" / "requirements" / "test.txt" ).read_text(encoding="utf-8") self.assertIn("httpx>=0.28.1,<0.29", test_requirements) self.assertNotIn("httpx2", test_requirements) + def test_optional_ollama_profile_never_pulls_a_model_implicitly(self): + compose = (ROOT / "compose.coolify.yaml").read_text( + encoding="utf-8" + ) + + self.assertIn( + "ollama/ollama:0.32.5@sha256:" + "4dea9fb511947e24a84237bb636b0203abcb2ff0d3fbc7b4ff865deb91362131", + compose, + ) + self.assertIn('profiles: ["slm"]', compose) + self.assertIn( + "VIDXP_SLM_BASE_URL: ${VIDXP_SLM_BASE_URL:-}", + compose, + ) + self.assertIn( + "VIDXP_SLM_MODEL: ${VIDXP_SLM_MODEL:-}", + compose, + ) + self.assertNotIn("ollama pull", compose.lower()) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_query_service.py b/tests/test_query_service.py new file mode 100644 index 0000000..a57029d --- /dev/null +++ b/tests/test_query_service.py @@ -0,0 +1,377 @@ +import unittest + +from vidxp.application_models import ( + DraftAnswer, + DraftClaim, + FusedSearchResult, + FusionProvenance, + IndexSnapshotReference, + QueryAnswerMode, + QueryModelIdentity, + QueryPlan, + QueryVideoCommand, + SearchHit, + SearchMomentsPlanStep, + SearchResult, +) +from vidxp.ports import QueryProviderError +from vidxp.query_service import GroundedQueryService +from vidxp.search_fusion import fuse_search_results + + +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" +SNAPSHOT_ID = "323456781234423481234567890abcde" +SNAPSHOT_SHA256 = "a" * 64 + + +class FakeQueryModel: + identity = QueryModelIdentity(provider="ollama", model="test") + + def __init__(self, plan, answer=None): + self.proposed_plan = plan + self.proposed_answer = answer + + def plan(self, _request): + if isinstance(self.proposed_plan, Exception): + raise self.proposed_plan + return self.proposed_plan + + def synthesize(self, _request): + if isinstance(self.proposed_answer, Exception): + raise self.proposed_answer + return self.proposed_answer + + +def result(*, text: str | None = None, modality: str = "scene"): + metadata = {"text": text} if text is not None else {} + return SearchResult( + query_id=f"{modality}:q", + query="taxi", + modality=modality, + hits=( + SearchHit( + rank=1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, + start=1, + end=2, + score=-0.1, + raw_distance=0.1, + modality=modality, + source_id=f"{modality}:1", + metadata=metadata, + ), + ), + ) + + +def fused( + modality: str = "scene", + atomic: SearchResult | None = None, +): + if atomic is not None: + return fuse_search_results( + query=atomic.query, + requested_modalities=(modality,), + results=(atomic,), + ) + return FusedSearchResult( + query_id="fused:q", + query="taxi", + modalities=(modality,), + fusion=FusionProvenance( + requested_modalities=(modality,), + searched_modalities=(modality,), + ), + ) + + +class GroundedQueryServiceTests(unittest.TestCase): + def setUp(self): + self.command = QueryVideoCommand(question="Where is the taxi?") + self.snapshot = IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ) + + def test_invalid_model_plan_falls_back_to_complete_closed_plan(self): + model = FakeQueryModel( + QueryPlan( + steps=( + SearchMomentsPlanStep( + modality="scene", + query="taxi", + ), + ) + ) + ) + service = GroundedQueryService(model) + + plan, reason = service.plan( + self.command, + search_modalities=("scene", "dialogue"), + actor_overview=False, + ) + + self.assertEqual(reason, "query_plan_rejected") + self.assertEqual( + [step.modality for step in plan.steps], + ["scene", "dialogue"], + ) + + def test_provider_failure_uses_deterministic_retrieval_plan(self): + service = GroundedQueryService( + FakeQueryModel(QueryProviderError("offline")) + ) + + plan, reason = service.plan( + self.command, + search_modalities=("scene",), + actor_overview=False, + ) + + self.assertEqual(reason, "query_model_unavailable") + self.assertEqual(plan.steps[0].query, self.command.question) + + def test_scene_only_evidence_never_becomes_a_generated_claim(self): + service = GroundedQueryService( + FakeQueryModel( + QueryPlan( + steps=( + SearchMomentsPlanStep( + modality="scene", + query="taxi", + ), + ) + ), + DraftAnswer( + claims=( + DraftClaim( + text="A taxi is visible.", + evidence_ids=("b" * 64,), + ), + ) + ), + ) + ) + atomic = result() + fused_result = fused(atomic=atomic) + evidence = service.evidence( + snapshot=self.snapshot, + fused=fused_result, + actors=(), + ) + + answer = service.answer( + self.command, + plan=service.plan( + self.command, + search_modalities=("scene",), + actor_overview=False, + )[0], + planning_fallback=None, + evidence=evidence, + fused=fused_result, + ) + + self.assertEqual(answer.mode, QueryAnswerMode.evidence_only) + self.assertEqual(answer.fallback_reason, "no_textual_evidence") + self.assertFalse(answer.claims) + + def test_unknown_citation_is_rejected(self): + plan = QueryPlan( + steps=( + SearchMomentsPlanStep( + modality="dialogue", + query="taxi", + ), + ) + ) + service = GroundedQueryService( + FakeQueryModel( + plan, + DraftAnswer( + claims=( + DraftClaim( + text="Someone mentions a taxi.", + evidence_ids=("b" * 64,), + ), + ) + ), + ) + ) + atomic = result(text="the taxi arrived", modality="dialogue") + fused_result = fused("dialogue", atomic) + evidence = service.evidence( + snapshot=self.snapshot, + fused=fused_result, + actors=(), + ) + + answer = service.answer( + self.command, + plan=plan, + planning_fallback=None, + evidence=evidence, + fused=fused_result, + ) + + self.assertEqual(answer.mode, QueryAnswerMode.evidence_only) + self.assertEqual(answer.fallback_reason, "query_citations_rejected") + + def test_valid_textual_citation_is_reconstructed_by_the_application(self): + plan = QueryPlan( + steps=( + SearchMomentsPlanStep( + modality="dialogue", + query="taxi", + ), + ) + ) + atomic = result(text="the taxi arrived", modality="dialogue") + service = GroundedQueryService() + fused_result = fused("dialogue", atomic) + evidence = service.evidence( + snapshot=self.snapshot, + fused=fused_result, + actors=(), + ) + service.model = FakeQueryModel( + plan, + DraftAnswer( + claims=( + DraftClaim( + text="The taxi arrived.", + evidence_ids=(evidence[0].evidence_id,), + ), + ) + ), + ) + + answer = service.answer( + self.command, + plan=plan, + planning_fallback=None, + evidence=evidence, + fused=fused_result, + ) + + self.assertEqual(answer.mode, QueryAnswerMode.generated) + self.assertEqual( + answer.claims[0].evidence_ids, + (evidence[0].evidence_id,), + ) + + def test_generated_answer_preserves_rejected_plan_provenance(self): + atomic = result(text="the taxi arrived", modality="dialogue") + fused_result = fused("dialogue", atomic) + service = GroundedQueryService( + FakeQueryModel( + QueryPlan( + steps=( + SearchMomentsPlanStep( + modality="dialogue", + query="taxi", + ), + ) + ) + ) + ) + evidence = service.evidence( + snapshot=self.snapshot, + fused=fused_result, + actors=(), + ) + service.model.proposed_answer = DraftAnswer( + claims=( + DraftClaim( + text="The taxi arrived.", + evidence_ids=(evidence[0].evidence_id,), + ), + ) + ) + + answer = service.answer( + self.command, + plan=service.model.proposed_plan, + planning_fallback="query_plan_rejected", + evidence=evidence, + fused=fused_result, + ) + + self.assertEqual(answer.mode, QueryAnswerMode.generated) + self.assertEqual(answer.fallback_reason, "query_plan_rejected") + self.assertEqual(answer.model, service.model.identity) + + def test_configured_model_identity_is_retained_without_evidence(self): + plan = QueryPlan( + steps=( + SearchMomentsPlanStep( + modality="scene", + query="taxi", + ), + ) + ) + service = GroundedQueryService(FakeQueryModel(plan)) + + answer = service.answer( + self.command, + plan=plan, + planning_fallback=None, + evidence=(), + fused=fused(), + ) + + self.assertEqual(answer.mode, QueryAnswerMode.no_evidence) + self.assertEqual(answer.model, service.model.identity) + + def test_evidence_is_bounded_to_retained_fused_moments(self): + atomic = SearchResult( + query_id="dialogue:many", + query="taxi", + modality="dialogue", + hits=tuple( + SearchHit( + rank=index + 1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, + start=float(index * 2), + end=float(index * 2 + 1), + score=-float(index + 1), + raw_distance=float(index + 1), + modality="dialogue", + source_id=f"dialogue:{index}", + metadata={"text": f"line {index}"}, + ) + for index in range(201) + ), + ) + fused_result = fuse_search_results( + query="taxi", + requested_modalities=("dialogue",), + results=(atomic,), + top_k=201, + ) + + evidence = GroundedQueryService.evidence( + snapshot=self.snapshot, + fused=fused_result, + actors=(), + ) + + self.assertEqual(len(evidence), 200) + retained_sources = { + hit.source_id + for moment in fused_result.moments + for hit in moment.hits + } + self.assertTrue( + all(item.source_id in retained_sources for item in evidence) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_read_job_planner.py b/tests/test_read_job_planner.py index 265a2ea..bb12274 100644 --- a/tests/test_read_job_planner.py +++ b/tests/test_read_job_planner.py @@ -3,6 +3,7 @@ from vidxp.application_models import ( CreateActorOverlayCommand, + QueryVideoCommand, SearchCommand, ) from vidxp.core.contracts import IndexConfig @@ -35,7 +36,7 @@ def setUp(self): def test_search_job_carries_only_the_logical_snapshot_reference(self): request = self.planner.plan_search( - SearchCommand(modality="scene", query="a taxi"), + SearchCommand(modalities=("scene",), query="a taxi"), ) self.assertEqual(request.snapshot.snapshot_id, SNAPSHOT_ID) @@ -54,6 +55,19 @@ def test_actor_job_pins_snapshot_without_opening_vector_storage(self): self.assertEqual(request.snapshot.snapshot_id, SNAPSHOT_ID) self.index.open_store.assert_not_called() + def test_query_job_carries_the_same_logical_snapshot_reference(self): + request = self.planner.plan_query( + QueryVideoCommand( + question="What happens?", + modalities=("scene",), + ) + ) + + self.assertEqual(request.command.modalities, ("scene",)) + self.assertEqual(request.snapshot.snapshot_id, SNAPSHOT_ID) + self.assertEqual(request.snapshot.snapshot_sha256, "a" * 64) + self.index.open_store.assert_not_called() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_search_fusion.py b/tests/test_search_fusion.py new file mode 100644 index 0000000..6d83e53 --- /dev/null +++ b/tests/test_search_fusion.py @@ -0,0 +1,114 @@ +import unittest + +from vidxp.application_models import SearchHit, SearchResult +from vidxp.search_fusion import RRF_RANK_CONSTANT, fuse_search_results + + +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" + + +def hit( + modality: str, + rank: int, + start: float, + end: float, + source_id: str, +) -> SearchHit: + return SearchHit( + rank=rank, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, + start=start, + end=end, + score=-float(rank), + raw_distance=float(rank), + modality=modality, + source_id=source_id, + ) + + +class SearchFusionTests(unittest.TestCase): + def test_rrf_counts_only_the_best_rank_per_modality_in_a_moment(self): + scene = SearchResult( + query_id="scene:q", + query="taxi", + modality="scene", + hits=( + hit("scene", 1, 1, 3, "scene:1"), + hit("scene", 2, 2, 4, "scene:2"), + ), + ) + dialogue = SearchResult( + query_id="dialogue:q", + query="taxi", + modality="dialogue", + hits=(hit("dialogue", 1, 2.5, 3.5, "dialogue:1"),), + ) + + result = fuse_search_results( + query="taxi", + requested_modalities=("scene", "dialogue"), + results=(scene, dialogue), + ) + + self.assertEqual(len(result.moments), 1) + moment = result.moments[0] + self.assertAlmostEqual(moment.score, 2 / (RRF_RANK_CONSTANT + 1)) + self.assertEqual(len(moment.hits), 3) + self.assertEqual(moment.start, 1) + self.assertEqual(moment.end, 4) + + def test_result_order_does_not_change_fusion_identity_or_output(self): + scene = SearchResult( + query_id="scene:q", + query="taxi", + modality="scene", + hits=(hit("scene", 1, 1, 2, "scene:1"),), + ) + dialogue = SearchResult( + query_id="dialogue:q", + query="taxi", + modality="dialogue", + hits=(hit("dialogue", 1, 1, 2, "dialogue:1"),), + ) + arguments = { + "query": "taxi", + "requested_modalities": ("scene", "dialogue"), + } + + forward = fuse_search_results( + results=(scene, dialogue), + **arguments, + ) + reverse = fuse_search_results( + results=(dialogue, scene), + **arguments, + ) + + self.assertEqual(forward, reverse) + + def test_rewritten_atomic_query_identity_changes_fused_identity(self): + original = SearchResult( + query_id="scene:original", + query="taxi", + modality="scene", + hits=(hit("scene", 1, 1, 2, "scene:1"),), + ) + rewritten = original.model_copy( + update={"query_id": "scene:rewritten"} + ) + arguments = { + "query": "Where is the taxi?", + "requested_modalities": ("scene",), + } + + first = fuse_search_results(results=(original,), **arguments) + second = fuse_search_results(results=(rewritten,), **arguments) + + self.assertNotEqual(first.query_id, second.query_id) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 08b911d..57ecd7e 100644 --- a/uv.lock +++ b/uv.lock @@ -711,6 +711,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/d4/17f83581209153dff0bb7f6369c83d98d06303f8674746a7d47875add155/dbos-2.28.0-py3-none-any.whl", hash = "sha256:ba1bec5fcb2505b1bdd99251056cecf79669337699a81d5ef2b7598f701f31b6", size = 236036, upload-time = "2026-07-21T15:24:06.354Z" }, ] +[[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 = "durationpy" version = "0.10" @@ -883,6 +892,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, ] +[[package]] +name = "genai-prices" +version = "0.0.72" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx2" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/c8/2549fa8ceaaf0bd61114cf392250a107be657233723bc4e65cb91dba934f/genai_prices-0.0.72.tar.gz", hash = "sha256:a7e481d0ea85922fcf48df6864f2491fc81a4f03dcf0ecbd9aa8c2c6d9fcdbe8", size = 82753, upload-time = "2026-07-22T21:11:03.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/2e/1e2c31666f7e4e2d0327a80f1c920fd8764ccd27672c4bc389d79219ece6/genai_prices-0.0.72-py3-none-any.whl", hash = "sha256:21281d8df34d9bfbb736e0763a60ceda02226938cd47379ae437a44fcf8ba23d", size = 85238, upload-time = "2026-07-22T21:11:02.911Z" }, +] + [[package]] name = "gitdb" version = "4.0.12" @@ -976,6 +998,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" }, ] +[[package]] +name = "griffelib" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, +] + [[package]] name = "grpcio" version = "1.83.0" @@ -1228,6 +1259,92 @@ 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.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/3f/fae6cc967d120ec89e31c5418a51176d8278b3087fbb384a9176754f353c/jiter-0.16.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:67fddeda1688f0cce2d2ae83ccf8a80f79936f2d2997d6cc2261f82fdb54a4d3", size = 309289, upload-time = "2026-06-29T13:02:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/97c6c3562c077f6247d6e6ce5c82562500b6316c0d928e97e106b7a1321a/jiter-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c90c0f63df322be920eda6ce622e3083d8906ba267f8220fe7873213b8b4430e", size = 315181, upload-time = "2026-06-29T13:02:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/7b/89/d8d073f8aa2667e46c6c0873f86fe4a512bba4293cc730f626a076211a62/jiter-0.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64c0203212098470032aabcde9356fc168f377aade3e43def61dfe17e92f2037", size = 340939, upload-time = "2026-06-29T13:02:55.412Z" }, + { url = "https://files.pythonhosted.org/packages/87/c9/db4fda3ed73fb864139305e935e5b8b38a5a24692a5a9dd356c22f1b9c8d/jiter-0.16.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12288303c9844e61e1651d02a9a6f6633e47d39f897d6991d1427161ce6b746e", size = 364932, upload-time = "2026-06-29T13:02:57.28Z" }, + { url = "https://files.pythonhosted.org/packages/a2/74/52b5e86241057f52ddd7c9a580f90effb51f9d06239f6fc612279b91a838/jiter-0.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cf109d010b4b05a105afb3d43be36a21322d345ad3111e13d15f680afef0e5b", size = 461132, upload-time = "2026-06-29T13:02:58.994Z" }, + { url = "https://files.pythonhosted.org/packages/a9/87/544a700f7447c1f31c5d7833821a4daa5683165c2d5a094fbf5b5800c3dc/jiter-0.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62c1b7fe1f77925acf5af68b6140b8810fa87dfd4dc0a9c8568ec2fa2a10429c", size = 374857, upload-time = "2026-06-29T13:03:00.455Z" }, + { url = "https://files.pythonhosted.org/packages/40/cd/0fcc3f7d39183674d5bfa9ec640faaeb506c60be7c8f94625dfba366e37c/jiter-0.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8597d23c87f59294f83bcb6229b9ed1fccee13dbba967b46930d2f1759466fee", size = 347053, upload-time = "2026-06-29T13:03:02.045Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ae/c7e64e7932ad597fa395b61440b249ada6366716e25c6e08dd2afbd021e6/jiter-0.16.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3126a5dbad56401989ac769aca0cb56005bfb3e2366eea0ca99d1a91c3c1ee03", size = 356153, upload-time = "2026-06-29T13:03:03.706Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/1c719044f14da814e1a060191ab19b96f3e99207bc5b4bfc6d6be34b3f80/jiter-0.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c4b4717bdb35ae456f831a6b08d01880fff399887a6bbc526a583a406e484eea", size = 393956, upload-time = "2026-06-29T13:03:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/3b/dc/7b2f303a2847207e265503853a2d964a55354cffd62a5f2936c155486798/jiter-0.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:adff21bc78edfe086c15eb495b900306076de378dc2337c132401fc39bd79c91", size = 521081, upload-time = "2026-06-29T13:03:06.886Z" }, + { url = "https://files.pythonhosted.org/packages/c2/5f/501cf6e1e09caeb420195179ffc6f62aca603f1220ec53fd80d0d70b3e56/jiter-0.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dab907db06fc593645e73109acf4581ba5b548897d28b9348dc41ddc8343b2d3", size = 552085, upload-time = "2026-06-29T13:03:08.339Z" }, + { url = "https://files.pythonhosted.org/packages/79/54/aa5be86520113b79455c3877f3d1f07a348098df4083ba3688e9537e52dd/jiter-0.16.0-cp311-cp311-win32.whl", hash = "sha256:560b2cf3fb03240cd34f27409a238547488708f05b7c3924f571a60422251ec7", size = 206755, upload-time = "2026-06-29T13:03:09.653Z" }, + { url = "https://files.pythonhosted.org/packages/64/ec/2feb893eb330bd69b413866f4d5daada33c3962f1c6f270c91ca2d87fdf9/jiter-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:e431cfc9caf44c1d5459ff77d4e64cbf85fddb6a35dad836a15c6a9ec23087c1", size = 199155, upload-time = "2026-06-29T13:03:10.979Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9c/ca040d94415048a3666fc237774df8151c96f8d2b661cbe3b184acc95876/jiter-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:2a8e9e39cf083016137aa5cadafe3188adc2ba6ba1fbf1e5d18889ad3e9ad056", size = 194403, upload-time = "2026-06-29T13:03:12.341Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a", size = 306943, upload-time = "2026-06-29T13:03:14.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9", size = 307779, upload-time = "2026-06-29T13:03:15.418Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72", size = 335826, upload-time = "2026-06-29T13:03:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e", size = 362573, upload-time = "2026-06-29T13:03:18.781Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290", size = 457979, upload-time = "2026-06-29T13:03:20.293Z" }, + { url = "https://files.pythonhosted.org/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01", size = 372302, upload-time = "2026-06-29T13:03:21.739Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48", size = 343805, upload-time = "2026-06-29T13:03:23.384Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9", size = 351107, upload-time = "2026-06-29T13:03:24.815Z" }, + { url = "https://files.pythonhosted.org/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5", size = 388441, upload-time = "2026-06-29T13:03:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730", size = 516354, upload-time = "2026-06-29T13:03:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f", size = 547880, upload-time = "2026-06-29T13:03:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274", size = 203473, upload-time = "2026-06-29T13:03:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7", size = 196905, upload-time = "2026-06-29T13:03:32.472Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331", size = 190618, upload-time = "2026-06-29T13:03:34.672Z" }, + { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" }, + { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" }, + { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" }, + { url = "https://files.pythonhosted.org/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee", size = 308239, upload-time = "2026-06-29T13:03:57.205Z" }, + { url = "https://files.pythonhosted.org/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2", size = 308928, upload-time = "2026-06-29T13:03:58.643Z" }, + { url = "https://files.pythonhosted.org/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883", size = 336998, upload-time = "2026-06-29T13:04:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898", size = 362112, upload-time = "2026-06-29T13:04:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5", size = 459807, upload-time = "2026-06-29T13:04:03.214Z" }, + { url = "https://files.pythonhosted.org/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567", size = 373181, upload-time = "2026-06-29T13:04:04.629Z" }, + { url = "https://files.pythonhosted.org/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69", size = 344927, upload-time = "2026-06-29T13:04:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29", size = 352754, upload-time = "2026-06-29T13:04:07.477Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93", size = 390553, upload-time = "2026-06-29T13:04:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a", size = 516900, upload-time = "2026-06-29T13:04:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00", size = 548754, upload-time = "2026-06-29T13:04:12.046Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe", size = 122381, upload-time = "2026-06-29T13:04:13.413Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106", size = 204578, upload-time = "2026-06-29T13:04:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8", size = 198154, upload-time = "2026-06-29T13:04:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585", size = 191458, upload-time = "2026-06-29T13:04:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207", size = 316739, upload-time = "2026-06-29T13:04:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818", size = 340911, upload-time = "2026-06-29T13:04:21.257Z" }, + { url = "https://files.pythonhosted.org/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3", size = 361747, upload-time = "2026-06-29T13:04:22.677Z" }, + { url = "https://files.pythonhosted.org/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284", size = 460225, upload-time = "2026-06-29T13:04:24.441Z" }, + { url = "https://files.pythonhosted.org/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929", size = 373169, upload-time = "2026-06-29T13:04:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620", size = 350332, upload-time = "2026-06-29T13:04:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af", size = 353377, upload-time = "2026-06-29T13:04:28.731Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e", size = 387746, upload-time = "2026-06-29T13:04:30.319Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077", size = 517292, upload-time = "2026-06-29T13:04:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734", size = 549259, upload-time = "2026-06-29T13:04:33.721Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf", size = 206523, upload-time = "2026-06-29T13:04:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db", size = 200366, upload-time = "2026-06-29T13:04:36.61Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" }, + { url = "https://files.pythonhosted.org/packages/06/d3/8e278946d43eeca2585b4dd0834a887cd71136329b837f3a16ed86a8b4b0/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:850ccb1d7eedb4200f4014b1c0e8a577de114fc3cd88faad646dcc9bc4bb12ad", size = 304518, upload-time = "2026-06-29T13:05:00.172Z" }, + { url = "https://files.pythonhosted.org/packages/72/43/28d4ef495028bf0506a413d4db3f4eb3e7288a382e0f065f306a17bbeb5e/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:e34e97bda77eb63242a410243c071e28ac7e0d8c0948c5ee658498690a4b2f2f", size = 310207, upload-time = "2026-06-29T13:05:02.123Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ca/c366b1012da1d640de975d9683acd44e4d150d9068845d0ca2610435253f/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7dc85ea77d4abbae8bad0d3538678aedee75bceec4e2f6c8dfb1c74772e5aa5", size = 342771, upload-time = "2026-06-29T13:05:03.55Z" }, + { url = "https://files.pythonhosted.org/packages/16/52/50cc4056fc1ae02e7154704e7ecc89df0afb8300222cfe8a52d3f67e4730/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17ca7fae79f6d99cd9a042b75f917eaada7b895cfc7dd2ee3a16089dcaec7a85", size = 346468, upload-time = "2026-06-29T13:05:05.452Z" }, + { url = "https://files.pythonhosted.org/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127", size = 303106, upload-time = "2026-06-29T13:05:07.118Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3", size = 304658, upload-time = "2026-06-29T13:05:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702", size = 339719, upload-time = "2026-06-29T13:05:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, +] + [[package]] name = "joblib" version = "1.5.3" @@ -1285,6 +1402,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/30/a96d47df739689ac0001ade0afefc16e3b477fc2fb426b568515fdc8afce/kubernetes-36.0.3-py2.py3-none-any.whl", hash = "sha256:8fde9241c4b298e6374a069dcf728359b4e72c2fb29489a975ba4e1c047cf10f", size = 4618066, upload-time = "2026-07-13T20:38:10.172Z" }, ] +[[package]] +name = "logfire-api" +version = "4.39.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/f4/41f8647f6091fb9b9aac5a4b6d164bddb11d55b8369bf38de154ef91b8f4/logfire_api-4.39.0.tar.gz", hash = "sha256:1e885f95c37d58cdb927bbc6baea4f4a7c13066f6b3019758627d4dc442643d0", size = 90619, upload-time = "2026-07-24T18:31:36.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/d4/87747d12eaf2d852676fd6535df76df945ef62681f1eb5391b63d1fc05e2/logfire_api-4.39.0-py3-none-any.whl", hash = "sha256:20057bbd2898dec2eed02e2559bd73f4e10bc4b108987821df55e9c762da3ba8", size = 140413, upload-time = "2026-07-24T18:31:32.818Z" }, +] + [[package]] name = "mako" version = "1.3.12" @@ -1861,6 +1987,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/a9/68707e1ce345cbdbcd4df65932ebc82a673e917d63eda0007ebcff948691/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f6e92367ddce1e4d33cf295024f40192be6c6171a09208f515ba169ced06c8e", size = 19222976, upload-time = "2026-07-25T01:22:12.474Z" }, ] +[[package]] +name = "openai" +version = "2.50.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/d8/f5/e7735f2af272ee179a287911a698b3cbdb59d7a4ac4874571363adf1e4de/openai-2.50.0.tar.gz", hash = "sha256:5128f7caf4a6b01aefd6e7e93efe170a2c3427b8de286b9af5cdff3aa47e02c8", size = 1081965, upload-time = "2026-07-28T22:16:31.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/ca/db315b3bb748c26c644a3f85b7d509e774354d6518d47080b1446005ee41/openai-2.50.0-py3-none-any.whl", hash = "sha256:90bdddcc5a2fa529b350fac9c5780d87e5c361dcc6090ab57b0d470b0d7af7fa", size = 1650721, upload-time = "2026-07-28T22:16:29.48Z" }, +] + [[package]] name = "opencv-python-headless" version = "5.0.0.93" @@ -2644,6 +2789,30 @@ 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-ai-slim" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "genai-prices" }, + { name = "griffelib" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pydantic-graph" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/53/7bfa293ed6d2eb3c400c14d442867bc22cba3cf5f1551b829f54f18ff693/pydantic_ai_slim-2.20.0.tar.gz", hash = "sha256:d70eea3c82a5976d992542ebe2bdbd1af63cd41d9d896b0d59f061fadf42cd2b", size = 912253, upload-time = "2026-07-29T03:37:50.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/3e/d5377980b29e6b18ee334f31cdbdb55dbfe437358251406489e683bde631/pydantic_ai_slim-2.20.0-py3-none-any.whl", hash = "sha256:c61f1e9a19cb85028638e4909f64b8331ed754e918d8bf892434fd92b1f93f59", size = 1101608, upload-time = "2026-07-29T03:37:41.463Z" }, +] + +[package.optional-dependencies] +openai = [ + { name = "openai" }, + { name = "tiktoken" }, +] + [[package]] name = "pydantic-core" version = "2.46.4" @@ -2746,6 +2915,21 @@ wheels = [ { 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-graph" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "logfire-api" }, + { name = "pydantic" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/b2/63e33bec7f5d95a40af8503c83d66cda151315a16d334975209875766d4d/pydantic_graph-2.20.0.tar.gz", hash = "sha256:d009dc97689ede3d20e2d99384f93b867ccd5b408dcc394551b8dd26cfcde80f", size = 44150, upload-time = "2026-07-29T03:37:52.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/24/1dd8d79a01ec0278ce9dc0933fe3a5c5bb8cde34e717294b9b028c539f6e/pydantic_graph-2.20.0-py3-none-any.whl", hash = "sha256:ceee415418a8cc6f8ef6968c8343e6be00c1971157dc552fc1fbb24790b5250a", size = 51658, upload-time = "2026-07-29T03:37:45.131Z" }, +] + [[package]] name = "pydantic-settings" version = "2.14.2" @@ -3451,6 +3635,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, ] +[[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 = "sqlalchemy" version = "2.0.51" @@ -3601,6 +3794,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, ] +[[package]] +name = "tiktoken" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1", size = 38986, upload-time = "2026-05-15T04:51:27.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/4c/1bc81f4cd53e827c4ee67ca951b5935724716049452d8dfa09b8b82372bb/tiktoken-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7bfe1849caa65d1e1d9871817170ec497bbb7984e182012e1bdce72f66608cdb", size = 1036353, upload-time = "2026-05-15T04:50:21.757Z" }, + { url = "https://files.pythonhosted.org/packages/75/91/10b9c7076bc02c246c853201fdbbe300a4b8c5ed7b84c25f7403f4e32655/tiktoken-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:91c180fe255bd5a86d8316210d2833a1d4d33d026cd86a67812f4773743c8d26", size = 984644, upload-time = "2026-05-15T04:50:23.256Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e4/fceae98015fab47fcd49b8bd7f46145bcd187a47e0add1e5378ed67ef980/tiktoken-0.13.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:059c8ecf554eb5b41e6e054ba467b871b03277d267dee7244380aca4359747d4", size = 1119261, upload-time = "2026-05-15T04:50:24.348Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/fe42ad00de01a8c4a49ad8649a2c8a316835a9cad5961b11d21eac0020a5/tiktoken-0.13.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:36217497eaffc158607a3b26f065300db2aefd43b115263f3b9688ce38146173", size = 1138253, upload-time = "2026-05-15T04:50:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/ccee1ecccca107e9a16efcecdeeb964c325305038554d466ece65b42338f/tiktoken-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:303f7d91b4fce3baddbcde05c139091d4caa5026ac7214c1dc7ff7a71ee429ff", size = 1185747, upload-time = "2026-05-15T04:50:27.02Z" }, + { url = "https://files.pythonhosted.org/packages/9d/03/cd0cba295522b91eb55c6b2704f1df895f8226cfe60ab10d4d51d0cc9e69/tiktoken-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5d48843bee149630eb735a99e1f4a85b47308d21868ea63163f6e87768d3cfed", size = 1241265, upload-time = "2026-05-15T04:50:28.815Z" }, + { url = "https://files.pythonhosted.org/packages/7e/25/a10efd564402d82c2ff50d12057353ace447aa8007deceaa48641f63d35c/tiktoken-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:fc1c44cd37b43fc46bae593129164f4f281e82ea116b57a85aa81bda57eafc94", size = 876509, upload-time = "2026-05-15T04:50:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/85/8e/144bde4e01df66b34bb865557c7cd754ed08b036217ebd79c9db5e9048a9/tiktoken-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32ac870a806cfb260a02d0cb70426aef02e038297f8ad50df5040bb5af360791", size = 1034888, upload-time = "2026-05-15T04:50:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/36/18/d4ac9d20956cdebca04841316660ed584c2fecdc2b81722a28bc7ad3b1e4/tiktoken-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d9980f11429ed2d737c463bb1fb78cf330caa026adf002f714aced7849a687b", size = 982970, upload-time = "2026-05-15T04:50:32.961Z" }, + { url = "https://files.pythonhosted.org/packages/74/ed/6bb8d05b9f731f749fee5c6f5ca63e981143c826a5985877330507bd13b7/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3f277ebea5edd7b8bf03c6f9431e1d67d517530115572b2dc1d465326e8f88c7", size = 1115741, upload-time = "2026-05-15T04:50:34.475Z" }, + { url = "https://files.pythonhosted.org/packages/34/de/2ca96b07a82d972b74fe4b46de055b79c904e45c7eab699354a0bfa697dc/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a116178fa7e1b4065bff05214360373a65cac22f965be7b3f73d00a0dbfe7649", size = 1136523, upload-time = "2026-05-15T04:50:35.782Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/9dafec002c2d4424378563cf4cf5c7fb93631d2a55013c8b87554ee4012c/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c397ddda233208345b01bd30f2fca79ff730e55731d0108a603f9bc57f6af3b", size = 1181954, upload-time = "2026-05-15T04:50:36.99Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d0/1f8578c45b2f24759b46f0b50d31878c63c73e6bf0f2227e10ec5c5408dc/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95097e4f89b06403976e498abf61a0ee73a7497e73fb599cb211d8197a054d91", size = 1240069, upload-time = "2026-05-15T04:50:38.221Z" }, + { url = "https://files.pythonhosted.org/packages/aa/90/28d7f154888610aa9237e541986beb62b479df29d193a5a0617dbb1514d0/tiktoken-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f2d16e7a7c783ad81f36e457d046d1f1c8af70b22aec8a13238efe531977c41", size = 874748, upload-time = "2026-05-15T04:50:39.587Z" }, + { url = "https://files.pythonhosted.org/packages/9c/83/b096c859c2a47c11731bf2f5885f4028b809dfe2396582883eed9cae372f/tiktoken-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5df5d1507bd245f1ccad4a074698240021239e455eb0bb4ced4e3d7181872154", size = 1034228, upload-time = "2026-05-15T04:50:40.988Z" }, + { url = "https://files.pythonhosted.org/packages/53/61/c68e123b6d753e3fc2751e9b18e732c9d8bf1e1926762e736eee935d931c/tiktoken-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fe806a50664e83a6ffd56cbd1e4f5dcc6cd32a3e7538f70dc38b1a271384545", size = 982978, upload-time = "2026-05-15T04:50:42.195Z" }, + { url = "https://files.pythonhosted.org/packages/ef/8b/96cc178cc584e65d363134500f297790b06cd48cdeb1e8fcf7bbe60f4715/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:125bc05005e747f993a83dc67934249932d6e4209854452cd4c0b1d53fba3ba2", size = 1116355, upload-time = "2026-05-15T04:50:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/bab735d2c72ea55404b295d02d092644eb5f7cc6205e34d35eb9abfb9ab2/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5e6358911cab4adee6712da27d65573496a4f68cf8a2b5fca6a4ad10fc5748cf", size = 1135772, upload-time = "2026-05-15T04:50:44.782Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b9/6de04ebdf904edfaad87788011b3735087a0c9ea671b9027e1e4e965e8c8/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:975cbd78d085d75d26b59660e262736dcaed1e35f8f142cd6291025c01d25486", size = 1182415, upload-time = "2026-05-15T04:50:46.422Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9c/470a05f3b1caf038f44880e334d47ab674e0c80d514c66b375d14d5afa10/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ab9bc99fa020a4c283424590ecd7f3afd70c1c281cb3fa3192a6c3af9f9615", size = 1239879, upload-time = "2026-05-15T04:50:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/42/a6/c1936d16055436cb32e6c6128d68629622e00f4768562f55653752d34768/tiktoken-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:6b1615f0ff71953d19729ceb18865429c185b0a23c5353f1bbca34a394bf60f7", size = 874829, upload-time = "2026-05-15T04:50:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/d6/07/acb5992c3772b5a36284f742cfb7a5895aa4471d1848ac31464ad50d7fdf/tiktoken-0.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6eb4a5bfbc6426938026b1a334e898ac53541360d62d8c689870160cc80abd67", size = 1033600, upload-time = "2026-05-15T04:50:50.4Z" }, + { url = "https://files.pythonhosted.org/packages/14/e9/742e9aec30f59b9f161f7ff7cd072e02ea836c9e1c0854a8076dfcd40d5c/tiktoken-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:43cee3e5400573b2046fbf092cc7a5bc30164f9e4c95ce20714da929df48737a", size = 982516, upload-time = "2026-05-15T04:50:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/72/74/ca1541b053e7648254d2e4b42a253e1bb4359f2c91a0a8d49228c794e1a0/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7de52e3f566d19b3b11bd37eea552c6c305ad74081f736882bd44d148ed4c48d", size = 1115518, upload-time = "2026-05-15T04:50:53.543Z" }, + { url = "https://files.pythonhosted.org/packages/46/e3/93825eaf5a4a504795b787e5d5dea07fbeb3dabf97aa7b450be8bde59c89/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:51384448aa508e4df84c0f7c1dc3211c7f7b8096325660ee5fc82f3e11b381ce", size = 1136867, upload-time = "2026-05-15T04:50:55.191Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/002b68de6827091d5ae90b048f326e8aad8d953520950e5ce1508879414f/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e28157350f7ebf35008dd8e9e0fdb621f976e4230c881099c85e8cf07eaa50e2", size = 1181826, upload-time = "2026-05-15T04:50:56.296Z" }, + { url = "https://files.pythonhosted.org/packages/db/c6/d393e3185a276505182f7abd93fe714f3c444a2be9180798fa052347504e/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:165cf1820ea4a354985c2490a5205d4cc74661c934aca79dd0368232fff94e0f", size = 1239489, upload-time = "2026-05-15T04:50:57.918Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4d/bc07d1f1635d4897a202acc0ae11c2886eaa7325c359ba4741b47bf8e225/tiktoken-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6c43a675ca14f6f2749ba7f12075d37456015a24b859f2517b9beb4ef30807ec", size = 873820, upload-time = "2026-05-15T04:50:59.528Z" }, + { url = "https://files.pythonhosted.org/packages/8c/93/0dd6adca026a616c3a92974566b43381eea4b475ce1f36c062b8271a9ac5/tiktoken-0.13.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaaaef47c2406277181d2086484c317bf7fc433e2d5d03ff94f56b0dcec87471", size = 1034977, upload-time = "2026-05-15T04:51:00.957Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5ec6e6bc5b30bed6d93f7f2162d8f6b32437b3ba27cb527cfe004f6109c9/tiktoken-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ca8b310bd93b3772cb1b7922d915446864860f562bdfe4825c63a0aed3fb28cd", size = 983635, upload-time = "2026-05-15T04:51:02.629Z" }, + { url = "https://files.pythonhosted.org/packages/94/b0/c8ae9aff00d625c50659b4513e707a0462c4bf5d4d6cc1b802103225c02e/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32e0c12305105002c047b3bb1070b0dd9a73b0cb3b2856a8972b810e7a4f5881", size = 1116036, upload-time = "2026-05-15T04:51:04.082Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/6a5dddd1d0a6018ecb389bd0353e6b4a515eb4d2286611bd0ace1937b9e1/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5ba5fd62507a932d1241346179e3b39bc7bf7408f03c272652d93b3bedf5db24", size = 1135544, upload-time = "2026-05-15T04:51:05.229Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b8/585032b4384b2f7dcdaddcb52865c83a701a420d09e3c2b4a2be1c450c57/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d108bc2d470fc53c8ecd24f2c0fd2b5f98c33e87cdb6aa2e9b8c5dced703d273", size = 1182217, upload-time = "2026-05-15T04:51:06.517Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b6/993ff1ded3958215fd341a847b8e5ffeb5de473f435296870d314fc91ac4/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb99cb5127449f58d0a2d5f5ccfb390d8dbdfd919c221246caaee29d8725ed51", size = 1239404, upload-time = "2026-05-15T04:51:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3d/fef7e06e3b33e7538db0ced734cf9fe23b6832d2ac4990c119c377aec55e/tiktoken-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:115c4f26ffa11caac8b54eea35c2ad38c612c20a48d35dd15d70a02ac6f51f58", size = 918686, upload-time = "2026-05-15T04:51:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/c1/82/a7fc44582bc32ab00de988a2299bf77c077f59068b233109e34b7d6ca7e6/tiktoken-0.13.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:472527e9132952f2fbf77cd290658bacf003d4d5a3fabc18e5fbd407cbae4d9b", size = 1034454, upload-time = "2026-05-15T04:51:10.035Z" }, + { url = "https://files.pythonhosted.org/packages/37/d0/24d8a890c14f432a05cea669c17bebeaa99f96a7c79523b590f564246411/tiktoken-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e2f67d27c9626cdd25fe33d9313c5cdb3d8d82da646b68d6eb8e7e9c20e6448", size = 982976, upload-time = "2026-05-15T04:51:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/49/b7/2ab43f62788a9266187a9bfc1d3af99ad83e5eaa25fbef168a69cd5ad14f/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2b920b35805cd64585a37c3dc7ce65fba4d2d36016be01e1d7942482ca29093a", size = 1115526, upload-time = "2026-05-15T04:51:12.608Z" }, + { url = "https://files.pythonhosted.org/packages/64/39/1494321ed323ce7a14d88e3cd6cb9058625977df1c6961ddc492bd10a9f3/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:493af3aa28a4aaf2e3d2600a2ee717252c9bf5ab38fff94eb5a02db5ab77e5ad", size = 1136466, upload-time = "2026-05-15T04:51:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/96/d9/dfd086aa2d918c563a140720e0ce296cada1634efd2783d5cf51e05f984e/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6644c9c2b5cf3916f5a3641d7d12fdb3f006a7b3d9ff6acdaec44e29ab1ff91e", size = 1181863, upload-time = "2026-05-15T04:51:15.025Z" }, + { url = "https://files.pythonhosted.org/packages/2f/68/a18b4f307086954fdae32714cb4f85562e34f9d34ab206e61f1816aa6018/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cb65b60b9408563676d874a3a4ee573370066f0dc4e29d84e82e989c6517424", size = 1239218, upload-time = "2026-05-15T04:51:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07", size = 918110, upload-time = "2026-05-15T04:51:17.237Z" }, +] + [[package]] name = "tokenizers" version = "0.22.2" @@ -3940,6 +4187,7 @@ local-worker = [ { name = "pillow" }, { name = "pooch" }, { name = "psutil" }, + { name = "pydantic-ai-slim", extra = ["openai"] }, { name = "sentence-transformers" }, { name = "torch", version = "2.13.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3985,6 +4233,7 @@ server-worker = [ { name = "pooch" }, { name = "psutil" }, { name = "psycopg", extra = ["binary"] }, + { name = "pydantic-ai-slim", extra = ["openai"] }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "sentence-transformers" }, @@ -3993,6 +4242,9 @@ server-worker = [ { name = "transformers" }, { name = "uvicorn", extra = ["standard"] }, ] +slm = [ + { name = "pydantic-ai-slim", extra = ["openai"] }, +] storage = [ { name = "chromadb" }, { name = "psutil" }, @@ -4062,6 +4314,9 @@ requires-dist = [ { name = "psycopg", extras = ["binary"], marker = "extra == 'server'", specifier = ">=3.3.4,<4" }, { name = "psycopg", extras = ["binary"], marker = "extra == 'server-worker'", specifier = ">=3.3.4,<4" }, { name = "pydantic", specifier = ">=2.13.4,<3" }, + { name = "pydantic-ai-slim", extras = ["openai"], marker = "extra == 'local-worker'", specifier = ">=2.13,<3" }, + { name = "pydantic-ai-slim", extras = ["openai"], marker = "extra == 'server-worker'", specifier = ">=2.13,<3" }, + { name = "pydantic-ai-slim", extras = ["openai"], marker = "extra == 'slm'", specifier = ">=2.13,<3" }, { name = "pydantic-settings", specifier = ">=2.14.2,<3" }, { name = "pyjwt", extras = ["crypto"], marker = "extra == 'server'", specifier = ">=2.13,<3" }, { name = "pyjwt", extras = ["crypto"], marker = "extra == 'server-worker'", specifier = ">=2.13,<3" }, @@ -4092,7 +4347,7 @@ requires-dist = [ { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.51,<0.52" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'server-worker'", specifier = ">=0.51,<0.52" }, ] -provides-extras = ["storage", "dialogue", "scene", "actor", "all", "local-worker", "mcp", "server", "server-worker", "test", "frontend", "benchmarks"] +provides-extras = ["storage", "dialogue", "scene", "actor", "all", "local-worker", "mcp", "slm", "server", "server-worker", "test", "frontend", "benchmarks"] [[package]] name = "watchdog" From baa64e2be0e94345eeb885025ec77bb99dff901e Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 13:13:28 +0500 Subject: [PATCH 13/46] feat: add desktop application bootstrap --- .gitignore | 9 + README.md | 3 +- changes/desktop.feature.md | 7 + desktop/package-lock.json | 232 + desktop/package.json | 16 + desktop/runtime-manifest.json | 37 + desktop/scripts/fetch-uv.ps1 | 59 + desktop/scripts/fetch-uv.sh | 36 + desktop/sidecars.json | 17 + desktop/src-tauri/Cargo.lock | 5252 ++++++++++++++++++++++ desktop/src-tauri/Cargo.toml | 27 + desktop/src-tauri/build.rs | 33 + desktop/src-tauri/capabilities/main.json | 9 + desktop/src-tauri/icons/128x128.png | Bin 0 -> 21616 bytes desktop/src-tauri/icons/128x128@2x.png | Bin 0 -> 79588 bytes desktop/src-tauri/icons/32x32.png | Bin 0 -> 1995 bytes desktop/src-tauri/icons/icon.icns | Bin 0 -> 1373347 bytes desktop/src-tauri/icons/icon.ico | Bin 0 -> 99450 bytes desktop/src-tauri/icons/icon.png | Bin 0 -> 244327 bytes desktop/src-tauri/src/lib.rs | 810 ++++ desktop/src-tauri/src/main.rs | 3 + desktop/src-tauri/tauri.conf.json | 48 + desktop/web/app.js | 110 + desktop/web/index.html | 53 + desktop/web/styles.css | 181 + docs/architecture/platform.md | 15 +- docs/desktop.md | 71 + src/vidxp/infrastructure/local_worker.py | 24 +- src/vidxp/workflow_runtime.py | 9 + src/vidxp/workflow_worker.py | 136 +- tests/test_local_worker.py | 50 +- tests/test_packaging.py | 61 + 32 files changed, 7261 insertions(+), 47 deletions(-) create mode 100644 changes/desktop.feature.md create mode 100644 desktop/package-lock.json create mode 100644 desktop/package.json create mode 100644 desktop/runtime-manifest.json create mode 100644 desktop/scripts/fetch-uv.ps1 create mode 100644 desktop/scripts/fetch-uv.sh create mode 100644 desktop/sidecars.json create mode 100644 desktop/src-tauri/Cargo.lock create mode 100644 desktop/src-tauri/Cargo.toml create mode 100644 desktop/src-tauri/build.rs create mode 100644 desktop/src-tauri/capabilities/main.json create mode 100644 desktop/src-tauri/icons/128x128.png create mode 100644 desktop/src-tauri/icons/128x128@2x.png create mode 100644 desktop/src-tauri/icons/32x32.png create mode 100644 desktop/src-tauri/icons/icon.icns create mode 100644 desktop/src-tauri/icons/icon.ico create mode 100644 desktop/src-tauri/icons/icon.png create mode 100644 desktop/src-tauri/src/lib.rs create mode 100644 desktop/src-tauri/src/main.rs create mode 100644 desktop/src-tauri/tauri.conf.json create mode 100644 desktop/web/app.js create mode 100644 desktop/web/index.html create mode 100644 desktop/web/styles.css create mode 100644 docs/desktop.md diff --git a/.gitignore b/.gitignore index ffcf0a1..9f4d419 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,15 @@ pnpm-debug.log* # compiled output /target +/desktop/node_modules/ +/desktop/src-tauri/target/ +/desktop/src-tauri/gen/ +/desktop/src-tauri/binaries/uv-* +/desktop/src-tauri/icons/android/ +/desktop/src-tauri/icons/ios/ +/desktop/src-tauri/icons/64x64.png +/desktop/src-tauri/icons/Square*.png +/desktop/src-tauri/icons/StoreLogo.png # dependencies /node_modules diff --git a/README.md b/README.md index 894bb71..35db4eb 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@

  • From the command line
  • Through its browser interface
  • As an indexing and retrieval layer inside another application
  • -
  • Through the HTTP API or MCP, with a desktop interface on the roadmap
  • +
  • Through the HTTP API or MCP
  • +
  • Through the desktop bootstrap currently in release validation
  • diff --git a/changes/desktop.feature.md b/changes/desktop.feature.md new file mode 100644 index 0000000..d4d8219 --- /dev/null +++ b/changes/desktop.feature.md @@ -0,0 +1,7 @@ +### Desktop installer + +- Added a minimal Tauri desktop installer and supervisor that installs exact + capability extras from PyPI into an app-owned uv runtime. +- Added atomic runtime activation, optional model preparation, platform + app-data paths, loopback-only UI launch, and graceful local-worker shutdown. +- Kept FFmpeg packaging as an explicit provenance and licensing gate. diff --git a/desktop/package-lock.json b/desktop/package-lock.json new file mode 100644 index 0000000..edbaf8d --- /dev/null +++ b/desktop/package-lock.json @@ -0,0 +1,232 @@ +{ + "name": "vidxp-desktop", + "version": "0.2.1-beta.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vidxp-desktop", + "version": "0.2.1-beta.1", + "devDependencies": { + "@tauri-apps/cli": "2.11.4" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", + "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.4", + "@tauri-apps/cli-darwin-x64": "2.11.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", + "@tauri-apps/cli-linux-arm64-musl": "2.11.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-musl": "2.11.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", + "@tauri-apps/cli-win32-x64-msvc": "2.11.4" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", + "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", + "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", + "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", + "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", + "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", + "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", + "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", + "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", + "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", + "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", + "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 0000000..9e8fac2 --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,16 @@ +{ + "name": "vidxp-desktop", + "private": true, + "version": "0.2.1-beta.1", + "type": "module", + "scripts": { + "tauri": "tauri", + "desktop:dev": "tauri dev", + "desktop:build": "tauri build", + "sidecar:windows": "powershell -ExecutionPolicy Bypass -File scripts/fetch-uv.ps1", + "sidecar:unix": "bash scripts/fetch-uv.sh" + }, + "devDependencies": { + "@tauri-apps/cli": "2.11.4" + } +} diff --git a/desktop/runtime-manifest.json b/desktop/runtime-manifest.json new file mode 100644 index 0000000..611fd46 --- /dev/null +++ b/desktop/runtime-manifest.json @@ -0,0 +1,37 @@ +{ + "schema_version": 1, + "desktop_version": "0.2.1-beta.1", + "package_name": "vidxp", + "package_version": "0.2.1b1", + "package_index": "https://pypi.org/simple", + "python_version": "3.14.6", + "uv_version": "0.12.0", + "always_install_extras": [ + "frontend" + ], + "capabilities": { + "dialogue": { + "extra": "dialogue", + "modality": "dialogue", + "label": "Dialogue search" + }, + "scene": { + "extra": "scene", + "modality": "scene", + "label": "Visual scene search" + }, + "actor": { + "extra": "actor", + "modality": "actor", + "label": "Actor recognition" + } + }, + "media_runtime": { + "strategy": "system", + "executables": [ + "ffmpeg", + "ffprobe" + ], + "reason": "Bundled FFmpeg builds remain disabled until their target-specific codec licenses and provenance are recorded." + } +} diff --git a/desktop/scripts/fetch-uv.ps1 b/desktop/scripts/fetch-uv.ps1 new file mode 100644 index 0000000..1549a05 --- /dev/null +++ b/desktop/scripts/fetch-uv.ps1 @@ -0,0 +1,59 @@ +$ErrorActionPreference = "Stop" + +$scriptRoot = Split-Path -Parent $PSScriptRoot +$lock = Get-Content -Raw (Join-Path $scriptRoot "sidecars.json") | + ConvertFrom-Json +$version = $lock.uv_version +$architecture = $env:PROCESSOR_ARCHITECTURE +if ($architecture -ne "AMD64") { + throw "The first Windows desktop target supports x86-64 only (found $architecture)." +} + +$target = "x86_64-pc-windows-msvc" +$targetLock = $lock.targets.$target +$archiveName = $targetLock.archive +$lockedChecksum = $targetLock.sha256.ToLowerInvariant() +$releaseRoot = "https://github.com/astral-sh/uv/releases/download/$version" +$binaryDirectory = Join-Path $scriptRoot "src-tauri\binaries" +$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("vidxp-uv-" + [guid]::NewGuid()) +$resolvedTempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) +$resolvedTemporaryRoot = [System.IO.Path]::GetFullPath($temporaryRoot) +if (-not $resolvedTemporaryRoot.StartsWith($resolvedTempBase, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to use a temporary directory outside the system temporary root." +} + +New-Item -ItemType Directory -Path $temporaryRoot | Out-Null +New-Item -ItemType Directory -Force -Path $binaryDirectory | Out-Null +try { + $archive = Join-Path $temporaryRoot $archiveName + Invoke-WebRequest -Uri "$releaseRoot/$archiveName" -OutFile $archive + $stream = [System.IO.File]::OpenRead($archive) + try { + $hasher = [System.Security.Cryptography.SHA256]::Create() + $actual = [System.BitConverter]::ToString( + $hasher.ComputeHash($stream) + ).Replace("-", "").ToLowerInvariant() + } + finally { + $stream.Dispose() + } + if ($actual -ne $lockedChecksum) { + throw "The uv archive checksum did not match desktop/sidecars.json." + } + + $expanded = Join-Path $temporaryRoot "expanded" + Expand-Archive -LiteralPath $archive -DestinationPath $expanded + $source = Get-ChildItem -LiteralPath $expanded -Recurse -File -Filter "uv.exe" | + Select-Object -First 1 + if ($null -eq $source) { + throw "The uv release archive did not contain uv.exe." + } + Copy-Item -LiteralPath $source.FullName -Destination ( + Join-Path $binaryDirectory "uv-$target.exe" + ) +} +finally { + if (Test-Path -LiteralPath $resolvedTemporaryRoot) { + Remove-Item -LiteralPath $resolvedTemporaryRoot -Recurse -Force + } +} diff --git a/desktop/scripts/fetch-uv.sh b/desktop/scripts/fetch-uv.sh new file mode 100644 index 0000000..e751452 --- /dev/null +++ b/desktop/scripts/fetch-uv.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +case "$(uname -s):$(uname -m)" in + Darwin:arm64) target="aarch64-apple-darwin" ;; + Linux:x86_64) target="x86_64-unknown-linux-gnu" ;; + *) + echo "The first Unix desktop targets are macOS arm64 and Linux x86-64." >&2 + exit 1 + ;; +esac + +script_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +version="$(node -p "require('${script_root}/sidecars.json').uv_version")" +archive_name="$(node -p "require('${script_root}/sidecars.json').targets['${target}'].archive")" +locked_checksum="$(node -p "require('${script_root}/sidecars.json').targets['${target}'].sha256")" +release_root="https://github.com/astral-sh/uv/releases/download/${version}" +binary_directory="${script_root}/src-tauri/binaries" +temporary_root="$(mktemp -d "${TMPDIR:-/tmp}/vidxp-uv.XXXXXXXX")" +trap 'rm -rf -- "$temporary_root"' EXIT + +mkdir -p "$binary_directory" +curl --fail --location --silent --show-error \ + "${release_root}/${archive_name}" \ + --output "${temporary_root}/${archive_name}" +if command -v sha256sum >/dev/null 2>&1; then + actual_checksum="$(sha256sum "${temporary_root}/${archive_name}" | awk '{print $1}')" +else + actual_checksum="$(shasum -a 256 "${temporary_root}/${archive_name}" | awk '{print $1}')" +fi +test "$actual_checksum" = "$locked_checksum" + +tar -xzf "${temporary_root}/${archive_name}" -C "$temporary_root" +uv_path="$(find "$temporary_root" -type f -name uv -print -quit)" +test -n "$uv_path" +install -m 0755 "$uv_path" "${binary_directory}/uv-${target}" diff --git a/desktop/sidecars.json b/desktop/sidecars.json new file mode 100644 index 0000000..a16bb0b --- /dev/null +++ b/desktop/sidecars.json @@ -0,0 +1,17 @@ +{ + "uv_version": "0.12.0", + "targets": { + "x86_64-pc-windows-msvc": { + "archive": "uv-x86_64-pc-windows-msvc.zip", + "sha256": "68200e25de594df92387186bbfb9d9df606ec1d87efaa0ae0c7f690970e53db6" + }, + "aarch64-apple-darwin": { + "archive": "uv-aarch64-apple-darwin.tar.gz", + "sha256": "2b9e582af54f84fa50c115427451a6c13e80f43b52f8282b8af5791077317bbf" + }, + "x86_64-unknown-linux-gnu": { + "archive": "uv-x86_64-unknown-linux-gnu.tar.gz", + "sha256": "eaf842262aa1c418d8ecc5605f02ee1ebfd369124fa48548e85f9481a47831a9" + } + } +} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock new file mode 100644 index 0000000..03ed936 --- /dev/null +++ b/desktop/src-tauri/Cargo.lock @@ -0,0 +1,5252 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atomic-write-file" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84790c55b5704b0d35130bf16a4ce22a8e70eb0ea773522557524d9a4852663d" +dependencies = [ + "nix", + "rand", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.19", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.4+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "log", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.19", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "shared_child" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.60.2", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "sigchld" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.19", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.19", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-log" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c" +dependencies = [ + "android_logger", + "fern", + "log", + "objc2", + "objc2-foundation", + "serde", + "serde_json", + "serde_repr", + "swift-rs", + "tauri", + "tauri-plugin", + "thiserror 2.0.19", + "time", +] + +[[package]] +name = "tauri-plugin-shell" +version = "2.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.19", + "tokio", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.19", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.19", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.19", + "toml 1.1.4+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.4+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" + +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vidxp-desktop" +version = "0.2.1-beta.1" +dependencies = [ + "atomic-write-file", + "hex", + "serde", + "serde_json", + "sha2 0.11.0", + "tauri", + "tauri-build", + "tauri-plugin-log", + "tauri-plugin-shell", + "tauri-plugin-single-instance", + "wait-timeout", +] + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.19", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2 0.10.9", + "soup3", + "tao-macros", + "thiserror 2.0.19", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.15", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.15", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.15", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "winnow 0.7.15", +] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml new file mode 100644 index 0000000..a5ff30a --- /dev/null +++ b/desktop/src-tauri/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "vidxp-desktop" +version = "0.2.1-beta.1" +description = "A thin desktop installer and supervisor for VidXP" +edition = "2024" +rust-version = "1.85" +license = "MIT" + +[lib] +name = "vidxp_desktop_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +serde_json = "1.0.151" +tauri-build = { version = "2.6.3", features = [] } + +[dependencies] +atomic-write-file = "0.3.0" +hex = "0.4.3" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +sha2 = "0.11.0" +tauri = { version = "2.11.5", features = [] } +tauri-plugin-log = "2.9.0" +tauri-plugin-shell = "2.3.5" +tauri-plugin-single-instance = "2.4.3" +wait-timeout = "0.2.1" diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs new file mode 100644 index 0000000..ae6d059 --- /dev/null +++ b/desktop/src-tauri/build.rs @@ -0,0 +1,33 @@ +fn main() { + let manifest: serde_json::Value = + serde_json::from_slice(include_bytes!("../runtime-manifest.json")) + .expect("desktop/runtime-manifest.json must be valid JSON"); + let expected = manifest["uv_version"] + .as_str() + .expect("runtime manifest must contain uv_version"); + let target = std::env::var("TARGET").expect("Cargo must provide TARGET"); + let suffix = if target.contains("windows") { + ".exe" + } else { + "" + }; + let sidecar = std::path::PathBuf::from("binaries").join(format!("uv-{target}{suffix}")); + let output = std::process::Command::new(&sidecar) + .arg("--version") + .output() + .unwrap_or_else(|error| { + panic!( + "{} is missing or unusable; run the target sidecar fetch script: {error}", + sidecar.display() + ) + }); + let actual = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && actual.starts_with(&format!("uv {expected}")), + "{} reports {:?}; expected uv {}", + sidecar.display(), + actual.trim(), + expected + ); + tauri_build::build() +} diff --git a/desktop/src-tauri/capabilities/main.json b/desktop/src-tauri/capabilities/main.json new file mode 100644 index 0000000..f3f4ca4 --- /dev/null +++ b/desktop/src-tauri/capabilities/main.json @@ -0,0 +1,9 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "main-window", + "description": "The bundled setup interface; no shell or filesystem API is exposed.", + "windows": [ + "main" + ], + "permissions": [] +} diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..07e4996a94e4068c63e27b1e3f931e3e2e45512f GIT binary patch literal 21616 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

    IGO}*Fyz@2}Mx=fhuqTUjyW( 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 literal 0 HcmV?d00001 diff --git a/desktop/src-tauri/icons/128x128@2x.png b/desktop/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..ebab03861953002a9911719fafd3ef68390ae7d3 GIT binary patch literal 79588 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 literal 0 HcmV?d00001 diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..e7fd4f602f0d3b41be96d5ecd93de6a78683799a GIT binary patch literal 1373347 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

    #~Feg2G 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 z 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%0DK` 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%0DH2Lws-?d zwUKcZ*U_xQ7d5Q)$dso4e|%{5Rij|O3RV2@6oc|-%`*APDwwvAc)9jv)>cM*wJZ|v z8>Icu$%tS3_hj|gUx)J9b^lHa0DPPS$Q`Hvc%h{>Cvou`Wn%;c^R?)HU1iMghGXig zBx-GIAPp}8z#Kr}2e-kFx?3p3bU@R{J%Akrf%JR2Acv}EC4|4fM8iwuTX+xj{x?i& zptbuvfb@$igy2y?5rS8uEA6S*w~Zm8TD4*e*X^1}(Y`{Dxc2th9g;h`1Rz!>wYUle zf+!tny8oZe*zZ*U{4hXXJJ1gRvtrZ75B7U>VWDB8!=_t|?j2J<*cucCd^*LAa7h~( z2G=LmV{7-grC1DmQ2Ii5-1f=w0eboR&Yka{uFyn09^fA^`YE{NGbbbEAEQ67_YVaz zH#WR}OiO4IIdXHbS?6Um#Z*U)N~071R+=(#EVf|eiFCo-{#_x1B3^X+r%?9P$rS+p zbpB)1A1`OjAPvbR;eVB^@OTKmKz3hleZeKp5AJL4`2XArow)wLcHSTH1NqUDr%!F% zA3u3)2>@r#V6vM?QgXlR8V>3Dt?YtVIFPkBOuAm^+4aTz%*)>^M|84kgLCoFF981w zg5nl>6zZB;??;&*{uJHLY_q@GUGA@f2Y}FS*qV&Yxe%$b0d(;lusXGHba$d_Ogw4N zpJC&MYPA&48ONw=cpw}TKOaG-qaF#+h#1G@N(v3T()Yi)gMw$+_y+NR_3_q-Pr+Y3h@=C|08(*Y);jlZMdfT?GqK>EF>!pX(Zd z?S=b_w*%t{H9V#{@ZS`f-n%hJA78htr7Zp)*bK5wmaJx7DS^M{Tayg&TgK$L z@Jx=ELG%ABnt%88)29zL_yhy}_!&Yy%hMLPei1FQ$9PI`irMMJToG{tH;wY}T~x8!-E z^L}_d+2|^%g1WxBUeaS(*5TAOoDNpOEwhh*1_mpUOD#FR*qHA$6QuoIltEeC)N!fy z*;rks(GsSFOc8oV({<{`n|fcvmSmK-xpF&_FT3-TPd)_zX|C4=NKQhkMRwMMgxAqY z*P+Es@-#A3LpiwWBx$xqTg;Iw1nBJBL09+k7zf~qg_P>325H`nELnedDbHiey8TUQ zz^j>JdmVkqX`#qm7e%6uf`2W0xGv%*r00?jZzrltdJj51=s}?isOPzOwZY9yw#$22=zeLE?>1mm zc=ztTw9P%8^4G85m_DBld8$3OE@ap|59RpA$TbPv$gAL<%_q8G0D=J;_uoXjL5T)X zuyN-&9zA|!1ptmMG8Uw#Jk!}xeCfkE0sw%>ZIc81Xh4g91J0r+0HBRygAw3?!ZV{~ zpBaPT)PMrM0hb9d>*mb&yXoW4p1=m|QOSqhS^F})E}dUHl0D5l10~~}zDX$THfK&w z5<2@XnE82KJ9_GO@$r7FFPk=Dj0R1&{;FbaZ z(^kjdk8=WIHgn(S$}C%}(*3Otsu6UF+<55qWt`6mL`W)&S z_(^x4()bCok@9KNC($L5-AROsLqTW9Ez#a72{QO<#Fmc1gKUENR-wtN0~}z)t+8{f<$t9~#)D2A89!zG&-d(4}P3JerRtNv#=M&vN8+$hL(6m!(c-jfgJ*?A* zAf003|L9zvxs7+!_p#=0-vN99Yk&sE=tlT^`*)zKcY$sdh;Fn>;szbW~+UZI)b`mx9K6bJ-g8!>aOFOV+jgKEpcg}pK z^B*by5&ScEo;uw*TK{KGCl9Lx`9qZ3SQP-VZr)#7a=`~X z7qtGE{&7AL{GU3*KhA}izn^~k$-qCRg(pdI%oi>;l}vR0=a@Zfr{f?0`%93b;5G5* zpMTgVg$ukWJow+5FToGS=4ql8c^xZOhWozu`KYA8CjL08%T>VtD#1Ht&ploFoOR{j zTYio7n}}pR3J8&JZ8Is?f=$0lq;9Ll9VB*$0^Elyu-6stll|5Hofy}vy5oNAo*EVh zSnJ!WR=}E!no*89*p}cn$(*H;;Z z_{|)Zu5-C|o%*$=V?)zLs|#Hg&}K1Zt^hzM%#6vP<-6`=jPuhNf0wS!DJwCPCsjxG zgvS@1b3h-I;g1e5aUAHQ0gk`@`kR4&eji;p8saTZ{a0;IfkQV=O);aY%>nU2pfe5V zAzBWadUUA=3}Ipiz(vQ6DIjk3Fcrw82hds}0kgUU$dVSI+JL!Kg3#@>kH#jYUFWPC zPX;O6DafnAtNnYEJYRjpAsYUx-~|ysqmDg-efw|seTo2=5v(UE0nn?xf#lYV6B$>= z%UBlfdPawTfG|}5wDTX6Dl7$ArKZ>qnU{*JEB8xfCaT(T$%pD+8H2^xF&m#k@WpBU zam7b#&O8PzGjHC$ePf+-#*E|Syg6sg=DgPbsk`?c*K&x>)-!NOn~?DSlEltx!UAY5 z8bh(RG7OmNREK&VRJHGeCJYY>85A$*q{C+5E7zY7uBjC=9Po!m?{)NzX<-_j=hwb& zQbDGWCndChe-A|_gfWEFckf~BaJ&>LFe&`Y6#!Vz_+X45h0Z^p4;=G{xf)F?M;F-m z9H{qf{dpxL84Cx|{&hG4SNkO78Kz4!_DDq$ztZngOMa`Fv_%hUI2{H2ra_<}A=hmVdVH0Gxc#IUjFzjd#TIbV)e zsGV?H0B7(I*js-1m4Q24_|XXi98b31V2}>uOQY)U?7i;0ZZUv~zt>h5_(1T#+0>eM zjs>%}_yKSQoN>NS99Vee7z#Ef4%p#j|Ia^ftV0XPF$Zzi@x;{7z^*F*SbQJ=J9N`I zmlWuCjRFVt`Xdi??QKT-9a5?N4k+~W3qXb;Wgoz#)^U9ReTp1-GXSRE+rI84kY7Og!yG40=M znTY1Hjl>f0uQPS>Uxi^kvJcRadDv%@9dOLLtT8ijjxr^S8;htNlMa-|8&17OMn;$Q zw3{0zm}g7c8jmzUt~OWoGJmqs%@^nWf8pMQ)pj?ul@$@4z1Qy?{Q9*2WdovLCt>bd z&stZ}`L9WFylz`{t-d{K`wG!e*U3&=e7JgjmqD-ZVjg!o9CyyQ48~PgUn_F;_Xt9x zsTfY|fL&xx<@{EHp0>6=6B9cKx}`-8=Zoz*@85ma`W%Y@iSid9^f#K)*s3_ zOn_Y|vYFAJn@7<6k-WfqL318E<(17FPd!g5`DqchTjUw#qZK|Md=boR+yTMyUI6@a zty^7mcQ0~p#t*LZ4?jFJ+CNc$QK~~RXdnDF>T}R>Ep+)KRs)Hk&1$jxZ0|ig==4*l zpb$eb@$;vT^%aGwL$uW1V>#8F?R_#}XJZA@;nP?}Cv}yJnFgchE|Z;&Ba~XQ-TC!0ZM*k` z6bWwHx^0<~v*o1~i*&p?BMh*i0qk{OgK&TufE6$U*kxmUBN*ZNBETE#ZlY19a|?}| z?!8520pJE6D(N^iY)b#Wyf(m};sYIFSOK_ozjEC>T;ur!ME(2U|80K<@TSv#*8xhp z$ortvk01-b0+G$QI7_EvgQC=98@k#Yo2R8W#RiW1^{v-I11;9&rRxYK9uq^x2QX}& z0KpsJCjWhY=FJq{c%KB8Ks0-6qU)-jxmLeNzPn4|A2m6l=!wzyERf<39=budLVt-QS`mgXe^?nbaFhM&`vvHs#Mw zUB{0`*9QN343C`dGx1Qtd0vLti+iiIng}AA1^&xff78P%1xy1u7Uo#`C#zVpEys){ z!1-GM|Jg*W1s(r{Lm%yzH}N&2$Brw(e|GEdr6T~;C*fiMElqaA7uAg4D;*FGriEJb z!DQ6UrlZOk+YnVnTiDV~^`QT{s15@??Kl)`$)YBD4Irdo<_lo5|RgM-JXB^b`Dayp^b>+pq;~ z6P;jY_H^dFH3OUTtFXDcK|?`003qKe*C@zi~!!Kb^q?J8`JN+=G~(0 zC=lqm?}%4jZN7Ah0hz64r(jVfk^;sAKDbm!uY4t**F;{m{j-p|kN->%Ss=HEcSV^f%m|J7AUN{E1aSI7CK zQFE$!mPRH|dE5Z7Sp5~q{z=|@Jf_I%h)fNWuekI0V08bN2Ku=#pvl9KdH?Rc6$kiz zk}hCBK#PCq<~1PsL7jGR%g0C7?2F*X5p?*yNXMaoWlf*|GQDGlINTo>3WEN-~!G4>0`V2d08@5j}3FM4>7PLIe&(J?wg;UUUOoy}r5>jTYy7n`jt?87W8WJ}QszRqmb z1G5t*{!UluDCn_MIOc-?6^^kpWKxyq7>`Z}Ip${J7;2FtKADSNL~6hTpreCJx6D(( zKUlYu%{5&0zvstN6fE52sPA{))gN%r<{Ub4e9iyRd32zfDmlkydGQWx z0F>yinwenm7T9cLW@ku~IK=42+av@;WpkfSAFYrP0rzby0Nl27F~5M58}_?^nY6PxEDuN-3yra0Jm&cC*vKMw}$gZwSZ;DuXOP>6gTxt#cH+Ih$t05GB0L{Mh?==68 z^3OTGPk=PnozDL+F6QsYpFHi~iW%T@#;EG({dFX1ea?Lvi*ztyH@zqrTHrTeq1hFxFl!_+s$R93w)S4j-fxrO*u;wqMEY+Bu`6&8-s zLmkjwESGh!n|^T3xo-<)*z>!k^HObe_NHa+m^#h$P9?)m==$zwSpd~BsEWs^3~04Z z(#FtvEepmxp3d+ZxO)ZLm_U&HzVT%>gj??%%TwwN~CWe-|9e zyB+|gF6(rgBOn1>M*6kosGIZrXX`+suRb?-{S)`IodZ?(1I_-_-k;7j%z;Sdl?;`) zr}asT0I@&Sapm_Nc@NCDr>;OE=ibR4AbF%HV^Nj_7?~KXtot^v51pFJIWXz?Z`0o;(f9%{QB$Q61ovgM!C}cJ^ z#}EoGo0w9eRb51hQV0q@@2ueS%I<|%!R4QV597$SzjNo76|pSd(7F6$GRHh+ydwD5 zM53`zO#e1R@qULez_l?m^h}0rOFPf8p!cV6_3rIE1OG@EVqpVHJ;%pfu;c>V{&CO$ zF*W=fD7vK=z3@UT1?wZm ze>(MhUnAzP#Q0D9)F4eM43Zk@@2r~dTw)#0m+j>V&a$-!Kvlini=MgP{%A!2XV)vO z7{Fe9*Wm6;^@!S#HlWpHch|j&oiquRQCfSZX_T=7j>z$KC@`fco@=TIQ0lQR z*+E%C{g+W{)?qkD52#4XXu?`a%v70GR#8K$jx#$S{M<&t-bdlp!H*RZ9;6F6K`bj%FDBLPfYwFVB8;X2a5&_W+ z@W^JL%`pW4^!9Xzp|`&Xy5^S-@V$0gW(os1fdhQ^fjX^G&o#&dkhbh9>9WQGfC2oI zlRC>A;LKOR)?3%*`}e>9ZO3}G^KS9y&QJ}iIq&{?|Jm3C6f9zQv-hx;OhtO&xsd|X z?(6XSbnkEL?HWJ>h5#Gy9U$}_^@{++ePIHcJ>As(ZkKZ;_TB#z-*vZw0-f}_E8S1y zsDUIv|1#0}Up0n?@iG9+eQcdCIK*x}#f!~m+q}R1Y-{gNk;2P8LcxOnc3}TJ6a`Mt z683_~L*W#`W)2r??Z(dlMKB(MQ6-?9L}c7%+CA zpL>90445!)oF@h#lY53j4U7(Ne(1qJ28dn)@STBBlgn(kiIHDbn__P2cn0ZH`(FJW6#QyLHUE z|Bs>n-?jCEv9NL3XOa{KrD{6tOf_K!G`$a)mJ;fcm|QJPFv>j6M23<92KI}ZVg1U) zc#ZK}Wb8TDQ!^iIkX_;lG?ogu9DW=N{AZ+6Gd1qAR-!>U^^$J@WcWoWzIH68%_jlOpkf?Mu8pWS2P!u@Fz z1K3p1>-RaRBBcjbN%|Q?`4Ubm)?EgZ(WT$4(N!x@nab7__+On8GXdbI!2dF$g3jkj z>v0z|Esc+yrL&uV&DL^Podo|&tNc3f^BF#TtAqcAWqvJBuq+%i8OxDcnWA}w*Vg&h zXnsa=*YhPY1l*%V^X6Sq?+yb1nmE84s=HC%UAGOlbDZEme0X5sCvI3xzr2y6BBvxf zshAxfJI}s5ch7a~oPOPb=IGAN=AI5^?3e;x(b#))xlN+w9`7dW46~n94;ZQT>F+XD znBO%qikGIPr>pv@j?#N)2$Xy4j`OMensy(@pf))hC_6_7;pn&?Dg3)o zV2~guFrEIH2kqO9>q4L32vUmX(?Gt(7xH=H_5Bzi2BSiX2iBq9cp7$;DAexh)vo0W`U1mOWG+-J~ zO#zGR4CSA5`raA+J#*Cm$Bxy2uYq}Rn0et8 zEJ!H$Gj#qx2Gc*7D-WI85<94DB~W!L2xe20Vo zY8LpXzVUGi3y_d-uL= zreg$L<&*hZK)Ge&HlPQN)M1AvJRSaIW3+NM@XxNAXgGlGix-}D&8Gk6t#bn+yw`1$ z4r~H&j^*S0b7=F1#t}3kny18Th9Vz*Zye?qf7QbE9maX;9nN8aj(^vV3|Cih?cLaD+qSi zxvWwRuo&!9P*Lt2zu$2Ik{Cb;|2)>WGt{rzp;xxpI5NNl(fRzlfw7hRs7tkw2c1^j zB+A+3SdkW4vw}(T(R8lj0AHv_k_MSJZc;u_YDm244)wBx~iFg5Q3Y;A*9xpC3I5zH0un(Ka)2*enmD?7KH zckmBi_X%ZvXmzU-1bBaobPFJtXt#~0d!OC#T7GloKZnA9v)1Y@3-%4vb@%te@OrMTEFW@v1tTG$0C^@6E@(o-FlvCy7qJ=7BEbV0)G;KV3SM$ z(*&;xy8dX&0m9JYU%75S5tz&|<`eft^g|%#khzcS1H(W5J9hq0tO%88*!!KSTF36^ z+QaKdcK;T4dD!G##kGHz%tG6U{IFT4!u6E)F*`MrqGyT^^IG6Fc%D z7lddlUK~{@1J7(-mYqD!T+oSV!fv`#F|By;P)qg;L!3mJk-&A>c2Os{TBU%xTPRyQ zo-bjs>rm4@ftE7xvjH!69iaZVbIBIl4D{IUwgH#+ZmqSxNb($YD8EA9e@Tnu#tq%4 zF@UlaiU5`w(5@Q%f9$=1EM$1~}hS=h~p}}?uu@W&%5>zr}0-597kF8Sa;mmI#xojh~w6^RI% zv6S*efrHw|o>SCEE|T$)S@D6Kdm+seWi+hf-7W$Qarb8)nB2o1^v6sK^jMS;1(al? zwOx52h;HUn>1v|;hebB%!RTBAG@#Q4K!onLr`BRStdo46RLOwXB5>aiyV;N;7+rP+ zpjPWwM*Az^MSTLmOpvaoGqJ-qVbNN}tJ&aP3%EGfRH!j`P~-31HkCfgt+cuA74Sm` z9-vT-73ElJ*K}vi`tI~*J{cKgWz*lb88h&y6#1CZV}14tL~lCsX!jgKoc$LgNN zo}TW2YpL=D%hZ6(MI;)~`fIeGbeAD@frJC+0>=}qmXU7Ibic8$%yTx4+uqqm&y62kCk~-#FS55VX!2ZA`|GQVOl=hG7_*nKG*N-L` zpnER+2-^HtV)pm)W%8s_D*d% ztPt*%LNXU!FrYD}xE&$P9R>Oxdme5Z>jW>N)_NgJ?#G;$-o%+Kh@PSVC)EcWbFu1- zVdmKl{&=>i!;~Zc6h=Uov?fRI-;4x|6es*iX#YJ&mq?Z0K=pTBjnI+jsA-r%5kD^o zFX6;{B^$974L~IUSjo)KatC*ZkBaQpdQN)WSb9eucVYn@$>db7=HlcgI=PN9^&Fcf zlPt;7qQod(n75UEY^bxPGTF6%ug8t&W}uBYz#SQpW0DDGy9gEm|2a&6d0nK(UY1>d zMxE8xm8Uts5Fh}MfMcZ5cOnbGf*XM0f#kwJv=rMg&pvCip!$MI_3LHjB#y2$T)SOT z0T)&06qIZL000+&T~P4>W?}q=0*3Px$UVQE8sLtYpH6pqdE8Pny<06`fo2f_01Y7L z!bbINt*gH?Sqm2d?q}_4Zw}O~PrLjmfY=Uq)vXEgh=T=by<^NC{5!zf-|3)fmn+RB znlk{@Y|g3?qF4C1#4wJ~QT^e5k75#Z{U5BeKYqp-hsmFsVxov6ARk?R0OJ?ZSq8|& zn1{jNfgNKGT<#hskKwgYOw2n-&H>(l&#*tjBn!8{=$ymg@71^87TXxV`aQO;H{zCFW0+*e<@(R zl>#8704U@f*yNJu3jV*+eSjCkT|GZlc4OaP@o zjjoB90B!;g05NH$vb>Z(xsn_3;^R~FQ7?W!_hH?Cg0HXpK6!TX>7RzRHqi){&m*1d{r8naiKZ`oKoXL^>(7aj`!V+DZNeumNj_uU)n1puV9 z07V%9Y9e^)BMrTKeIyM)AhOSz_@S#$R_pBO`(Bz+n&TVl`AgRob%u;~d%`rI zHt25;L|Uem&{)sJ3jkh@VSO2VdV-$U(wiiru!4>;z~qTg;W2h#Xo=i-HMW!l2N*O$ zhJm6D#y2ATlWPd&$WqY#2VmBYza3u&{FlDi;PB1R+g3(Rx;E}Yu)MMzbM&^& znBNL>=T!F-;Q5E|zAs*V_igc3^#0B;4}S}0!xQP28`C^vX!yiM=E*){qp(tSW5#@5 zM0?9=TYT;`jk9KvtEvFtq58w95Cwp@!eT&?2HS$MkMpCF1p)sk(3rTt zqdM(BruiRY+M2OA84v1tI{43QT2Ncxln31NY!4U(g-l$*30jNyCqiXkZ2u(zKx&+Y5R|SC zDiZ*?b8U}>mUko{e8$iStX8{N>_N|lrMvzd)bv$jpX}1nU=@#_{Wu~wE5F$lW!9Du8h()g)WScokpR#yLSx}00DpRVvb2cWc~@H^-G@T*2s4>al- zck0QV-%epwz+$MM3m0vvM?xs?76=DjWK%O@jbTK0AO024kb}j7XiKmFAa4QCG~?z_ zLBCS{1@MDWVyOQ2q!4vSJK`|HGw*GVpOJGkET6)4Mviv2_o?8b=V~nDKU4J3w!Aet zM9kgRn0N}S0VaRnzY_4QQF-sQxK!8zA87K3_CweF8TJ>_hWo}bU#}fasW9V3ug~?W z$CIEPX}}%LCfx&T%I734E8F=;AxzB*QM|xDSnv<#0**bH2{<=MVfgCh*Gm0&;2+mN zwhwZQGKeZ{-TZjKyiYyeDpTj8$>5)(+d*-Yf_(7bvxep5`_-L4IhyZHiM0o36--7O zUB~XL-0kU=1pl0hj2ze^$e_w6@g6tUoU}Y>=Xux@+*^dVE&o8b&y8b3@nfY8m(+9A zOtA0TzOJ-Ajse)lSZS^`KENaFRkKc&Mu*I4VUG|UFgfYLdIdpPK~j(!i5Idt9}K&ofbQ#2Z?3fdI_K$YwM z&T6S$EFM6ZzGmfg@3*A;ztQdaB%1B58vn70(nII6Af?BLT5Pgld62~^^B}k4@b5z1 zaa6+?&Tm0+B3v}YS@T@`4E-EiT#1d1sK%MOHSc>zq^`6x-Y+eRsA(@m4Pa>`BFOO* zCj~Wk{b>D%a)0pDNvCz3fl3(Tvr0q zGWW-(jKt~Ly?=vzhlBx=2JG+efB$>^j>4V$!oEIMe~*{ymX8klqlb?)9YWrj z2Vdl-btZQ%nQc~Ob5St5+{2xg$Fu4o1`Mbf3;~7&3-<3P^K|wN^G%E(SvIAOXQ&1+ z&q41RE)TdVnK3*)Ft>LxLPI+dV{iJ`76~|WVdPq4LA%C0&BH9P@2*s4XzRs)OKIf? zMvD)PurohXI<z!&YDn@-*9x_?KHj4WN z6zweXW<+82NL%ty3Ylgmp)Cdexu-ho*&(!QB@!6WEAk16h$}BjEd-%^3{M4g!J!pL z@d~DQC|DWr|HT)VZrfA@*ZF@gp!h4vy+37KC>$* z3b{hrdR+tm*f#7luFLZ?6Jv!>-@ttADa1lwsUUAR8?4V zhJWsQ8+W_mq_eoPU9A4Uu#yUxGlJctMVs&8`2Sj&KF(Yd_aI0XUc4}x|Hsa(Fpy<< zQ&DU?t>&{hqVA^D6x#oIsXZ^J*T1QudSlMzpzLuD%q@q9mh%#TJjKcy4sWho+s~u! zm5$fnkU{NZTy}g`$aPPqY=q7bNT5khB_8DwQUUa>9xx3 zgtVyCMasq8CVU)`0a5RJ#k~-z(>Sp=W{LV(ER>E(%hEiClA$`V0mc0A&^X#d8w|=V zvc#b+Mhq8p`f%EYDVoxy<#nn>wuu0tYka`RYcnnz$Cd&fH2{e$>y5FVfHeRX4qW|F zq(H|LMS%bQ-~UzV-S6JMD}Evw=7=&woS`dD;IB|LKySX>Zasj`H3mz`0aBd?y(Xyg ze;|tAXPWanC1YjDOWB&~NR%+wO9KCnRA7#zMHCsWhrCdoirQZ@CX4{YDspk9(RPmF zU!@l86aYg40^kUC%9rC4jl5I6zl_skDFR@;;yFl5U~mbV`+YI7MAskSA7CGkhtdg0 z=O5#FbFi_-dhUEw+vCnbkTTiIV`XY&nsR;&A~y^ht#h;#!d80CA@Z`&;0?3?*w;@m zH2h!;4d02>0YwiK145^KczU470cj6dJJ|QTh%dASGUv4srj1fZ7+QUCOTGDUVHXd! zjSA51^TuoBLfAzgRbGUG%{$!(1OBmn_wF0;4~7O949>5>88Vi;vc_7NT%@KBop0C5 zEOhhFNU7t@1ypU0P4+h?k)n<;!-^eAS?o~OT$aV7jjOZdR14UnzS#%SfDh!oYQx7l^;{c_QNOa zWH+|L?N?i-{^Bpr{>y*)FK5CLxHwP~Ks$A;T&JK)=&5#Ma1kJzYTwd9v=(qY z%G)ie`=7bsNR`;6cCvT~K&UOE+DJMn5#l9{+Bhlh;08Qw-ROBtqG%=n8^Dg|-YKfD zz`&#EdA;U(9Q*vSv3lvqygu;Xr1Da7;ZA8vdPf(gFY1tam~ByuZp`id;;B{YYXSeW z5Skmj;mmE%N=c&J7q$-uH^Pj_O}3!on+XS9Qkd}pSY5%a4({9ns$Rc-qp^ShO#l@D z6m%Hq-KgjW|FJP>jm_LxcO5+!5qKLEX3;ML5W?x(<=LZKsOEs5KP&LLa2esMt6vM~ zyIplMGYoU5ETE`nnitm_4t>uQNUnb#M;k>c=0R4(YbI@W4AY9c?n}(R-fm(YOJ6u} z=8n+!1K2CbmvfHu{}~VY8Qp&v|Dii>#?MMwB=-d9!@J^Xp!0W35sg%|29g@xc(tv7 zYw7L>rgL31Jv5s4)a1%MCkBz58UtH2HQS(ujskYUa!^kW0m^XqN7o$Gc^m^2MDT^{ z16^_eW;p!APXqgW`_3Jo904ZG*0D*-ih0u|4do(Lpr-D4N4%D+N^r^M`o6G*MKF5a z$i84da1IR%VWozJ&z?V1w|@ivpIGpJP)o9K*RbY7&;X_#=iYn!V{%*G4PEm-5BCNb zZ%Ee<{)Z)2Es!2C+k0@&yq{j|x20k2^~s;>)W5KXPnb!7>#yV5jfqt!PzcwWUi=be{3D*o52~HCl!SDqJLjvKT{3uX=XIV-ePU8k7Cbz zlwyq2f${a7uz4a3Y0fdvd!#Qfd|w)+oS1%d4<}JaqeaIYPV)c}S6f4|Mj-$+do0l3 zD)@M*Jx7@H5Qt3PP)C;-4k z9v~89qLPS|`gd^Bcb)f;8C4ZsG7s4Z2UE{}@QDW1>(D8`IyXjtj){YAIY2Xv4$(1p zr?0P4g4e? z#YEs662Hekf~ddD&!cvmq#zDGYD`s*0;Hc}0OTQYkw zcDHu7%US_IZtPdG$k|E}b4R9%(VXPd=JL!Psk=NJWH66f<=9_G!WKFF{dCKmj(5qf z-g3TKLZ1;jrCt+_V3`Bp^mi(`MoSy}x$Q0^Pq-6t-Ai^9l%X6e8J-#Z(@4NeVF!dd zBpV}KCq~Tl-1XjEjLj_^R*)K81mKxnmAg#;x)Nh}9`6l~XkkV!%(CM|x!W_sN7M!S za_#b4?eV2V0z#wvK`6(d9Rq}*^Z59?$4alp->Dln3;|tt(P?t68DF=0GSv{MwC>WxHq< z6s4L$3Ehk`%IHvXEke*8hqaB5RBgL0et^~RNx5 zSJ}c_n>6x484M_J0Q^H%glqTZSH|uCNnrRF78k?r|6JZQQD<)&$#|y>sUQvOy4R); zn#=r&lS}F`-T$!`ciH4w%2KF~i{#vc>0{4PEXI%X$GoHs*@gYjFvrG9pak zaI)x$`s=j!@1%9*A3ti+>lB?dl?iPh`YM2+aGaxM)wlRm}6%~q1l8E)v@Y2?i$ z6a07U+&ByP4+FG3xu2Pi4;+~XTO>RMXvBhoBCkuugbwoS$ti8+jn{l6IYt6O(en_i zh2X3)CQ2^I8ES!v=|zC?5wF#H$W62PRWs(f%tmA5iyJ8h zZHh7n^CsIY-W3&5muF`R=a?P@lz&`nkh>rrPc`zE?vGBoZns<$U^W2sfBh4aXn>*@T>cI4 zHwCY-i`0t6Oza~ywf>1U7d7%O@VX({ja~&7M{0U-%v=3wtCYy6HA;K@!*vMGx z44S5Mq69f2)8K1&*5`n;&I@isEY5d|oFOaDupX~lFS!I@H_9fA==RA*#xNH{;(j@m zGQd&Kq$2OG5iWK~3jpWx?x%9sKa)MZyxLN|03cnA?N?v@f$*Qe0H~e}cfKnMkaM%h ziXSfxh`Fn8#PfE|u9H5+e6vprPyaCPu<FJCB$B^Xp;|<`@5eWM9)`HhGU)qI$Lr z7rATBF}DV)nX%^4keh$wsQ2hd0zBNgL#mqwr~qXb)M0e*V8RAKqMb{b+0gw5AYZTV z`bB@U@}Il-tVQD4csC!*hR+f(0Kjucvf2Uq(B%ct!=E5V%mE5rLn!!Q1N!8502Au; z9%Q?F`NC}78(zQy`v#D3Ih~%+lqGN?#?YP1=3=u(4L`OWVkzQ$d~XUmp}yaJA+^HL z{2v*Ozta4j+1)))d#~aZ%~}eqfe_f%3{h$Rqu2(Z)^KxO_ano$p}4lSW|blM|5oDt zaJ``*;*KHq4JMf;BF}XG)dW*I|2Qs&@^7f}yMl{4tF$uO3b&C99wj;Qg#f+w*QW5} z3Om-nLCrd)NcZ}OA7o#?Qz_AxlP|ijIF~3OBBD?Y|D*u$6o!8Q|2CPS+*OPn$0!-w z`R5x*OV0)WOH0tM?+r(S?$NQnGs_>B0{=hGufh66nq~hfGs3av8_+2!sMUrB45HvY zanrhx-<-fw65s}%r#``h1M{jdN2-w1_uCiq8?3OPyDI4v@ngMzdA=j$RKlxoDJ!DLV8C3zN|$oE1+hISe*<+qi7 zZ2;Gy)mM9PfzGaV8+X~?(KLg^HW{E^b!qVc^8gHmTZgxl72t~(Qjh>}1l4#eKo1V- zpdO4%@xitS$Sn^!e&u7QlQB=YP1r1CO+@&eE_4$DrB8_pZ%0 zf{lW6?~Wxp`*?ujuV#DdEn&3%s`0-tF@C2KYo{iE$41|O>N@`N8aEaJ2#v=7jspD$ za!vvIkqxiWm5_VL>kbcW_uFRNV>=AE#$u`r3>(R3(=QNu7WMl8_Ud4G<*F2%F%JTC*IEIGYZ zTswX=K$Ab#BrjK=KK%VT@us~XgwwiO&e9d}3|;p0Hl?O~ibh=yjO}YplBqM1>*WwX zmgL;{ZFd}w=(yX1nhR=mX|vD)*iHqc!L=KY1Bp+A=K8j{?ql%}P-55sYt!{r&9NyQ ztbD{zQ84Gt{uD&&_``z#DcIN)cZKTwTr>grJW#>WWrRK&-9>baU%xSV@UM^Vs!keU z5!B)*PoES|gevVc;Vu>pGKbJ%yh%ZfO@X<9it70pJ+}s$7|Rt){YfXuwDkQ6Iwv;@ zIV%hP*A~>8uDfAgs29Ypa(zD)Fb_@t>6cUO>O<52MgEQMK7js(fM29eT-h!--oqt9 zpkU*){dl0u4dw^N9nRrhc@Dn=_{BIYZKPhwjuvXcH&re))zG4jlrk=g>`aCF^;<>z zRmV|_Bx};5)5={VJ7e=T$Cg|aw= z%_IAO0wXB@kTIZ$qi+A!uv5XmjhOZEjNSUYD5HV;6T*gzXE-xQY4SYUeYeO>&K{VU zm|QPXk|oRi``Nh`SNr6J)3jnGcw*NN3!T@A|H-HluvjMoFGx?n??r+%w0A7!R0Xp;rQNoco59m@O zcwG(O$Y_k+y&Zr9o&CGg6@~Ws`NtP(QutaZ(=Z6cw!$n8TJk%pDSxB@1PKRLbS;3R z)NZ5e#=5yT*BEesN`K(rQ_n(@2l5KyMJsMbFLkEl)&V3|H|kupV&C!irhYK76JUR) zW`4$~v5N5fsOEM!H+UY24yASR2gBpGWoY2M9%-llfp)vq)KR+MU;${Xohw9Gc8H<* zOX8BH%Gacn_I$4>mxRHC`5Lu6c(%u!!mg9~&#-A&j}eXwfbr`eUYlq?1^u7&8bjd( zUnr`fcxBx2zf#Eqq%bIa;5j`O0N%T;-PeZRzf=Cq!)7P!5}T!_zCXKCr#a35|E{0` zz5lo0S_y*`=A4WJ=N9(~vp+A@?f;R2|0CTO_dfOOaFyYGlp|-De_b*6!m7IvO_{~^ zJPGa4P3)VEdGKEhGz{b!X#Z2e|A1JgMZkY%5nx$4LwBlASTGI?%C-5Fr>)1V=D&9e z6;z=FCA|< zw{CA~@Xzz?3e5@rXDHI~9Fs3ECT*Yw&_)5k7_EVBj|je_V*7Zj3w12y(a8nC#)2M{ zUQn(9#y9H&)%0Q!zqYyKuG1N6`|I;pcM%|5JT#;8@csZmE|Spczw!AVFbLR0H}drK z%!5?Su>hXvu0Bv8b=NZ&RAzwfUvCtUR$A%8JE*hGZm+g2viPJpMYQrfreTHX*7_r! zfgKtebD|7D?tg)PpMup>DoTlMS3Q(U7-#vNiSXNPRm+a!3lON?eL3zZ1bor%|Cizf zj}sH&hu3V?vw^+&jL{X!2mY zK=Y68zw@;4hM89CV2XD(N}6b%d8FCH8l)AVd)cFS`W4+MDi}SQr2>0bGj-;Og?(N6vZXVa{CL4+sC82(-*e3Q0+15+U*t zdK**s^F_x!k)oqDp%e?o6~GLDH5Tr08`n;r+@2u=KnD{#<(2gS09_jtUTCqYs7wJv z0PiT-Q`Rn2bCfswa{zg0i17h1Cxjb1V$-yv`o+isK(PiWg~l4@W9X#fVd&Hk6j%X_ z97^HiK#Le_-A)5~ORq_;_JxCJH_m2ba4)oWdb9f2ZBVJY&C^8Z{S{?z0U3wf;*bW+t#ViYOkFdBoX`ZA3RV+vBU;%GMql*x z92v`$Tys(HhO|xz6@77Svp5@tCA&H^lS4|%h)6)nUdQLfqX)-(LS>ONtH7aBy2bLj z#ztbUoS-k--rZi)ntyH=-aExUAvK0Gw}Ux)9(Wj6d^kHiGfTO5#Et;}oLSc4kr&$7&Oc}78TUyrR%bo>F@@KEmn06ZJN<}~(&Wf(Y@@F8_n zN=OQ}+sd?$Exq{Zv!@D@5EJP1)bG@{;Jt1u9spkl2d`B>{a8TAGoe)jn82{k0pLQE z3`kR3S!slcIOEPoU1Lq%i|WSeCV53OcSmJ~MR)55F~})L3uY#6vD938JyU*>tiBN2 zof`Nl*q6Azi;MH(Tz)>@EhKGU%~Tw%hU7aboc260|od;Pp*Tx;CE`*N@r3D3We zfwi%CPV0|i6viSn@o!$gQOZA#9ge5_!Zif8!wc#7Lm!W>KV%Rns_8KaA`Mkiz(GXb zilXs+WAyN$*1rkBe0*kMm>3ubC}g~n)PnC`y;3#<;v3x>4MiM)eH1fZyfEPZp~xEW zdw3!!LtiJq0Fo(&RyMb=30%HV9*tfWiyC`53a*c;xD5YEF~2`7Q}= zIdOipqn^2Lr&!!QQJieww&|1KOXX7-0JJ)l89*5TUw{2|n*ksTA}y-R;Xji&?N4Hn|0FGXy-frE{kLJ}>&yh8Nf$^s$GO~fJdmdj13ggV@q49r%j2`^x<4rs z;841`&PxEgWpr>|$2J82b1WhZ@UQ%GR@R6P-C_XAhYw7LAK=O9rO}ne>j6>$TCg8G z>$`&vziQV7s2MrIE!$~5sIv`%VnmpC9%q1X_MqTC!?|GP zTi@{qJss!#LOcKG+U0iJyY#?2m) zb})6UMfl0aspP==GPFG!sQ!)8z0pZNLO~l3 z%D*ybq0jNe#ylzBN6~vzFf*=caCZSyljLvb?8eDm7#)q0F15ROt~E} za~2zR6!_<2QiK%|jiQ_EE7f(uQcETl$h@&N<0ol>+fuMi-SsT$?-lbv8;l9ye|+@g zbaTC^Oo11s2i6>PFRfp*aCThl)`GxoAYrk7OpHkogct->%LoRzW!{%eg*t5 zRX%}^K0rUfKLEaa9>#m__a@u@z@K-}{Bi7*&M%Lp^I&{ z)qwvk^!~OGWn2L$cma%(8l@7>ut_e1?eKk=q7dd=GAZN*JAoJNyEdF=ryYsRLdIi1! zaCNmVW!{sLxq^S83l!&aKsWNHrn!*2`Wt9B&D@_a)&r`;)ys%#4AU%snK-&J%HLj`{d1e@1MPd zNhK?AdN|PnoqpzPGzR1x2@6ej18zt83|T{PhKbv-f)N#?Z&5wtmHHebl0^Xr;sJ`W zum?pMdT-Ff5xIw`w;!aliCu7}ZqBI8E%s|51BC*mEU%LJ!Q3kyL$q3T@6fDw$O2Yn z;Y?`YOYnI0Sfk?rK#q@Jss{VxCzBFzdS)`%T_>BZ|s+2_XG7Bl6m6-#`yB6;bC*X`Q@85Uf? zZ~ztEfj0nl)#zVVI(|p-KbK>2Zb7}%@jDH_1LGWw3+-SZ-m;YL15+%Kt(?J(NWIPI1E*QC^D3Uid_XMof{?Fd*sq*chK*r%6 z_r-(|QHCfkVSIfQp#SmH2g8n#f*Ho$XBlHCvf%5kFd+c+AwhwE1&RR9??BBdZyoBV z;oP|b5(*?#uI!lUyB){GS@HWQ*}H=NS90b16O1|S{44t>ENZ+}y+2Y6T+AQ7RL1b~ zg|Zk-a=|0rH;oOo=7Ayb*W#gF2XK-PLwR!mr(#&^5<%+n6dI6hG=^o8&oiS0_|J@M z4vz&DlVxRnYnZVqSh3z_=(j;bIf6XU(0fwzKHl00{Vb@U#le3r^?puZ&)hO)=olMZ z#N6}iwQdUj-PHOA_WJyZjSO9>5Cn5pa38Y z-~@|$0Lh)DB?ANi7`&B)002CIzzwp=<#`yqX->PP%PW;T0Ip*eA5GU?19Cy|O^T+de?((uqCeq^xAZ~T}0H_Bxy3ZN+na7bj`nv)Ep7)V;wvZHl;l1%WwYb4p zahiHn?6Y3UhV@Q1G@((TamN}3r(NmyLBYR_r+3060hrH|G?v39U_+PTCAEeXy-g){KJ!YKHD4A$?fhylyiL%FNWvuJI z3p)|VWxFa%NxEHK-2eN(-})EJeXnK$C=={`KhC%R%7mW_0}y8o9-|G!e|zmWnc?f`Gb=U(^wjtBYb;;;S- zcRl6*mX%Bx@V_<&eq`La@VcPYs63LFT}My_1Pu!Sti)Z&JkM3gtzXw+!;^g9)9v) zVOV6p{`8l>T>AomOdjQGb$BUnelGW0$N;e7*!B1h#OMsfSty(u&cRjIE71TD-!9iC zD?cuYutv8am*K(QNuYKc<2*;7T4&-Svg1#K! zjJM1-tO+`0E!a0e6Or{yCrgeegs4wE_G9D$l%BMhIa%cAUeXQ^1hid$H-RC*14%|X z=9Po^j+}h?m@Y?7sf!{Jf<9rWh)+P2ZRAP$6L~;kFKS&{Lt!s0r5|7q6CPVyFu)={ zOe4+2@T~lO=|QAY>P0tijQG|@HyvuyT_><%us3#L6TXN0S>*u+=_tqt*n0So70?2_ z!jSG*ImK3BU&(@4FWCx^B0E=7WtbESP_*FTvV? ztF;4KF6+FYngJgN$44Uw137^j%xPEF==0Uk5U;Vieg&m4YCJa*2c_>TL!z|bZGr+9 zHgDbP*=1YS8w2(Y#h=uTe}hiHn)%(e-U#Ml*bj%^9|8N0e>($3Jq`i*uXoX_@6_o0 zH+>K&B7^D-6J~B*g%qWRb8v5H#wo5m_}4^&)yl_0!i68>`$X6z7&9360RE0u;@th+ z*v7bkgyivK1CXCsjlUWo$}z<_tUB&gQA$5X6}w}lU|h$hEn@BL zctYnNYzFB4Ve|*RKO_L|%Or4!%fEE}QPg-Oj0P}gkoI7_9aaG&hNHd({dY``%rVr( zXUHh7Q%zQqGlz9fCCoG1%yGii+$o^Bvj9#*TIoDs9Xh7V&K+xV9xFYd5#+@wk>6B1 z2XSrtFe*Mb(eZ%AMy!ZVM2mxePR2!Y7GWbS5+v%riS!0a3t^Zu;=-vh4>=k`JRUGO zzI7WIs;pnb?RHW;HZg!-*32LN?!W!FWA~b0{Nfk%%{SjvqDV=;-61qb-e15!of4H7 z1+R7m6)kOKYYRD^2s)0}&jm9Lt)@^P(5dINQd2(Rg%jN6mY!QpRTlp;ymM zG@k>~7M+KQ7fJ=_8BtT?5}S+txxs(20Qm3HUT5ULPcjBfRZ^J;kHK_ta7guoing+7 zLbsg8x8G+VUUo$Ijqi-mz(lE-Vb0Kzo4rJ1PDV~vT?=&VDra{91b|-G$#vR!3`)?0 zQ3Sx>07NKN+{OaJ01ifd6s4n$U${`CTO972g5w}By|lXHTS`s8CG`IL0_L8YJbUD{ zO924gPSBbm7dVsOoeHzzi_N$PH69&NbT9$z0el_x-P7GI>i`cm4z$bw07(=8);6_Z zZH)?jp`xfdx$nT0Rj`ok=pL#0)rM^e=+Q#|NuD+8o98~&! z08+R8j_KgK`;b03uy3OJs*bT40xR!_0EtLxHSe`CR$I7M{slDjI_F3R6zARPDVmgA zhmKlo(N6Wwnkkmn+5cQYK0qqyf+m5*M808*l*mwV>~y-p65<; z8tWf51^jn62WIKUkM6z~d+Yo&bB-TU0#Z-g4o+Q3%mY{T26R?^4^`LKx0vf@zVY?0 zYn*eF#V5XgbRs{04nOs`zx^%A+*vUI&Nt_!eD zC4!El;g2XCP)I%WC;KO~Jb%dg?i7|1=;kd-}1T+Z*6! z(EV~d|MSEk?o-iM+=v`c=4-mC1QjM@%4R6Lr3HH%jomvkZv3J1y-a54f&wIg3JtJ` z4l}yd=zyA-z1s>t@d)YP?a6jpmT@zApkfA?qyPiLD{lg9Gr$?ZKTHBq$T85U0LD3<)Gwft3#|OTq_iD=6aah? zzyYWLnBsSig06lHyNUtejsC3e9PjKvx+^>Yd>#rj1BDQ<7+}!m-1_l5csU$8<37aH zIrBqhIULx;1@eY&nn~{%9U+q;EGnfsRRh+Fqf|zTKk|MARCq%_*XnWRiHgfO8cK)ID$1ykWYYJo?P88j&Znn&v0#wXO=1a|!#P6tzg@%$bLG-y>odzl}3kAOUPoX;hp8#(8agV9r1 zd)F=VHT{Qr7lISbwt6$3sWLP)GQ6F-wiag$Jiohl*8D8FuhJ$sfa3GAJ(mgjSl;uq zyq6>a(6)K=Z`LQ6xwpGaaf(KGo~U^fZl1s$u9{Sj+*Uai*>rI( zX2>7R)tvQoerc*^cp?*G*o}U87rS@cshBOAZLnM*Ih)YnXq}eBE)~Nvq=y{%E?v*u zf5Ar^o!vQIz85`$cY1vv`M4nb#;tH&|P$yP>MP z*h7=een$!x0^rUbDezbDe{PKO(0PZ!p1z+2)-DHL^U`GB7>#WJEnjhcdF6|)Qw+2y!S$o0)&Zj+!$E`==t{hfpz0I+Wy;~#h?rT zecgf4{wuxzmigUg?ZLC|mn1L1y4biE7rxu;?tC0E6*7wis^5Ys<2p~ho?~-y)v;^3 z-q)let4jawiaM^40$&Sv?lFd;^+$2&vlN9iNkV>)q5#f=JAU_VtRKLzi#r4*USsgk z9k&S?IyPLhrx8UJ?IgdVkRVAFG$aJ!1fBiyJ$WTi7sjOp9`v+JTNdiMiDmgcryKVpiaN zNqjU<_n%`w@H}n3-b}=%Bsgt_HaS9=i#Zw{F@1xjhw}_fLGCXpwCU!&j3Gf!Om3Wt z&o!gcxe6nx^+>Y_(62;~>FGP=?e)_UTN6+)HtID)qUhfwB@79B5VE58n(-mIkU-o! z)r$weU(qL-z@@2?tbxkMa>;1RGy97e8t?z(tKN&&B8% zw8pxMTAGMh_F|5{qpnH_RIH&ugy9y+)V>>V5fO28?B`e~d7c^gr-kADiHXxp;dspJ zMgmDr>~l8lkjHh)$8I~1DJkb(L!3X4&EgHf9mkrpZA_bMjGz=C&wom zG3P-KBmqZO65zG^RRI9+HD&u9=yqE6W^S)b4lpKbh5vkjz*|aRUg^F7xa~ATZ)c41 z(Cxo4uK8D1f4?nvUN3!R&G(EhzqG{w^Zb=Hd0j%D~V9 zigO<>s-g*G26)}Up?N642Y|QKet`eY+5o?mWkIY~ju~Ko4?t{xkAj2xH&{CN)fLxz zC1GqIa}kXz496;EYqS&pqK?IVL^{I-x8yCg^(0g6nB&}`0}j9sy8FkEAA-b)Puc~? zSl(I#JazWJXVN0j?SK5_v5CHuBA1CkJXEQL0)P3>{T&CC_!ad@&GOuJqTVne$hPMT zOE59x@a9!l5@U#h9n1@V_(633B5T0UD0rOP6oW$J3y*Z);ALPE44&$KgGGX3k+Ou` z_~yu>lPK*S#F?DBuISUujtKWjK{<5_c*L}q2a4O*$cUOV)nREodbx*|U*wt)^Zv|n zd0bTESZX_G8s8NQx)F93_(mkl@M!Sgo61A|5$tuK?Y-fMDILb@UsyH3qjEuNB-nG% zvmgrd2D>B-_GPsX2>R95My58wt?*%5pEuq*sA_+Z-+z*u;6>~rmNf-$f-Q9nfX~ni z$j5$>UvFJkp(x9G116=Ff6!m{r$N8Bfm(1mu%uddnpzoTO83jl>R46{`Cd$yx*T=H)# zz(n@E(@O(TX-Ck3Zy4EapVVRkog|>E^FMq+*G>#JKyO!+W&Hh=#oz#2kazF%&;yFS zKJagRm(&3G1gQ4j76H_K-#Nz1@2=#yDBFRXS?k?mB}3oecx}GX=L67h0_XO%ckBlP z0JMIm>qim5{hgB@6p_fnn75F7&ao0&I%EnX$vU*=v}~|X+fa%;J0e6zuBhTXAxdFt zVAX}6QJ}$?J#~?HLh%RHA9{bVTHp(@vjE;1`~d(12qPX750$#yH{f4Q@Tf9)bcLd% zX%}UH+<^sbf9!3fp--E@_F#-kkqYA$`}w`-{J(qkts4HJcz0zz95ALJU3e@74gi0R z`ICJ`f$UIaJ{6OP%80j9EGhg)u)v#ruH&pa^!yp!%(F)xFFBv{jnK8<&@nlGY{6`w zMMdJ;kA5`UhHyC4L+KAES@5~aW%fm0?kHz*XF6UDHjrmrg88s=Z*12@(EWqWe=0Y` ztFzUnX5jMEpZ=78^PAt4z5pN<<*nRM&*Ub1EEB~ic~2<-(4}D#NF}wRLhAl!`p~x~ z@?I(>d6qgCV?{TeRD5c7&E3i5xwWgj$4o8m)XEPBfAi?;$L`}$L&P4ET`z&6-}q}x zL?VwZNX&kCdG6Ct9c%6bkd43Uys~q=Ifi#O#)?fQyhPS8-44`&*YQ zsBs^<`v;o<(66kwKu`ccA?CJ;D|E#Sm=;!k1Yyi$>7@oHhR%Gib@l<2Rm>fj)U|+X zBC?OhUj#Waz83Q!IGFQIyK9n>KuA}oRVXh(s*te%v8bOR0#=#7h_D;3j048@sk@ml)_?^iI zUPuviS0wyfeF5Ort5>_nkEKwsdhuB%h)?VF!HK-p6}CT=0dUHqpW_!*lc(TML>MC$ z?ji?N4(2XO4HvaUgy@+~kaCFXTbO&B=ngoH1JF$VZZ6F?E>MWxmAK7&Pi#MrqD=L8 zaQ4R4Wp@__SeA1_2a=8otcp}^?`P^>802>Fm#A>^2(Zt&CNh1aJPwd~1c8S$qc*a~ zBbj|X^tEPx6fQvfvw{#_hi5%B~VnycKF2egM|?K3QYRy*}p z-xsm~98=)Hn&AOh!yFIY_U+Zyxc56Ze{1Te0I=M7u#~zc!;qYcO79E=SH?il#kH-C zsh~4AT&Vp1hG`-z#$r?E7($2{_krG|P)S56XH*pUc zoH~-TXh`QjSN6?3%J?qdRfm)FC{YbTX1*u&=%c5ST)Wo`)FX>a_iWSmg?%#w`d=5s zp=TKR#aLPUVheQm3xr&IY5{z%`Mz-VI;vsk=CHJ>ClpM1xF4`Nmz!#t`hHi5th#Naw!A_flbKJQxO`tsK=2eg2l}gaLSQE=`vFMpFR* zIsh;XxDT}1_%6T#bmGEy60J@?$Utw(5sDdP4XJG9vMe()m~A{FI`8se0Y!cR6zp5A zupV8Xa%c2(%?Bw|(TB$IMA3b8i2?nJm!UKkPQUEx%=659o@1uK& zfV^YbFUAZK)cRfYF42y`8+4!UDDH9~$?#)@DoQS+2!)lZ6sWL&xDMaGdsDpr;kCy5 zq3aJ5K=*}xLYH6l{?A`3_5U#<46SUZu&Dul9N6eW3ZOY>o5H1+u{mbfj>O8NirFJ* z|Fqm~pC+#LI6{fY-(1S~+?fgd=Q~_9EX!^Ls2}pWc8uUnlphT67hr1w7%db8GCXdk zyxKw}g|8!jb8K+!dv2Bq_7|C&a9K4QiQ>b&IeAjOSsC=a+4%9*)d*nB$v?O9QRfBd z|B?a#CciNMq6>)4-~R32u4E!AZ4qGmt6%-<%RQnm0{JhnrH*=-ritJzdZ~$C1B*n$Egko<$M}=I?$^xPzr`nuoA-wE(Ej)6fw0WY zc)4TPj0kk`Zv{HxrU-y;K0p!|20#HY08l_@4A2Dt2Ffuy<)B`}ZT-cUm&F4C80ez= z4)UwycDzxWHA+Uc@}m z0pDiSVi6i1XQ%_~g#{|chcgVMUe_W7eddMVZeNVTIPu4vd;!Tlj1=R_OHly)mFjQ7 z|L4!f(cg6ZPgT}|zr*-LG3zeCzjXK2acl~k5H^!Q0?0h?7C3KBoI@ff6 zpWXxG-oHmgo%f!R*Rhwg#pc*}Rj>b`_W2W5J!?wdE~v`{ll{b7FE+PK`Wf{96M5BV z(e9s!BZ@3*?zaj6-=F~Shky8oD*^uOKKGk%zB&Eb&wh3)pK~Ga1?j(!M+%On7By^G zO2{{*2$!*XE~yWwS3Igi)J>mho)eCE4>)=R<=<3m#-#;wUq(ccqQ_FIYmu6GYqdve zi>7ikhyyMx&VYYBAP$Z>hfg<9i$w#?h$aTKR|^=5THd?ovm@A2-bfIz_vjw}wyAADO#Wv7>0AawIshp6008)-5CG~mDA=C`e0}_6T-IMAPyDIT z7Xw_u#U3+>r_ozq00(NR!3@YT5KI~Gb!8cc^Ij{akScmLg}2Y3K;Wk7>a(d9UsPu{ zB%A+5*R7=XPmUc6A0?#8M#F%AI~|C|(4466UdD}eS9O0VgYK~I+p6%g2$yQ$;;)>O zKV&F~ZA8%tF?L^+?k^8S`pLLMw;#{Jj{>HZ>Su9yXks7%{6*(~U%#zTxr_6@@I@+1SEVY0s(qiB>akqwPF2UtopX3ktye}Kg5Npt@Amhzcp9MF|4w8% zaQg@I!F}PHgT5bt9|gRJQUn0q-|Y9@%G5Aq8-xl~s)g$nJXqq%{bIGi+$)bk*g5+Z z(M+QZ(>!V!Uvfu)LnFF1xKN2Rx1EatNqOzPqP{t&C*3YA%+YW|pY&MZ4!&_S7P6IU z5HX!=tUsFf!6mx?E?p-5cfw+cj7l{ z+tMh-Kjs0DcZ1^Q8kOxOOwOoZrzS$zpFhYy-V|$oFLTwG@~h3R`PO%S_<=XCU%v)l z(myH1fXl!6o4+}e6Ml|Fma^nt4MvTIQM95I!qJ#u83vqOj*<{*Fweb4i;Vg@_FDsG zaE`d+j>tXGx=y7m#>JU&f{Z~ORH|$}SI6pzGduq@E|Mn6t?Mc@j6fr;t^lWxKX)C& zrK9INGHkO5U(^)eaj^%%&Ao7UGfm1g<8mDRGxlJuIBRIKB!^op-$C!JPxh8f! zIYa8SmxPWHor2I{b`@xgUD4VBWPl{-`|k;mdLr)V=q4gD0HEo+?_Qbi>z5PXfdxRj zkSIOXsgu#StDaf`f7y!58?%pHe;X?xI8e#de%?C_hoHLF;UCs9XYMgzkkEGGlZRqP zjZP&lsXP&k*FmX16iB52(?Qb=?88MHf(hUkr?P+0`G58G*P``*Argm&vd>4t2sreG zj?x$#_y<-06Me;Kjx@h=_MJO@9k`e=MJiF`rjF)COEaIb z_T@zA^`J&U-+Dh83(0jUGk32)rpy&%Ue_)pPct*_Dol?m*e8lQeWz$Z5um+1rKaGw z3bkv^c|lvgt1ZIJ-P|7Q2}qQIi+JEf=+-=@@B%EJo`qzzmpl|%hQXC8Up}L z|DXT-=e&_gXjS~;7x;~TQYN8`)#~6vHslg{g766_E9xP24xr36BhSE}$-Pgs?E!d< z{E7Qm0At<8$J5C7NVn&OY5#mf;_UW{cJ_FhIWuLGc>1AR#6p;E@VVp#wwFg4AdX=H z@C}Iu8U_AqZfkXiV%M2>o&W$1Fbi9uiz*#dfGBnQFH7r?ztxT`!2cHR`uaQb9Ua_< z@;4eQxLJEQes~J3R-B~nFG97Xc%QK>IpZ@pSVT?t;GnRG7}oGPz7(Ux>Zeb?KM%Wh zxXJ8_iiYPyos#A{?P}&{9sLU!O2Tb_YZ4kDF*#8ue*^wajNd0IFrE5{Mm)5DApZg6 z!s-4I9R01EKeYZz^OrGu$3`i-aa{&71541X-_mJ$F^`%FIypo^mANFk{-W~_V>mwu z-5=~5bp6r&2Ll2r9WHVj`}y?Q^Wv+ozE&0vjscDbj4i`XWLa+gm=6sGC$FV4pP`Ie z^-_#=-e+3gs09cwye8uOr0+d#FW(^DJkgIyygAmMqFSsrr$o-tL~ygE7hlIU{01BB zHqNV~a#t7)_Y^j`HMA|&pSg@l_6>o|^_=DmX zs0aj4$ihbM#w;=b`XX=gMYLM?iXa8RO@wBkRq04EQgKJMom#(TqR9FoK9-p|H%4uY zPBtiZgv{g!(Q(}UMXOI-r>UhVh&j<{+=-dy?wCe8H=W*q5HqMR|4l09fyFw@yvy+A^_JpWI1(D zE_g5CzZr!$<@|*2=~w_Rnhp<_nG5iRSHc|d2hkvd34oLU$O8c2(47S}`RUUq3d9ht zhAu3?n9`6}M&s-Nppym+@dwC}3nVX{1f!sXxtHzc%zfrL+)oiEO3za?*)5m*!$*6M zNt=@DB@+6*itjV=bt_LjhwdDD^(#iS;+2o^L#G=Z{c}tCM*#rxlF!=t|EzI$0LFM9 zj3Ln7QJhkPKcV%bKm}vJ2ajy${|Mvlj=tvL;80}-4*VA|zhsJbDy#Kj2Yq=^nn^Hz zIAq?qxXP84jIx>!YP_F-`!6S7l$9pLNfB}GO3dhDVCvZLC`a+E1GbsSzf#L_Q zHF$BL@aEDZ9J?Wu((r%^XwI6j8pr8!Nt;;LGr7$bHbu`PO%gk(};@qUJI#HQ8|NO-WnBnm!nz#Eu7xjM`A1fZg>Zt!2wc=^GE7B4N zn2U(hOfyX&lmeKvd!{rqtY$Ds`ErJ~hdx&~S4wJM$nOX@M0DXRQF^_Q>*iQ)$DdZb zJ}&QGoxlEnU$+VX|MD;Y!kas9-?oO{c6;k$b6ARbK=D!j>f`Cz**#2Bb|JMYsu%-j z2~dYUP2pfVVv?F>jj^M|9f91l47$Y0;?_2D@{ZO)`c1~~aVI^AlXo)Ct`ZMFHoQRR ze{w!~YO%`mINuN6N1iFduVeeWb#^>1O@Rj<-y-F)k)ut_C=d)dKRIxv<+Cw|XAg6- zm6o&ziIj7>m^(UegaIbUmVX?zXX%U+O&sBzu6GRR<^g3Ma0}V~6!6=k0KgV# z#?V=#%XnYF{rBI0rvL>24v}PMwgB+qJ+jB27cYd8yt9#k8o#%4ppc`+cR9WQ!91E( z4C4H{ zN47mcKOWB6-$dt?o`K-I;C9=Zk%@V8O6J_bMJ?J7Uvd>xgxa9mgRZ|8&!p(_{{1`c z_yhbq;15QCV-IN8Ux5E}q5VGx<^PcZ{i7z4&< z)8iNWPbcW|Lf`?CfC1Yyy?`Ze5YMBFVmdl+F4eiyg60=A*z`Wo4-mJVO68 zFz?Tqo_RVjUfLo5n%MnZEWoql<%$<$-;ZauW}lr>UQ8DaP~#;FRH@hq5H8|#Qd zotgGeL?hQ}@kK@m%EQ;eWbKi5V2!)Fg09gjGF&dDs&7#S1A zt?&4sS~q)i_yKUCX~)m_{e^VE9mU@rAB-<_{b5G=P{8hE@uYYRy1%&kn<&5A2JkmJ zN-9_Bh=goLRq;@ER^PFwsu0oy;f?`=-{ZKT@B#1-hyOPc?e|V-|0s-{TdxDhez7DQS+1pH^* z&}PY{@2cc?>D6cG{$~T|T-`k9Y3;KoTwb`y!NuP6gFMIF8R?)?FZp4BIa5dhHj!N2ncP}g?r4X_a~Vt%)_wCp=-Qig;8Ck1#@z7b0E zg?4uVYyjB68h{)79Z45}M*yPORHF@ZHhTn=4G?6&n@1VZAx0MA>13T*7IeJFVkz@9 z-F4E>pyTFA4Gn*lfIyKHg&Fn~BVSOmpxSHv@!dx+Rz-|3>T$MW zPNdao5)Z7|=BkQ11S8=J$K%`_4;0QIrTHk7|949Hf2Yg{7$RaDah>280Q}<`0_`82 ze-r}127t_=P&Vq7-}hETHqj*W-0Lb}*RFEntResxH^S{`f=K}veRS<5D(5^;;M|B! zN{_ZNF@1yX=X_J#2=-HmnJ~8nr=ss(LM_7Vz@kP28ZvihTu{$iI4l~FXJ1KC0FFe^ zmVJ>M^ry|`o#Vg%`@g)1Ev0|@)1UH2Ca%_AJl||~Z@1f1qRo3TI1(qp;;H=luH0}p zvOrp^(`{Kqm-~uG}&F!0V3*eNfyOmoxP!VuM9YMYybgW0Au*NEkF&x1e#?Sm3@+EK&qHF^|a=mD-2|Iy{xz7rUBm@mffO|O2zmtimiQfQuK!(4iDg5XIY>+R8(tl<({^;(5 z0pK3W4w2-Dtp)x7vHfR5k|9v0d2d0TB3YenN9r^byF#tqtUZu0=*1Q$QI<`wj z+@9YCcK$;Le`n+ujzM5n6t`B^hd_Naq&V_LJN_Sq0Ra|-arVc*9TUP7-PYROht?n0 z38?Cb<;_gZfWtMArWapp9%$+r7Lg>#mY(E&yg+(OwxyRHz7 zK5-Eb2(KCIAUkM*A9aG73H;~A+T?1N^PZbq+Q0*jDRaN(fg9@qDkhpa-7xT-zE}5h(3oN9W*pr=6!+iXR|@xu_yZureW#$F^*vAsz zI?&?5Hg0Cw%o3+A5I>go&#CLCDh`ykaD@Wag&YGE$H10>Yz9<+cxW8UvBL3l%p2@4 zKt7}n&!p4;WqMHI>?U|{Kw{ao!?%0=`ZeKUURi+p z&l@qR;a~jX7aYm}q$10R0+bu~iTv|e-W_s)OH(_sCOGAoTN@4Mn$nOQmSLcFba+>% z3F*iM*rIVFyf@j>ER?Pr;ytiWT?Ow|*UkH*uV=6I$1zQ2r~ACdQfTpHRZw{FFjT; zKU9;<4_X{Uq3pew{XzSWeR5xr)1cG;SoHl*1^7ef|LCzv)xdMkFGGtd_Pw!-dViq# zqzyO2w}y-B5|B*}+PyyQ+l>)EbR`2U$$-BJ;QuB`78*=K@4h$xeo@D{qO`GZF0!KG z!+vELUgVn3I*&n8Na+3-XCehS-R*9jg@5|TfBeV#S}_B38(=&DnqQ*9D&#z|9XT4M4Z|KFkI09%@E4 zkjjr@4mA8&{|0M39+IzWS z%AK8(ckpn$#$)QJtB=>7`#22L9>Cb5o&=1Hb#J zT!K0C2v3}pfo&cOWoc8s@&1Zj1djT%O8fTgMlDZ@ZC&OzSiV_%G$geSWdqoyg?$ zSw86m3;@ysfT_Ok0g(-u`tAmLz21aOAx)^a2sYM=)n}MGFyw&CS!w>wtZPm zVD7Lp9ChOzK(p-GKwPMN17s`#> znlUD4qVqE46h(M}1PvNGQ4YBAWyl_SlK3Wp?qZ^|GiQ4P5@}=?gjt&j6jE{6RGWVk_A1Ex_qz^@PCB3vgb+#hc^IZ0LCNWlL8_X1U`%I z8Ifn8em@Y(H#(n>Trog8!sq}4C`vg%wR;;{SCM_LTiswi@Bo<=rL?JCv6kF(f8wEI ziU&nRax&+YqgF63J3cG|taxRCzlrigM`Y&#|CI&zmuf@@Fn?}!{HHMQJ8`srV`8W7 z{;KD{&?3Rk7M}oy(Y-%JmtUD82Jqh#kbh58B#z9vmEYlY7(1Z01LV4b5-9$z!)@3I zrchy#w+HMY2;i=HhI8(Z90!d-frcBpNDkQ<_E@4=0=hp~5k~JXi3j#OjvcxF(>eVMv$o}F^+*usSx23x&RK0R~nNGV2a zs;3SgjZE-7##?5{y*KY1$e($BSAk=?C z_kY)00QjRn`lHrI{pR`e@@H?}oC@0iDDV1SCa%x&(U->i#n2ZC6PP9v>V0W&SD$^} zVX+j)grkY9N%yx2(!|D6ltwx0-)!O&<;u1?4gl{n;w87BFxGy4uX2XW1DyLV+0q%l z@kG7ra&{iQz0_fRM5Ce)7CjS7(Ei=++C`%o3ccyPB3cbbVd%7@Q;zSLa|jR4kKf6C zjq5IlF@e~gs@rU~`L-Ig88bKNxu3xB?8!&t4{&5C-S4FfipW5KM}Q%K&a<;~?I43X ze*b~U0`A+Kcg=b~Qo!i;an;aCI&kZNV=6m2H)?R1B&yJfJ{&LZ_;GLWZu@VVrl)mN zx@MeXGeXHd@674Y>30$h08|D40RHkghkL&Of1IOA(|@I`0q;I9+k=D%9h2(de`H=9Mo3bncms|;n!Mw)=J zjN^A=y)RJoQiDt}{KMGBvBUA!0*UND3=Prw2k3t;-WboGJ=bEuZAbSn%=_bd-|gDI z115GmU+yn|`Mradkr zn18GcSm1GHye}O8<*PJ!8hW%(vIigKU&pI8U;Olsf7<&_8|n6QQ-}G>|M(wwzxvg$ zj#o!VuWvIymizxZVb`IeKpK@KAFfr~rYpkm!No)4`#OFzVtqqd&vBB=Or44j9t#9H z{w}<@Hfv@u!3nzEfbE!j&f9Adf|rip%S#X8#ep$TtR&IMbGZjSCPlqYM;$oMx$zHr ze_mi6!Lzin4kh)IgEZg(v4r~mcpNDBM7Pu&zoG_$Il>fh$GmMw6>!RHjEd&;%{_X77)_hn z?2aHaKJ09Vc+;~LDC~S&Zv?lg0E*C^Z+9BYx4qgLknaAiy78?&-rIoYf2H02OWVme z1%PvnaYO;Yb^4Xok7AU@G8XncZ>%ST0sc52IL~*F44wauJ&*Vfu&dIJJGX7u|4=*r zj@Ivr5qR_6&M*_W|IV8ss;>o#SlapjWV-(9 z@c+q2WMW^j@AqulfyNuc`v43Wy^bF!D?;i1WK1!)<+heI;21d-5s!w?*9%Om$s03I zn6u*TsR=Q;Tr-oUan`g+^Ub9^m3UHzeQpKuoD7HmalWz{mJ1daGTz%O0$L!Rnwrye z^?--_?sl#hYqr)DFI2Wj1Cb<~M3KT73QEVK5Sf1QL7sV!0)YH{DZYNSH~Obo0pK^k z`HctgGEtGa2d~OM|MNdT7jwYx|CgWr?A|TnALLVyVg2W*nT7XT&<(0l)k1D zwE2!mh$N|kD!E{A2)SBvBorLnXZXJ+p@Pk*WG@J zg8fkW0mtc)!)L5%?#pwma(JxiVD=hkT7}qLv#!HIA*zlQ&PVtUtusp9kH?D*)#!1^5@XlYbd#_AdbR z(d9=hpLF=)$Dq$Ez&Bd|%iwWk-TSc%IEMi3DCQsq!bJ1kGM#MoZMdtyyM5=5e-7tR zY3v7v-jAXUel~jfB3hg=09W>q$tMxiB7~?JCq&Z|88{N_SG%u4=U?|#K&=9W6###X zW9{}!*B`(i`u;B`U$h{AbD-}a#}DiWWyVOyAJKkbx_GI}-8*{B^jft=7PdQHV?6Yv z!_Aj|!5!4a89B?Tr2_}(fCd7tn8-9je&wujLPh_Z$NWL&>GboQAe(c&pNj0{_gTR| zEn&8srIVjQ$}nF+W)x#4hskH==tKjb;`LP1Mb|&Bu&5&1+LXmjacbfcsueA(ctg|s zB=>)gdww(*V_apga=~4kSA66Z!K)AS{{qeW@)Pa2rT@S8FaOdykeeU@fW#ee?Id=E zrUd-*@~{8;uiqR#c<@fL|L_49vO9NLBefl5*^}ek<25p|^ulpVBN|Q-w2hu?VXjVBuK#Om__f!+4*nH&*7R|p0D`2xZQtlX%OA!Dx>Gv9o_EuxjpqB<{0`Nrl`Gk(u0MK`qQ2?-@A3g#1MJo+Pfs+d$l>o;j zWKlSfSvO2T$#$jfexQ4PaamFL;j@78 zf#Y~)AI8YfJWvpDoqg@@TXuk<;O|;a_u9D@ZwI5{zh_PR?s}U49T+$&@IO@0@5Uez zwag=TW$8^Ln*v~t==_|KBnCali%#5!L;;i6hU*O z0bMRJmlvR`^W5@)rP1}&&OcudRlWm*?OnpL6!=ec$_K(m3cx*E&ut>+9_ZT2f8%8D zlbXiXTy)1&n?I^%W>cHcjYK+&Ar+vg$~*b-L|P#4B%BR6N){V)Fc zpZ}TK6tUGY-cUaJvkavZ(b7G_Lc}`Ol3D<`q6RY$a2A~`NzAR0^E~NZH(M{Z$L-y# zU%?DC`hC*=dEuzPIVMED5o3?D?*Q}1QF3oA*<7E-KZrJTyn!DoFlf z_46*G4X)z=CFrsr1)6yW%)>~x!7kzh@`gh#edv-N(!H?{8Xc(J;yXbs+#T(no@#2q zg;K1Q1tDN{Yt7a09Eu?*7Ti7hAfWW9#t=ZRyy-ZvwL`A%@zQ-=udFYRd|$6Do!b@) z+VTkUz6K_OFo-eoK)uVniW9Pfz@H&l#2AKxiMR97hW-(pKbL^uqxs~qHHip%d@ij! zKY;$JDFU29&wqBVzf-e50DdbC*ww~_Sp?(Q9N2S(^JZfEKa59Y2(fdmyNpbOwrpwLsXv12!n`XPyMcEw`dW%wxeN!bQRL6enyb?4K;X=nD zsQyU0K=BHOmiRZWL-*yz^gZeHjYHFZ?EpOW{VD#w{GzJI48bhUi1S;d=Q0zu_=&%yZhXFy#r*Vi|IbdCJAKEb6wc zI>S6aScISmU<_{|EGtgr`FBzlc(W>3A1}A7RsrC}ix+JMK;HrU=9_OW|J}d)lM~*o z-pN1TNY(whOw_kZ6NQ6~TPu$&Wi!ULX(j3pP-mOHfI^yQzI6@5<7^d6cUnd z0Wm{a=6+2d7xQ-h=hj|@hrpgNxc(9u0LJ@6oK5A_ZJEge5w*LmkW@8aF`DC#lM8gcnpI{lBVPX`SDz?{HwbW9aJC}o(qOxm%Eu|KlbcRW9c z9~XDqywR1ev@*PfOeR3pX!!E|n>ZVcDqpXDKaTNYhO{PBg{l19U z-1iUWhwL>&n&Q1kfg|F=<@@{c!RD-aTz>k~pLQ1j{^_6E zHUTga_?y4^n{&Bwe~<(7S~l*5RNG(3qT;}A@5)s~tO)}}@uC6Xl*BE}iMmYV?|86T z1=VgZjjTxSC<0KA13+4W#R;d{9}oEN+a~TPpNs}KQ83xgJ5I*tq>2PR2ArdEWouxU zXVus9fC!mfEqA`@;DFkFMkG;tKe%`P=V%P_ROw4#A(^nxp>q%LkMBk%ac-#nh~V3r&u}qurgOb3 zV)RUx9&sxumiXg#+Z;D^rwz5=m@k^n{@NN3T4o1TObX+MZ`|rB0N)%-VxX!dPIfiN zb1$5^39N=gcn_4>lU( z_wvK{=fe#o{uhXx3+IG{oNZ`PXa0S>nT&u@?2PPnMQ+Ssd;#5v4~!~m z9In+$SqdO>0tpZmY3$maRT^a;n9 llJ_DCu9yeP0ZQRf+7dI7+j3bLj0m!8(`iU30L7O(&qjy8^5=f71(HMW&aWN*+s?G_wiW<1fx*)HkMJ6s zM^AxUf6ctFWG_Q$=tGnA8j58C@v`zRZ{X)>^_In9WD2E^+q=55g&MtvE)CO0#@(@W z{MFT89?<=pSikcC_#bG&WQ7W(WTZ_>QH$fI8?HXnrle{0MrJs zuKi@Kop(qW4i3GJ8fJe!q7DxM{#p=Fr}cB=Sijpk-yL62P?vjoz6^>gc<+q`_6q77 z^nS85@Qnq{3Z5N3oGPh6Q_JtE{bgAL%Q(*ABb0fRHoDKy_f7ZTafqGqoRbDrKz?oa zWv#`X-NwWYyD@QKnGL>pVIQUtBFFT0)%Euh2`i5{R7OBO#?iR;lQ#(55qUj7{xf#S zsc1!V=}t`z)?~x5ni#ScYt-&$Il!+h4(kD73<}LLzAT-#-?;amNh-w`7%5sN3QQ{j z`cLKekfERegTKe|fhi)cO+)j4XbdXvo2b7d`3~@p<8O*Zr71L(( z^Zv~H!T`S72;vCqF8jPtd+n5>pEvaU74Q!Lw(Gu!Pt-#)DR$T>d1-NM?Z!pNmU3;C zO*NUFe4WTY-pQQ#{pDu;!~gSFe|75q?oa>pPd8%jze}CCS^?l6{^1{5FF;DbuYdi2 zU&u{fi~vjV0br2=&~tg}fj@xN(W`-W>c##XWoI=1j&qH|y610hJF|UEhlU?S`p*DjX%ix@ek`nYh?g8UnazDPIoqryrnQ!|&@Q|E^ z8E(8?(u6F=1&>Um7*SU$xO>gV9UbQ^{eG@=Vy3vMadf}*DwSu>+~tuS{9KwHb3P-$ zb%CwGPP?%HF6cG_#JLXcYe@!p|Nesl6F8DXLyw{W9*86R$xkke=lonjG(aB}ZNGsO zjeqLmFpLPK$Y&@eev6{O&d-wo&@0WEKU6I?fGB9y4nTtj50(HJ09pXB4)@O0UbES+ zuB=qRbjh#qut3^JE=CqQ)x$*Xg=T@SgG|&NT~_x3Fw}CkCVs9s5WdD02`c8tS_=?* zJObm!g98A1bm?y^Sa)m$d3X9)R}*?HinGWRML4Hc*cM&e5|cMfYQ{j zjI+7_BIer^5`j>hslh3DzRgeXE=+w#a-QzWCJfIdF4?zRql^1 zup09_oKAsD0FUb`vBUpt(ISNqxOQz+4n27QCqQ{j#s?j$q;(_aEM$dolAIDiB`Yp>d+3z6afs7^?<1eqynJ8TkvllRpz}G2W%9c^AkWJ zz!eEXoqaPM;X2N95u_$K{{f}&+uv_2egCdaJpjmbU4GZyM}bJ)_)!3`-F;(vc%Y_- zD{s&j9RFs)*8)OP^|B!;pxW)c(@V_)T85G;M*UN!tWi+%I zn>>~I5OwVDY(dwsDs>H<(9|9Xorxlu)l=kNHUBGjPv9nL6E%hv6)pl;O?SV@?~dj9 z&nwy1LNg$`)V{1HaU@$`( zFbtv5?%7!K&bAhomR{ImHIMOSe+{kGF8~?WXhFiVSuTZw*s-5*FHk z8godCVCcZNU3krB*Hi)hT1mtt;nnK*jq<&8{aO9K)d*;{0vagyjg~5)mGKWd(L62C z*LUbUdp&d_dP_r*AHFhj)S`_nt%Kn_n(qG0R4CS#gM%`^6lkP_JWvnjjjdtJ-)CMv zi}dXn-F@lk%eC*j{`|8*`gIpt7ZYZ)0b$W|`B!FFe>Fh+jqy-`L)UZo{4@cbGYKLK zbgSHexEsrPkJgwC`cpj(gReDn{M$Klt3i>-oDjr}XiUg`Bi(-y`IFz7UrplAb4@z@ za{mY-rGfo3z2?lBGyL=2&0L%}wtnb~T7&%|h6vT`zScGgkjqH4DS}nST;x+eYu9!P zi`~%_rni|?gzScDCYo>kLymm-^g{yRdqtO^9IF$K(3Qe z8P8{p4zEeM3W;3;8B-Fu$I$qrV8HF7R~E22o79OB^2e`;_pt-6L`c$ql`8``_K5cV z5BL@Jsq^RQsmQ`&A^^Yz0Ht~VsuKX@ja^=J^;_Ti)}j2Hg`4i=clrds)lIEVY=|L& z5~~izD6%l^0WqeR7`qcFBZsA|1ljPL@ajWwnIn~{O(sUU93o|N1(4Y-Ms2o-^_1^} zuzA#5_65a~GHHg$#3v&1M=ywhs^C2)u2HqB?V7lXEa)ReIf67*^6zlFR3_9`)zAnk z2N+cwFV=P$IIh0Jp(22pNa{UAbHODUQJv+V2)MPvZYP>L2HwXeQ%2u@^r0j0UPfqdm|(Z)+x@+tH4X9vaIFLFM3BO{2K@2qM?ixLdXc1|20=X@pj%4XFR8-5 z$N#*@f4jVz<2vuehyIB^ubzGOH!+F;a4lLmD42cqt6!Ctgg5}G-~avJ-}~CvzV_N3 ziv?2vW^eO5?ll)>HbCz}aHhH@a5W~E)@@)TzRawRj$ikfD%uN?3U>`*d;R2J%o$+9IDcN#`Hsa6fDqS5cGv9~0!qQFT0g!5y;f z^t2e9?uttSA_-&L4Q~qo+nu$Y6al0=E-?AG-g>J^dc`_U9+C{VzVv81m%USs#f5he z0BZzXZop>P;zr)P-Q6|+ToI{|G@8jlZxiBqlOXTzwh6AqUGEb7Hu*nn&wtr=`E}9b zu={)S(1ikd7)22^LYFWT;sK>G@1gpO&Q)M$>I-;ihk>V0o(2JV1(bpOsMYPo4FX*u z;vl+)BWC{;5QVJM)L3sPf=@NlM?9T2X!hSmG7aGpIagdld<1K}yP@%$fAE+5as--_xV6d?1 z6*gf>KU!)igh68hDxfW29%`4w5f|(2F^FetCGlqsdM_0&Cyo)BiTYHwePf8Z&Ft@$ zkX@*aVIy6^wsq+d@Wce8)EwBdl-AlzgK7cHUKCm$0#ZvsdIU@;fK&YFp!KwB|Gw8H z|8Md4xA`yEc9$ns?%RFrV;@^xzI+y0B7o+TSUGU^(T{%AGHw0B7rt=wo8SEAW!}5R z4dBLM*Hv*;Gf7u!fMa5!g-PB5q;ha(DI+u-P4a(SUBpIj2l7M;H_hL#K4)?jvS>03 z!{c&|CZQ=H_f9!FI~`1&3|Af#<%$$eg7(9)wHzF5*H&G!J9LB>o%(wV8(;Llm+qzL z0%-pG#dj`pqI1v)p6zui`gdPF(3CVgG(&3k+U!eY1uDsja7eWb<@tikEO}>vLxfd(M4(cz- zf8Y7x z)_Ghg2HDozST~|iy-872jP>|MxdPGkG)wA)@TfYEvsA;{pn!&gyK04n_y^kKXK~_B zKj+6AYBhfsxVm)T`PpZm#lsIjoQD7~kK(!Ko+A#tDA2{Z&ZqbuOE+ES4e164Unlhj zO$GrBw$WbL-+%)7fP)mKv^5^3H`$HLbh!{67n-%PlaKZLH#jqBKZ$>`n6UAYOkBl{g8cCv==J?Ns&c8m!!KQ9FhnbP=DpM4a$A3ng zXws|`>oW#trEHlU6EoT<^HUQ3NwIr|XmsI0fIo~H^$_nPuk#;jOy0ENF{)moI@$>L;E2zc}Sc}4)d(G+zD@j9&levw0m>Q!zR#b`vlNXdj613 zAW214*Q1`!gW#RKVlGK|*QJLVypC&bBD)i;lYU9&mD#+oEtVq3K3oIG92OR+-!4K} zo=s!-4{9UaDwZ4qQ9!#U!_opE1N{PBERh79TAGN6`bpA%`SRsvUW<@lYlC@>ivq1f zguwho2(zbh@RztKh<%@kW1hL^p5|USeb=4pB-3fcO`&m5FlZbt+~1(4==rPKA`cAI z!aAb(NO{3h+SWBIlqu%TQ(@P4JY#2$F)M&F z>3QW5kh!+!QpKi>D{}Y!jVAx=P@4_wzjzVAEU3q{9cf6?5ft=)VstieqK*=P3gIr>J(uj{cckd(jblQWmQ4P; zq4~4!{Wpm1b$;+;)=|ksWGvkYY@8u*TS7`^61H_}UG6jsqB;}$+O5#jH2QhpUB4CU zILKUl{(d(r`U}rr z?xFd-4gGowXP1b|XZpy^u$IN9w~>6Y-=jp0V_JS!bVP+&DXv6-s_19T%XPMx!r$}Z&cwPe1IP(d9B;# zKW%d$MY{XHLf=cFS^+?#@J~sYbFef>qcu|xw{IloNQD<88^6=djxO;b3)qzHVa$>u ztuPrQD`d#A1@J9MT!;K1PmRztCg}b{P0B1nw9}sHQ;FPqvvU=bO?D*a0V-TlP-kI? z0?*?$cZir{7ExYBpC;P$r(jm%sJMivli7+Vo*TC$=(^qTl-sp<+^mBsYn5~nEt8_a zWwAQrWJ!{LN&2r{yV`XBCGi)n01-&L>+ZX_h;XW*5(oskp#|)=MFb6}Uv*Y_Iyr;3 z%F6*|*VgIeT-|0I~H$MwnX7@xZtSQIP%yB-p-Wqd-0Ah)P&{|B9 z;2~2l0i`SK(MX$M43!rN!n`nnMxmwoLWv``dQ|A>j-!w*$|^9fIo}Y$1!Mgglv@PY z6zqTUrh=LnVwpH*Q}5Im%g#{FDx6Z@_Dk0NL}u0ms?5@1A$8imJkhA#24#yQlb!CO z16$budPDO@p!rP0=RBN?0RQQUC$8$}oIQJ%UOIDT=F~_~TmX3b>8FDuv&B0PZx|PN z6M308kkfqVcXE~B9^NSK=Adk*p_NLXfnnc(c;B94aDEf59i(`~3>nGB&D)X$LDPs~ z&l%5>?X`olvRjngLs0Nz#-JbNxYr?qz`G~_!G0zMIF3k@t@5k5RF%-@zQ3uWO$7+G zQ4W5J^Eoy)3I9+4MVm5}Yz(7R@6?-)+yCTpBr&zJK8(bJ#?NtGoXHfO|B#n5B^C_S z7Agw5w?`>pH2NOq{p#GieL~I;5i7fkJw&Q z>qNba2V7Z)0d^nm*t$t;|9tcKj2j0dn^L$)fxwnaVR>Ux4ph^xg8PQhA$&Nwj=byp z3&r+PgbHH@G6b$6Y)Iy4=%BaUe(EVM(!syZ^HPNTWS$DozY+Bl3qL`yh^3$WoqsP> z04WUU&zJcst{5jyo@hGzlI#oQU*@&g_?=+ajGYeZzqKvai(AcjH==%OWzB0HCU+&Z zKIkQ1xha4Dt$ALads|^kDO0q06R`i0A^-xx8m91W$9P_Xk$oQ%>nPXP|0`)a@b~^O z3+M>;wA+d(L}l`ivgT7l1foyp(9?*>Y_(IH^;@Z5I`Masaz%jv(1!EF1EnL;+eOq? zQy(S;W2`W-!2L+_4LN!n!2bN(4&Y2rUTH`y8-$i(!9TjhPq;KcJbcgnh>u^sO#k-F zU#716k7@+KgAYFFy_k7ux6Yh7^9~>6t6V+4laKIzetcZ^3V~Is#+UX`mvT_x3qqOO zG!fx6=&kKJ8Pa9FtP z6c+&Q(spK|{Lfub>2^xOFXCv2oH$+N;(+J`T)uRflcYNvZuE`oH=4w1pZU|0M2Xd) zbVvs)KU;u@jAWUrP%w!bI0hT`)~keoOhRiCRv?VEi&s>X7^SamTsfL1C7G2z(<|+e z%H#cZp;wdWD~^Yj2JND=y}a27R$f(Cxw9|xQlS1f8j-*2O}AgV{_<~=lP~Wfw3nT2 z?)WpzU!eY?;4ewPB>w{WmjajAX6ksXFPh1b93GCVfVZ~P;iPcly%$yz*#_iLyQ-4FLE3wU9x3;&a`?Ua}L+3M)zYFK_|iof(Vq zvnJB5b>?2Q0Ki-V5f3navm$BibJh8P|90_LfAv>sb#QPeALA!@SKH$wor}7%R#Dc7 zg$_+m)5opIR;VnvPl9xStnM_8Ps#wVWZg=^bh@!l+q=;u|I%R<%D|aRXSjH9S8IpHuM<^t>7F(n z)U8Di-?mTz7CHc0Eokyd1Y~lS5GyX_K!(H6vXE#p256;9vC>o$#$Qt8Ti?$OW9K}k zEKXg~#j)wLBfc&sf$HoSS4}fSy7mI`Z!P@f-}{Y4pNLfoykCj{lKeL)fCl#876OF- zZ+e$xGKApdu?O8+8^Dc`rbK+p&Y3eDiKwGY&ymk4PkJNZ79mLsdT z2W+faFwI)OX=A=)sLgVe&jY5_f-;#Ss7+#zU-G}{*kPK^Cq-OwGE63IB!IhI)2KFl zF1xd7v$u`uG;4z_yH>>7hQ4^uxDJ6+R5o3M&?+D_U8oG;>f-Uo&q#K^>;wibeB&G6xSOANfy1I}D{&{7(Oj)` zkVGJ@A&Wr?z^a7I)sYTM8P-n(+8 zg<11M5;IA@L_AQs|ML91I$D6x38bSc|6VLkHKJ*Y#oW*bU?JWv$?G@omBGg+2gaYO zt5JN1egKXRCC`qa)?U91;U4k*CU=PxX;H~Kv zECwq^!{)HJX&mN2n`E~(lTvIF>PchU*LC+r(SNU@rrc~`|F>?oD*jE8pyU6W$0Xc- zDNfye=I*XYb-J17yHfPxV%K)}eP_2VShda>tEQ;D!T{wj;VB~(oTW5HkU=F|tD%4> zd>_RWw0t2-0C_8OT*7A!*C3Q@0c6T+BG2bKu-;G}ULnI~HiarmImV=ttdz%njldmc z*aD`VQr77F*A`lU(Z=Z%u;*Amy+`z0>q2xe;tlss7El@VF@z|$fTs(G05{9;(x;F+ z)N{{!fGHgQI(1J(L~8;8vHv#5cd2(!ea1|)=0Iodddqvt0YBpsKVIWszqne>UZ2kn zuc%jk*((6B1^^ydw*cT?+nXQ#=9eNWnz~sJ*T11Rs_I{CH9qq#(fZxA4nJg+aF0#lRX!H3WkzWI+__ zE4IEWftpqbkn+xF(&Rt2NoTLNR0JnP777cl3u5NgL#Cod{CKmAT~k)WQSs!El_EAT z3^--~1{fFYiTl%2z(#JGRvfVY8SCWVL7g&HiP6@d6v!>Cp9rDQjpk$$pMC_WjU) zdwY`qFyM2F4HDmiU3W?iL#a{Ll??u9D)Y*+d}Rh8A|j+jhI};8uIP6 z?NtxXo~V3{I$be7*=`b*4DQE^W0E4)annEmE~Xp26_!i zAMtdpq-T*zG;^SJP@9CbZ35gp0fYHAA+j(91>y+5Xv1ze*?>oyAO<)#82gH;Tn%52 z3}K10=tN?EV;#ZTlOVaSvu7~`ga<^G~rDPTv}lNjmR2zW~CD= z?LN%4J-3#tWnZik zxW6SXYN2LGttVKHVoTa-rXk)>F0muicVa8S<8op(H!$THDfvtRNAL&gHW^|c!>`+< z{p0JmOis~GpyTP|1PCE~c2qIAf1dv0aM2V}mgCZOBc*zebnAkK({|tryu#)nW2|7Zy4>_HignA7yRgOsJ<6O%C{|U5v5u2kU-FXbt^P`re>m;X*ma=%pqeI3{&U~U zQaIm0OhQzs;gWDifr0cIECFY|qW_%H0JAOm{fP$BFJA(a7O_ArvL=*(EBw%f07;(Y zcY+QOdVnAUK>$oAiY&)W8u~Eg(a>ugGfHzZ5;#42g}0B8Nc{i5Yvp873B7Q=V4J zv^xh_x=?08)D*@YQjs9Jw%9ry9v9Nz<=qo(^ThTT^z`P(O@6C)l!))+@WuP@IsN)0 zk31r@0QI@geQy5PV~?%$+vKSK7Zm`+67Y9__jgwhzVwpyrl0)eC&BhgS5~wTUc&Pt zP^%gi9Nohlxov@C$_7eH;$K1o9O6=42&(p*(~2y2SfrK|TW!d(R1RAb`l9MofMcaG z;mCn18L^)*0W;caf{4nX=#&Ldt5U2U<4}jJ?b6u$$VXwCE<_UkwmH~86$TL@rfX2B zDT7Ox3>#2hK0!HBvCg;Hb=@MUs#8%|doxrhP5wu$Wq_=mJ>Sf%0)Y^au$hwcpMJj@ zTc^CDhTGk23(l-|ejwCOh%TG}qL6Zgs4kU44&Bidm`=866`rqxrd({b+7r#s zovObU^fPFq4GKXAwzEb90G?(8A|j9E&_7^eS;-ei$X6||C=dXJg8U)gR!Jm4Yf&ik zRr|3SltuBLILRAYd$r+@KK8`CR0{bPxT$gNlkF6;t}q!N?=!F8O$Wv`qloH)HA=Wo z&^pO~RCuE2gu~2mA}Fw6zC)uD>#)=#^nTf>{UN{sp|JE$pMwPi2096Vej!Dwqem{~ z-xOw};Q9AR`Bf_cAQZDkPli_7RqtK?%Ud(7-e5D=H+1qZUmVZO9(w2@dg`gCmZc@j ztO9^I0x%6=C4~TGQ2^eatybGtmUQ^)d`5S&Y~U`R$0AiN*dp=htcIqB{byOx5$WC7Ko#(&dAL$b<2|%s zo9^1n?^~GmUeoy(Xth|UiDlp=E(8cqTe|-`v6IKuYwxk>THCE{Y}dqc@UHh7%>WS( zY&x`@yfulN&<}b_fKn}tZ|FK}H-U8A=5yW=>NXVcJ=Mg$M{MAxBrD__dO(!$D8tz1 z>vUu+{RRW(ue<5mZgNQerR#sI>FD2R?E2VE;>KR%Bq2$^B>YkwxYeNn;`aGx&mk0kIe33TCT|U~q zV*(4EjU&rZOrf8K;)zL6UX-I^TM|I5iA19jQBZa4(EFJ>8tzz6eJ&G1_*;|)$n9@Z z#4FI!N(;E>g-~aROU8!P`vFT~zC(Nkl7G0Om+{#PAI^X*oW)QY<4|EP%Y7>nD0D8N znBiUCNiJ}#^VT62@BD}V_nUj>i@AY8_uY4&y!ld1e$5pGQ=LxQ&)Xj%$yt=ZxuYc8n}`LqazsdhPB%GkE)p-b-C8s7uFHlKWG4 zoN7=4Vi_p(fQA~t{B23{q?3F7@LB`)ZiU@idVrt;nxX(FZ&CmdN zq^~UuY>8JcAH%FK3YgJ}mz)}jsdg~|V0Er~5khZ0plLe)x$9n1EeAfrCji+Uc>0D5 zfnm32i9v?UmkoG~BMho}v zApJrO=#z44X(%G#x7FBFa;IM?BPUyUzs!$@I?~ZcPM++b|56m_=p;RggofkR-&?1F zRbc<80S=wD&qlJclyCYvSa_N)G)or9Cvf4!Z7R~P6$2s2&heZQru^EdL;+mSB3j2& zc4Nstjs>khKUuE`xz-+AZ!u&MFRlF$CefViwax~f!$Aoo;_V<( zp`>#b!7Wj%{Le*zrr68EE~KvV!N10TdWA>j{A`Z*rkq!~8UT&3fbLvKfr57=7Ac&G z1;Lds|Mg!Ve0+D8acYZu_`pBGfBt3OT+c|r)deo{UlS1#q7KDyToKpWoazdEFQh>0 zBy0M*O&BEXavkl^K_ggx%oW(bV0ZHaXY5hj0-2N`R1ql3K z5`8HINYW>8JZ<47^Z>CQ+-^kMq_ex*BylG@Uv}y2p6=`g?-2gBBz=Man9my9LjB!J z{#PyUylwizp-&hE0t2pYY6=WDg-<{WxfGP3<44KPL3p-r{fi*cQzx)(XNJu6q%%YA zIs8Upz~}R~g_T)n*C(UWK=3=EJmKv(3I44v$>)c3@AWlpe{R1Ig@CrRFEoGygE!^03mDvznFA1GWwta(8+)EA&)=$=fILr(HvUzCR07`(m+ z`Gp|;3AHmG)A}XgHYrErzeCvnG~q;^eVUI>dUFT&02q^2GB~QK0G)^L|>=n3z zh;6GdtKRUHqIB2a2=d863ABM)XUiwoO8EQT_0!ajMx<}=Mz8tM|6Vx!2Q4Zh`%mA|yHK2Ll=~Ul&$LKE2&j%4OEeTepZLTl-k!}?Z}5S?&L?*d2T3310B45}oS^_T@3;VR`b#4spy|RzY|a8C z`)$|e&QR6uh*~?diqYOQt*j>Es1%_)K0L*V2NkzZN5S^bq>OHSLC4s^9Y%SF)jT_> z6JO4NLT8l@yK05htg#9`JKYbk!4+l%Vmbo|-c!qBMyg`37F#qlC2Kmtn zMc&crM6f2r9c|}d&;UXSkk|C6gpQWb9KUfMjkupl2<#hC6o(SVHv5fR2$L;p-tcZw8!-*`g#UUZ{SS#rQl!3)UURTOCUQ8W<}6p;Vp;@@Lp!MLGSmh z4Y2ygH@aDmuM$g9YvItquEN&Fi5=CME@ypjyr!a#7xJOmkqpGxDk)Re37X zZdEh_iBG9KxezHhFxXOccgn1FP^1h`>sbg*zlS2s?lNw7FYwQZAhByY9NP;p_{P-xLLGX#&?8vA|Xs?naYPHXUPunlr>)5>NTP zpa(ScTm0JvDlR`aobWC=Y!XXWe3x#r=8kLa023`B&N|`%C;O=s4FpEM=ziZ@25FwS zn=B*IBMlS+5Kxb&F?&by2cL|w)*TKSWT6e=J+|6A)Yf~;E~#JkJbg*b`+qOHpZRw! zfB&$9=@07rhs|sBqlNHmNPj~CXl(orn%6HoWqq>%qir2^;xF)itBBL9^|wWU-R^qH zb(81H_pRo-Z}mtIO=oDlok_KHhlci`3=XUxNK(?@+5lgKbW@DIE!*bT^D$0)x0|@D zEY(3HxgZrnyp%N`5aDDFc5o`}Vvtzgfx4myO3mXkMJ+MnhGIL5AR$l%H>&hv@DQq| z#)cQM^F!LiF{Tx7a>b)X6&oU+0029d(|PP!)SF3uJeE2P?eOwi|p0> zLcne9*CM7s8qtbK6)X|2m?bT|3N0R|(3J+py(^27@Qm(*DLOYv#lN!Szi#qxnxp`z z6rIe6O08e4Fpq_@&vDq}{p+YZz8Zp!-ac@LivSBo0NmLm)j|&t5BNP!_QgR!I{cy! zAesSUE2yF54Nc&nO*o~yY<7~H}A|r1O+GMBF@)5 zx}pFl_xm01erY81`dY{s-)gx0w=8kKrkl?XDHOOUfxg@B!uh-F()0DTdO!bLy7`S_ zf1~HH+a~x@BoMU5Y~CSB+JaJ}@6i2QGzbn>2R2p1)NxSok`@={52PR-f%$vQJ|rxOF+J!id+*o?R*?)+D^xJW28!(VLJ9XY)`y7O z&&4Qx7%2i|6=yJMfjsHYv`I%Dgkg|lF$7AQ3M#`B+;UQJRw#p}WgQ=EE`JMNqsUei z;rnOZ3oU$qa0Ar5eWxWrMB|zwq!T*N;eF^%w-eKnxI-+0w%S&+ewmaQgVABUx9#y8 zz0Eu3o2&HdHAv!7iE^ncxDV^K3dJ3Bx)$N>8K*T25Ev$H(6T5x~NK+gYq^)Wi)H((z5#S4oISy$a9FCnVDyG6N zu8S6%nDsHu@KUWM!3N-XYC^|^iRmZn2iTs?0&oF93IW%zx6}anzPG2X;fi z?c*-V>UBmAY&Qw~c9+yInr^Wq{JV{E{E4=kywf@g$aA*3Zgh(n*y@l4TMc4irriYs ziufLIk_s>zq21aF_M+L=U#X8n?gWS zI2bzn`&|)0C;zMEVYC?3B>P>5f8LF0pXi_HVEa4T$w6O_ldapssaDW$r{VN3lqE8` zT(xusqX;92kf0wqjl)kjr;&0@EQC|G!FWGV_txAyu<0{MyOvcd|88!dF4*@dfRuBM z-~H!Jyx-Pv5H}9{Uq28knH#~vad$2Hu>n%WCJHty0ehcvECMIJ8%EYwQ&hOYYaw1T zpg^VxhjF3{APo7Jx8Bl6)}q^1d;HXw`4=y(2yYPf-whqpg$ozxs9XT7FR=quE$m+h zQ8W3OA2%7E@YcWkcfXC#Tp!_7{}a3+ewa7y6Iva6B_zKN0B2bJ)`*2r3RE8H zH5$Mt8lN$gNwgd6A}Mj>P|3oKm}gRd-n8Bt0hFMfix>SW+aSWG()6SAlt-W)KL*p! z$Ya)}^+(l=5#f~80l;euQgvx3z<{yQFP6p_MR=UOh!%w-P82NxNJF7V?g^T> z6EWTU;iznu@|`gqT}SZ+{8s+5Rint5rE@_ zQFH=YiomT_aIT>WTyKy9tzJMYGAD%q)7ov-b*DcmDz ze4G(N1UkQn%ImM!akr8VOy4j73fz(cGzX=MMRdm-T(O2qOErldZz}#aX>JJEc@ibA zGSp~-oHO)@pc6`p1<2sTZ)Q(Df@st#L_mc?u&QH1T?F1M-A4*FYQzA}&XmnM0(u`H z|7~F@^uc0E_vKZJO}Ma3fkB)$hL@><{Q~|Mt;O z@&5TRZ;HReWAMN5uYQral=Ee)a^_gy;~;J#5CNt~3{86+ zD0HhBH6iw3j00y~3+=UVZnL|+bQ9FWgDxRmHhgvM5^(*-wH7HLZUBZ}fC$jty469| zyUw;wY)y`7%ln@ViM;$Q$J7Z2HUffO@;}f5S}MU#hc1v#^?WwBPyqulU=R_s|7LV= z+=ue{SEevu)74)d_G8m@=@0h%?z|-Ju1-NScKZAcaXei}ST2oDI#CEB+7)IN&3MU} zGzAms@Naikjl1om;qo)WVpntdx3;@t$)XYKn>AxC*S{V6_8QEGf=c)I9zCj24c$4d zOd1A4^SR^wt2`E9QZf#8R6B&Zchv~JTpQ4oJuM2l$jFQ_@{YHzn~eh6q+w;26^xg~ z2Nd@OK-oDWXBMJx@g=ILx2kaFDz!t)oYd=m&7!IQ0bw-kjxE+Oz1KHri( zMfL*Gui@9R;y=E}|NI_P`+t16w|ME>-~Prs?h!xq&_nZwAAWe%Y5=ICMF5yz4?g%H zh0?{@?BL>ZIX}k%%BwsUAL0|-;f?$rC2$8m?O9im_afvP z6S^A%O#y+&bRhV8HyNciV5_%DNFO$+B0L2y{Rkb_Hjmv~dsd?!`<0=4P;>89V^Aug zgSbsrM#m%hSL-GJNw*%d!)QtJJCVB7G3lgGiR4XJFpxKu*R@3e?HX{Sp$q6Dfc~y+ z=Oh4-1X;u3`%ZYHO%T>>u&2|kK?9f~gFBs*fgGcD3<&WwO{W1w^Rs8=xg;V+G5P`kOW<$9dw6(u{DVEX#|BJHFjC8Uvw9Q&o7UL zV$lgL_T7Aw!Vp+Lw0;1h2^50R{z2pqVX%L4A;IP;b4Nwbn;=Mmx;@8RYekeL>gFy> zC^>qpM^#I@HdH`6C)<0{T=r7X>c%N^$AFSINt0zG71R_^P z4iQA-i2o@H8n8)q&_MF9KcbN&>?#*r!@#WFeD!|Y%;lBZ;~(GPr=O)c{fLGCp8JpA z{>By6HQG7Z*APXp5dShH#((4mfY=vr_SZQ0IM4b0OMFrv;Im{(fZFBi`dtF8)1J4h zHoB6>JP6%KK$Ru~QdGPJ)rz+vKL^0b=dY7Zc*q50CK#Ad%&#&LLb-14nwtn>Dn<_K zl41&lYJJN=)IB3nd_BucMy&J7QfY{RMtImt3!U<$QTx^LWgTySYvDiGL?RXG7EBpa24bYJu@v zb;XlbtgnOjAMC4?z;J_7nAxa{ru)8V$93K#Am&|%f1p7$2|sHO_nHb@9gSi!=q2oQ zD*YPDuL}fHM9_-yD`TA7qK8@77=FLP%B$d$S=b{(iJlPW_ph-K)r-}Y^R>&?uDpes zHt1W3dP&*odH{$hIc+Su!RAWWP?JE^p{BL#-L!RKZukFKB>AX_Gm#U2!*WtG1@m2M zr&$=@p!yc&@@BOe00rQv$QmOWZxF#pAOu8E+k+5b8PtrQJ8N`fy8WF0e37@Bb6g&H z^>B9cwg2$V|8zya{rA8B{Vk@~V6aZL_&^wqv{KG%I^{sDx>n#pS zp6BztgHQT2AM*$JiFY-S2eiQvkA{@eWs9Ae_ztH-4$CB~vCX0M1t8!2DaJ%bSdbzH zKB6&&KGPJ?DpHcGqvXsM-I5uRJ4D=<&9&!93kyvcD4;?VF3G{pq8kKkVzMlQ3NRwb zaj~Z{P-12&s0RL+3)#bbY2w6zH)e#6!BV291z8BsfnU7cP3&)jb0fx zXh)I!r>v32EraR;#!VJ0h$!LykYPU@bEEN`G+NQ$PM3XH;7xHoGRCk;9tQy<`K|@$ z8i+dYebSW|CHcmJu2H=|=oRlZ4}M=1?K|N>O%dpY?;5rI_Oai6*EV(y=TaxlU2#GZ z*n{OkPgz(lSC+@Utv)~8UlgKUSnBo4vig%(SW5U^M_sfB+9aEufH)y<66$WNpBpg! zMTb=ADGl9svy(qJ;Qdkr&=!4z4#J`-7HBKKhWgM!__a=g`B^en`kI*qlGO@P;XbXC zFyoK~Ml?*e1eCWplpqF*A^eEaX{{K+3)hh+g9L|L>>oE3CF4oD8p<=DA~ZimHJr*r z7$o+6AlYS)7x63btEeplWX3KI!B419U z3PA(q=2HloHimiI$dICaLv6dnFMExDc%3(;x8B=%?}~Cb|Liz4dBth%0>JP6-tVm* zfBbP}^8Zy^%?H~BUgp5%6}~K|IKRJ_gMyE80PjqIoa;kt!|X5#wm+-9P=`>*L~uW>*^?;Qe9Jrk zl>2GbJ0K>#tkoh$C^Sc^GsTEoBC6wy)tfSzF0po+noGDjgg_i-5)`n?oWW!jtHPyJ zozON5{F1`rl~M@c1JQ8&-Cf;P9{B#c!`uqub;|c$0if3j=#qZTrEib}w_58wO&RFl z)97XoHhZ?#K(q)Fu~kaJEp*ava^>|y(;~FKK%=cNpAUkEeIma#yheBUJ3WI2+P~;@ z5PJB3t9u~d8`MC5yIswoti-UU^Pj5; zlc%;mb@E@lwG}S@*|v8%n>dAvjFHixy6t`P1R8bkg0;mIkLTCgoqw8!l8I@;4?oQg zs_i3a$=D{bR!76GMP{Lp5gQ_}UODChp#QGZKRFeny5XD`f*MVYQeSAhvx*#fF*DiX z0evTdi{jMn_XqsjciZG&JUa_|&R{URm>QkShPIv$K6aXaAUm1qF;t0Up zgxPQX)^9C8|M|~f|Led0>o<4q*tuh_wttaF;8*zUKgL1JeG(uTN%O2JjTpTsQ=%vn zeO7Ldhsq{jKL5KmJ3&_49!KbjcLcEi+hh3?2hvZknp&4OZ}jTb9`EEHP}rpV-0gC0 zJW?3_`wjL##pfnr{+se$mUxF<;x7L_ za1j9eZ2@5E6agARz2(5g7a_j({g$mYo#wGS!jyK>OUdj_A2S6C08J5KF*p>MynPee z-~3$)0E;cdwP4ml{pG#5DA0%*N&#R#Z^aMwcy{84E!u#3HU6du(z_K5-TX$JZ#BUA z!-9s9QV+RBCAv*EeOQ01&tBTG_JWJG1!pyJBZoMJv4q17xh)pRj=B9CL6j!O9yA8)k^%~F`~(cyhP+6&GO%{KU{L>rIA{h=VVqyn)>T^a6kGvy7I1b%o`X`|i8X`b7Env(G-uKRtqDRRGXM zfG+u`uYUEbbMwyr(wDw;EZ&CqbN6DKgORcS4FPRRL-2FOaJI^PwcvMS7!EO1k-+Kz}}K*{o*5@{Ll ztFOOiP9yW<+KP>Xyfc)^EpNJ%f^JO^h4ku!NJqiz(!{uQ24D&)EtMcVr64duzOOq5 za!uH{h`a?q1#>s;U8oZAf?TefgIL0CDiV0sNTXtlr99DzlYi48lLCucWm}9!92#m+ z5YeEK2A+N6#MjyVNy0o70cHbI!APd1qaBTfjmvZnBEZ1Y*S!5%_qYC>`(nW0a?ln?X2Ten1qDg;=QBg6 zVEDYA5+dYEVHI}p{N}cUfzW8$FD+`r4DM{_o^j}Qk_+^gO?YFcVBw!@BS}KC6~GX8>udBLqeSXP<&r~@3DFA8 zFEX-(MADX9Qn0q)Zl-vCl2*`#FcP37`5#m$6T%;LAcc|~02vzvttq7!D`Y?+-QCFw z@X7ueh>u3-A`}qos9>{At%*qZ5f@!s8ZUkUb!crwBi6`xBcm5Vl(Y`?LdxvTHf*ni z+@u&7f!lAEGoK2A;+t|o=Pj3?TVw$huyHI>y+&R=YRyWd2{jKF0chw*HxPaK-AW9B z`~cKL6%2g&Cb@3Z?qONwxi)>Sc%jK_H)#iBP(}o`YC~K$vS<>v-C&1%)R1|j0b|+^ z&Wz^`&l%oB#!m_t7USLo3KD$SKFkEiQT8W8onHriAV>SDM3-d(#1tk-{2A+|Y^ zxk-2wI2lc03xd=n88X!Bi zCVS3b=3*doso=#hW)Zh+ve3DG_qiaz~E3^S>^P9GhORu(4PqSv)69a6&j1 zw9EVf$vfxd{xC7U<^4uEtDysoApm3IR|Hh^=8KQPz#5qCdg^TUVvu{MuhO*q!`)L) zOy!8;G34LIWa0o^z*C1CFi(&|dMp%1ISjBe0jkaUUvt&XCC+zc=q~bK-{8k<{0XmZ zsnwg0J^px~{6F;2LyL!>dU*8})vEq~r4<0cu`U2S_~3(h`st_PR4-Pn1#pqO7ca1o z&tf)P?DBbhkVoJbHCM2^!I+rH;s->Ft_VOFxy*Us8W@Gp1d#-};%b;=Lnj+tKuNSW zrFR<3&;prIs1$ChrW1j@t5T#0T(gaiCiQE&4{ta*$+vh(oeGLCvkwrBEBS-g3o=n) z&skG!@alv|z|qBsivlW?`?H~jlLbC?fkhVUuR8fhmRzUViNKo0#VP%iUGxGR-eC%X zplKT9cC0;tI~#>CeD8W6jtE|+I-588De%!?o9J2%8j)2`IcU$@)=*Hr-C+Maa|d5J z_qOit_v?L&3k3P_Zv@a^IZ`{wk%y;=h-iq}Sk-CK_&Pgdp!q_1A{?D|P(C{g={1?g z_fbrobW8%BM{Nk>;K_ACjtYcgCrsXI_adO2-d1~jQ`q~VvQabOatf`^VQ}~{_B=B4 z1fv3AB^16}S}%U;qVtSnHn%zo=#_UE__Zy{RR*}0(g-0UviOC4y}bz~bn=fLiJ?(w zo|Q0cCMbGGI<;yA|7fmlcvb6uCr@}sI{*Bv7rEp8%3?NulV+y?^_1TE zrjKB!E>RN06yyQd6+phe_{A@7{l;(n#=qbq!2im^J-@@ehldwi+y7!`dxyo0_C?Kj zpU3!s2V_eLO2B|tz$R&r;hf1XvcaK3rxY%uP7mos3ge6<1Qjy^ID4zv3}+=U)=im$1krQd+f2L+R)e32!IBrAbbU7 zI|4M829G@Q$N^g!yz#*ge(+^J@t0W$;1leHd^eBPUEP&hptYHtcZ0v`neb)Uepk2; zb%{{Kquvb#>^@~QebKFUu!uydF>2pS{Ntg z`})*$S1>iYq1N*#`TGN2O`ZH_)932YjhhC@9lb5AI^NtssErjblKTnb_IZ{dIpQmc zkB~rcT9YH+hurYFD*4Cw?ErU=8a6)adiWjWKiNU^G#V{{4Uj74ivTY_>k^Ol%UIIS zc<*|4c5rx3&Gv4%uaV?`Q{sP{6almx0Y3WCj|%po_cZ*|KmF4^b_F=MO^avwi60Vc zWcDWhWgegpY;SFgIN(gUeqvk17fQcw?r61+!SWr@oEh;`+~Ne&a-6GIS+eyju&&)< zWnzU)^n*zf!#M9KPq6ri0;hU8i1LjV$I;3t+ffQ@Un8hZTNzyptzf*s1#MpOR`t#Oa?{cxoE zHbq?j+?azr^s$TBjc`NLE*=H0>Zo}?l&v+sx0{WO=xGXljev5ZCP#ZzY6>&*EQ4{C z|9hT){~`}JC;ta8&9BcdeCDao-2ArswsMvKbu<0_Q~*#d>_MsTe)p4L2Y{9PH>Lo* zvsxUSU98yn7qfjHE>T4Im+T3AdW$`O#nV`9i&*vNuuuGfd2d_gX~-ZsPpH>Xjtj#E zWQk~uCq|yc1wx?rxEr1qDNyfmI0ys;!*dF!;iTV!yl!}Ub)s0XGQ5uL#t)gyLh@|c zkf{z%(at~WAo~-ETRGLGxahNJKZOXA|0ka~%N2GyvS!PyvMl z#iYY>*uSazPccWQXuQ^S9!Le8t|-%>Q?t+D6&PCcKnj#c8 zN!P#e{S{wd`@+z`6HM}MDHe8&ha@3Q;&hAJwtI*=%ayhyg z$-kabC_!h#5)Io(Vhp$Aies0cuN_q7z8?lP`y5*)Bm$KF0pznWBGY-a^N;$j47{m; zax9Fy&V|;^(Q_u>TF-gRSZY|T^h9u|c~C1qtbfOvJI%jI|Gj&-|IU9<_n0-G?Y$4p zhGWt;fAYNUxv6vMEOL9n!`@y{QT2Qth3q53yNA0T9zpUyT3_`y5~#8S)P9@%D z)*vDv*HhR|5rXHB!0E%-mm-_-I9d%AIwhIOe`M3FL2~Ln6_GL9IBp`csUKDHZ+Ble za}1lrxK(lf?W7_*@<*To;%Cx`mj&HF`MLa z;pm`{z>!&BX8Z2eD#B;_2OoDXBr0LOm>3H=1+4^|%*9wiDN@!{m_agbVtS1{T#~!pD4a6W(7u{}eh8S;DNFbp-%6 zOc@38q*dJAB9RzS%>da|D&!z2i><|Kodn9Npg{xBPd2UlsXi^m?PuyDc~G#MyzXEj(MH? zkwDU@_{Lc>Inaa@e!%$B|4tkaaxbqtQ;HOsjgEFKT@AmVK92yR1h&FGV)F>PlWq(x zBGWr|`QV{x5M+X31lE%+tY6F)MCdbOC*4i27@;h#NRp6-R4OV*cA@rp(nk->t zQ1_1Af2`fh>vbZ5;y|pkHVy|e-2>JRz`HXU|5${mawDC%ezXf{VZ-Z&S?9civBnKo zAvD|wb4HLZ4y7z(!!L^=B#>3~Y`n8tscw7G49#^6J)!oAt9~ZxCs1;q(XUJ4m3*2I ztnrT2>=6^DMW%qQ?dFr}2_U-&IjvqwR=jM_PZVuZ6HyX!ib+l!VBB`7M%C4wv$3v@ zISoYN9q!uT-!ys?w@wjmq&*1acRsTgsSdDrwd&-*N7lto+s+O!Qs47w*c2T%Bv#1i zcF;T1tem;sysIGIR}CVMo7jScYks#91G+hDu?s`?6c1v zX?1LVR3F4AN6hc1pZ@fxD?ai5B!B03{w?_T+e?KlLIWO=5!+_Oz{$m8K{AMF5d^Dl zMfdYW{Tm?DCdUoB5LCM}ClXOIII(Y$2n7{>oq+=K6r;s!!1vxFvt}2RtQY{6=J7hL zmo$3EEF~b6Dh%vT%CG}Vo-C~8h|0NxNmu&><~L<@jX1e2rXJ?fun<=_fpX=!*F{0p zsP-pLtEeK4oOp%SMXF$AJ|QBA1AFs9w1Qn93?YMSNPcRKEYzHn{D;>Rpo~sY9*0r( zq(W{m7qcTGG6MmG2;1%IW;*0q_mqEaGj8*Q5J61u#T6T-Q1da$`GyazB*XbH1m8{b zcthKT!;9{?V^P#3Vkl+1#u06)W%_#;P{r3)hbFI>j;K>0)?A|uCt?ct5W?lC!?v4( zvV-s3IyO;>V@`BZJH^&9B?483d~ihR5OI8_5UqExoE&JwM%-qGL*$*ezUZ(xy;1EL z>p$)lql`UwWTFF+8Ahq@>uqss$92?!UiXaTj!EO3e?PdecD?krWU$79 z;kg@UqrZ;!xOJQ}TRX(YL%{5VcqZhW>NiZcS)*CET0wHxi~QqH`H%mA8U5had+)vU zcYpW)?YYCjd9nJVKl-C1;r^>5L;y5jEN%cs0IcM@^QVL5a`_e`CC>8E{E)}=X&%^f ze1-O802u(#@)T>aH{;T6DD}@l?b@5Sn3b*)x$?YJZ08>A?UEb9G%!%6%RUC`N-3&9A zz}lE+Ok}ibVkA%`K$0HNv2I}0MIgsJxnM+Kl+G<6)70|z!Kabh=od}6F2U}yyNHFD z2Ay}^fMW0YDrO6^q;4m6*^Ftdgz1@Z6LKiGY7#Bh*z*>7`iB@$L_H!06NV94$^T|~ z^J)SsSZtEsG!+*r7ZGftJb;RGMgeLAP8YxcFsAZ=LJcJMPD&_z6^th3$?qTyf5sZx z3f`f_v<(2O?in378q+g@>CV$v0wi7KAGZ0YElK>D-Y$an4D+X#7~uUJKVDF`ZvFf_ z-}%lh{cd0U;up6bdg#jwT?*|40RQ`Pzz(!t4-C*NkHz!wfztAFAm z%FlQxUgIZT;le<8F-Ks*hKtaM+&=#9L&;KEaIw#Ve>yi9N?0ls&a{(BJt>r7#Qp7t zDDODzfOOQ|phUqGLHVG`9Dz}?St5I>%PmWwu5MkSSqDsDtA>hPD!Dl#1(z#5K#aiX zV#t#~HJ|c(mYa(}(21RT3l2b2e%8dAnj>(fN%;B{PFMqLD&n2moH>>rXi!W{Ik@h_ zju*y>11cuqohT~gwaC*vx%{=KSlX@hPO20~trGoeRRt|9Um!MneaKvHrYLlqeM2HjS*#+3vU8)N&{)HT$xs+xjws78 z0I)++C?kksOvi>qlU7X<6o&r4p*v0=cM{oT+;a)>Rd0s_+4N5!pzxm`3Q&w5jK+Cw z$&bV4R7AjjE{*@0bo~#Rk@N=tnw#4AK2JV|{;ytn;)y4&njiAoYp>1jyYK8w)c@6O z_*xv*2T2#72L9H-t<{}+$80UA z8lFIh;^++>!nS~eqaTgfmr*R&G$sw9qRQs4lL(;zGc;?GP)71R42&8{AhEX*S*Ae_ zg7gMw1Epks+`2ir#zY8tc@d&&1jzCfO3IiZoXkeMUgTVGUx}N@NGL6+rR7Qja=0aM zo=4%?MilS3MZp3T+JiCXdJ(kMiz!bal6|9;Vd0tEXO|V?)rc^SfV@Igblk}1w)J)5 zkZD>FpPIOTwX2Rw(G!4V9S-AN6UqskVnYvOJ`yEgM{9S6NX2cj5R4JClmCJ3fkXnC z+doEJ94X#mH;EZ!PTk?g4vugP5>I48$UDC^@|Gr85vKnZu;g;7o7sS2k>#SXM`043 z=`4}&kDEXlgjAEAxY>Ze<^jl%U2w*ji_R#Gf()A*v!B$jA=Xg9T>MFylnLs8rqE$p zz?PhZiNxtZl6qAX97C;Z3)7KA?8D3BO1eI8LQ$mQ&GoC@wwgtyy%S?;Ogr|7uxV{p z6Xk^>#{FQtF-5yR=C$^dH28rG^{Wi?{%Niqcb2RTz<;^KPhxjF^?eqx`vE`WXFD_X zru$KQd%wBd+j~+y_Sj?iXI%i05j2y3<~z)vY=7SiA?)nzT)1&~`05OJo(J$gzD_$l zm>=O$KFvQ%X+?%uUN6^h1z6ihB|j>@g`!efGFEkm(_N*SH$-0L&I|U*DiXwb!&xaA zvV>`sl^j8UA?Ht=)*EZUBtfoMHBJOWmcKWCp2};5(N_~qfp{KWxeUVCB z;c)6}rpJ-OA*-zpy}!-WK|4!`q2}Q z{Xgeb=ZI$x{8LYT0GmK$zh!wlJw9f)<3pL}K+m0ijvjpbZR_(H39wu)7U!41Gb>vC zn5V(dcvxg0_ZKW6xU;jX7F!G5hZU~GYP`)ebyvc@U!T|>M+SLQK7L+Qv2I}V z%q&_vjh||>P`3w0U31nnLDHGOonwk)r$QU`0{X&jBoiK?%5k=5aIC_H4b0$dk~YRY ziN_TNs1~D+wUYm&mYdK)>{ zJgwnCo2#bRSkB@0?0Y8UzDQpm7I)kI?3A4yjiA&5w1PP80j|zGlp4|COfNqP>F%__ zOJ=ke)IcaEMN;;@6jLNLEkQz7p30$Oj4X&5C5@N|lGv22x@p2qblTC)ZK196*V}x$ zukz!0{`(70eDj;V@N5w z)>1klCl#mk61l3dP~+)`9UBqGd;A_fCRZ;wo%SX5?xOdRqX4atsjFdi?TnzZ*K{F@ zb6oNyh1XBqW1MaxgcSe7S~`ekun4t_3j;^QZij)CSJ=_Tm>N4-Cb0R%AM*P+X58h4 z-(dlN0D;`sy7x@qg3R-#jLAW!E;7~HAS2P`^EjFYRy&HFUjzIBSDCu^s-f{9wj}yj zr3NU&I$%5K#8wo_*rfX8f(ARISZolfFCYi<8t#hO=s)BA4Y_guEGSjwqcB@1f{jOr z;gkFzGNYBydK+1T5xlStM4pIBvs$uk&qC-UxPuCZr%g0AxHM3i9OER49e3b&6rxMo z2SKBHPo8dk`Th{gmJ#N!6Q!SPt%^it{vkn?|Hy~-+V}qJhDeoyhFAUEh@uE!(h@0f z6=`HhwKhHV+st5ngZO&xHpU4~J}~BV?}c#gG231NGz?NCa0}jG!^l#F$Gfv68L33P zenTP3B}@~5&RA4b{tv0Oq@Q!*PfhYKivOZn2fV{SKF8mG!Xx)%I^2K$nP;BaGnM{- z>6iZJ#kFf6!uj(*xb1cA#qIb|88EJdtW+2sAiqEN7k}{=2dD13YcZ$!y)#lDxpnK- zJ+t|IA@F~+45-#~o(lEaRJN@T1r>0R(x**+a)@F{k!~^^*$f)l^f6X-3oLy7O%IhP z0qXF+FeI#2$m8iPAkrO#1GJ6{RE-7qaUm3!YC&d487^xx;g_ODa74LnYUjiA6}wT? z+#`pZm!73r_+6OgROkJW+nzv1VPRxADJ4XN%p@x&00g6|hj0~o%FYH7T?i!BG{g5Q zTLLWOFa7OXXwj5sg zoJ0i?I8>P+Uz(phtm8B#jtK$y>)<3Cx9}@T{!2hiHFkd#Y!i%#AG-EdaBm|jMFS#M z+V@*O_b zr^Q1>jKhty1ELx>ROSgpR;B$QLd*_ z%JnyIZjhC0cc~>IKnZf23@W(pA#flVN1a(!Qp9z4fH>d44mk;#Ra3J7<4+q(`-zV@ zDOjQ)`T_^d0uWh{^`MhYrJ-(9F{K6~ye&iBq#?_Jf=KWFGbH(EiMvbu%oq3-|HQ9( zh9|_EY;k|fJ@aeVc9tLi_~q3PexUyQEC6(N2QUx;i@m+Q`S-v7{e!Lmp#J1f{^aey z{P+Ln6J}`y&E?^#s!xXtOURpaB8p+t{ zafF;k9O;TP`5INnGrUb23Q$p};b|gaQxwFYgW|Ss|Cm&Uf>3^yQHK&$lW^5gxhGcL z1K8-Tl7-v@vX%~Cv=okBL~QwX)lBp54oa4XBh2QILR;Y)=vH`vR1rWYavmLmQk7jh zsl#m}s;vFlL~Ab+I6~jQ%;bNPEoK;+z8VI14n+D=s;~;N_G*I>TUSbA%EEK1RoNJ7 zeYv?63TQy4@a6bKM4T0fkr*$r?RN^pjWa#kq2erb*1UAJ;^cq1x+d;*{LHiblAkgz z{e`U=-sWRpy6-2+{{s&^a4cADv)g?DA_(~0bI;LRZ@oniKKP(@B^Iw8zDXBdOu`;M(0>#&IZ+i3#air?DFlKF+ob(BMV&Ibb%1lR3+@;x%HEseMj`(drY za||t8rcGFXug5h91`lJXTo@Y)^$3z|WHj2w6>0qjsb=d4?^CDIqz8XQNL{J zULb{Bj|`qa`u7T%xft&kKR*iV2Vi&|9j&&^6h6unzS-W_&COXU z`YF-r2HWe7!gi4u!&zk&mRvaiP&}YOX+nT7w{D!Ni?rAs)>!M1JRUe+lyP5Sq@bBb z@{bs2)9Oh1_Dj}B#5xEGg=XX`Od(Or#0XIs!S%)Gy= zQ0{mV)_;IAABZ9%dT_kg$WWrTMb1}|IYcGad1a`AjBcw6p;gJRfMs{-fjsIQB@221 zhi+rajUnX%TH|pMu^gK-+w+Y=H2gT5ihPgp$ZC!}awU+VkOt(7Z(`uz!B;78k{!o8 zjL3K{BDU-lT%+vVi~{w4BxwqO)r{!5jYC95SrGL))L4cko--3DvVUcl?>yEAQKfos zSoBBNc--XKnDqlkKMP@rSRsl04xwcam^#dG3>Q$HwCEuvn$7Trb0F<|c9rQh9BC>s zf~r9F0L1hd;$~I|xzbQld^%uji@+K}Du;P!3nY>4=9(kEwCo;|^)H0Njt=&}T&=G1 zKVIQ~J8s+p5<92AeFW12o@55Y4kr($uA@mQI%%ykfZf2ylUM-A*ZQbwbQ z!dX_)$lb+}f0k5vh*LtntbEeMnkUbOZ$e#8(pYumEgq%_c`Ml87?r58_&@sh}lO#hJ|UqbN35*`na36`0PN zu)Uhx`)_#w5G}gZ>IVOg)&AAf{O2DtPv9qW96tZv#f#^SajDk znH`hk=#zgz1N`Q9eshbV6N^C@@C!Guy>RK(g9ALx+Kc~{FUOA=l=0Hx;o%K|VQlYg z&lwTGZ!yu(hY=x9K)tcV(cc;0-87o!Um2hT;`5<|LZBxQ6a=ZV@Zdb|SCun^+^txm+ zoavG0Tk%nta?&Gi4T~|v3%KUEuAw3kK#HJffrX21Cb!^&9-U)-TFASA@;(@)TLMDk zjgi^Ao8kn$c6jI^EdZ%$+40oH`>fKbIYPUdXzVV{(} zRt76=2+#sH809A8*3=kMJ{P+*1dTDITndkmK%h*iZc+JPqMarp^S{ujAc6h2oPW0Z zBl;OX|K^ZY75DiX9&pcJYB6{4#rVL9-CbgIfN(M7A%lO=O_5pmpbq{?rv6J5%AWzv zr;wU)_-3&_Hx`VL@jnmi4@9RgT39xPM1`Y?HMt}Lhe)HRP<3Rp^ zAUsa)S`9cF$=baH`HrKW=QPzI1hW1@3A(mzkCt%Orr5kOJfz~33HuriX3s?7Z7Ud5 z(=aUVi@0RXj7>4YwS?IfAiv#H+F7Q6+A>*&U~wg8&#EyHXSjZjM2U@&Jg6yB zPqAX4Y`A&E`p~15SeSaE1kBe`%`(@7z_lb(tT%{uB2Ry!e*kepPJ6XLQ7gP~oou*i zu!iK4Ycl9Im>fGWq;2Vp4I&YAkvFYkVr%{F5)Oo^#2wkkWm?;b3BM)Ba|+HK5py*S zYXw9X{}KHd#5(8!*Bdg7`z$*^)7{c_s)Lmb=vAp7Y)Wk%7$0lu`iRlz2fNxsSF%qY z2AkU;$N~vf8`~BGA%@B*RtAAZ#W5xb?B_(v_r@HWyp*255v>h!vHu>T-Q*I(bHxZ^ z@dBvq5FN~l^E_WQBHyzeQ^>*<6$s$2`b2|cTWQ`5Amw2XAhIABw~s>+@T>$WEMS;e z^L#i|_}nNe))5SP-RWu%V9b8bnKgDTfkq1$RFha6d&c0O=Mz}$Wy)F|hX|}+@05F= z1kNkB_!(RVU?)lltRdt888EKfvTQ8>)Jinxhh-ho`NtLBfw=QelG*=|-}#3M^JlmR z{l*hdJhAV--{(H}Il6J~10*`jv2og1=672=u|V?dbL+{>eV#KtGc0LU2+xXP8|ItnRF43dA#gC$y2>=gc+ zLS1P@Ird}QCEVbrK-nC|bw%=Zu!P3Kkop`d!8D!oK(ZP1qX$&vc_9J9uyjolC0(?U z)}3c$4^+7}eYx5+MLIljk!zR*C?X1yt&xhWswfti1Yu$6p*2k?`gZo#K_zUd+D%9*1e%e`f0bnFLe#FT0vllPE@rKj+ zr@$xtj{-p9aX}~nmoHzQed$Yo%!At&1pex;{_p#Cch&#Ipw9n;FU|kPPkWGG!bN~B z@*!2Ll}mR(W<0ma0zjYx5nKe51%O0Mlu8m}1pqiL$ao^}z0T>ng;A!yExP_Dk}iQxBI~H29L`XVM+*4Cd3W%ve!|)@d&J5sZ=pCL204s3IMV0 z90HfwDgaCns3Dptj>(E34;UGYu-?{|=WVXu!D%r-V zQC{+%kZMOp5dlGOZ;S&Z+&w!6Q;GoqVD!UPMCgDhIX}Za4!c2W3hTj#V`bipwpn1T z2Z)^F5z_81oz15|%V5HbD60UF&hc`E1X1OhI^t#%zP4@>mN@>fgAd~7?giq0J=SLI zyf#<>s7(I73+v96R6`=~{zYDWIL@e#(qhWyUP@Ihw{VcShjI!4u?4odhugW_a{gz{ zXE5x4kDvB)e!@@r*FPZ4ej*fr4}S3U&mVc@kpuH&KKt3vZh!1!A6xP7507Kwua21p zK#;~M7J*OS`*e5x1~kA2zq@*z+J8ma4|qn zj066D$Um)OY#A6POQZ0?;66-LnfxOJu6Mc$gWw7ERU`xg7-c(NWDs%SM-rx&S}ZVw zCsGD8?4c+xa0oq+QnPhcUjtl!7%+Zvp;+dr!CMFsEgwd$RvbGm_}*OR>nK6n%23vg z+eEp5eV~Gio3KPSPsa8J1GOA#pScXnz7c9%q&VnRHVREw0k~ZRCWtX^T_DMa@fGdz zI6;xhv^8>5Q|gm*WASm~5;(So{rEcfbe))AULY1u{d8@HL~Taf(2v55*f z;vuYo<_mYZbBc`XkDi~No%}1y9?#`)wC?H6{f4(H~)5X8Wr^amV7z5Bo)!LQn8ZdA|5CWoGRC<~C3s z5Xe&<+^}t3=4n@ikYM({0jn4746415kLZ!v@9jsOMETiYe)2!cOGjWIt!^l~?;Cia zK-r&QtXEmx|M4)yu!$VAUk=W$WUOnzTjI&T&*h;QAht}Q|7V|l_Sh!=$Grg19-L4Bo>Z%I=gwJzexHBr^UJs2{`p%h0{D~F z>hQntg?oxG%8&S(o#Wpbs;}*(HEt~U2GOt-X!=QwWFgGjq-lU1l_mei$C@{g`?E4> zhHyPd$V~qA_BxjQ+n;O(8%a2{dv7BoB;cq1!#ZOIM*){=c-G+YB-dDdXy7CT^*`X* z7qzfz5VkG$w zWmw4#J|~g4IQyYZ*iGKe=LEQpMMZ?1fXWQ$b!Q&_y@Gg5QskJBrKvDNeNf>jpUNXh zsDRKI*(zkinn#uhc}%n-WL@v-fx#364B<))7}i z`&K|v!i;swF0bUB$z3mDlltz5Bu6B25{TNH_w=PSSCcfK9sS3(~NX z6*bg-x~WV~Y0C~UoS!VDvkGgW+jEZ$qCUBhJ);O69$fQof{i?I?|F>xzeaKvmgG&j zk3{ZP3Va3CF9I=9h{6Q-HqAtr0mu^cbdz=eK35bHILwA{kD&34J>30l;~uf+t79KI zIxv3m1;M=l4k%*~;6&Co32Oq|xkPZA8&oQsdKheR(pV!S`48B%8_V+18Mjd00=vY9 z%*X}i@|-ze-RP;8XQFgOY$1XB*rW+Pv^Or9I#2vywSRhpfAuW??x$RD<-BwCvt7FN zS}6IKm)(E={mZAGdU$o5yZ^_T0-y#-fED${fcD=r(>37F|NPHCc;?KRPqPZ(|IRg= zhxy6>ivRW@K>{q=(I{y$hlh$-(cRgoJ#~Y4Q0qW^^Z6aIkD@$IW{S(5i3g7JRExq^1qt>M~gHnE?(QBSsyx`~!5b)#u z{i`p2=R4oIr3RtDd8awIWLx|79O%^lKmeF1fW8nQ$v+D%%*@07vp@T@cev}%bO8L2 zFX{LA@sE7rUfSQ^KM-et6T2s9XJ@B*AE5zs_JFIP?r=^^#wdEBP(Ud=nSdgv+iXJ_ zlUpoodq2ur*bsEQierJDc6F`@m7;i~EsyiA@)(lYB1|dazyfV4ih&ri-Uu#Yz`JIl zAPaTHyb|ah)^W}ow|Jx=c(*qBS8mtAMb3rx)pe48#JUR(v&2`|*!-8%u|NT|j*4vy zovJ!2*1#M$x2zsK?4*Tk>1x0M8Q`=Z@S#mH1 zCQjQ{wGS2>VA3ss=$=qFJ92?1j-w7yTol$@qbQy2WiI&?O3ubKgb-PW5c7u#bj|=j zNhVh~-Eta=SpvFxK9S@<$7^={eX^2&g}me+PNd=|I7F_3th0s#$kI2nl7IM=O|uJ) zjAA}QZO@THa&Z5NKDQd0kJO%*o8-qH1<8N(1{@UsDCjDHAmR<1*l=m2^VZ*A#yy`+ zZyFIn3|Qwx9I5iwRI(1qG?dC*+~UQdHLPY~qr*^{eRa3KbeEfau!a1$$$yh`Qj>E^ zp7;)*$cv04|1tZ{{eYkL(>w3ndDTq*$429_j584<*WOoCW8ckT3HrWHrxY4F1ODamFq!Rb7C>B{&$*Rn} zH-7V-SkLl(=bVU4%3Ly~WM<{PH*VaBvwX{Mx$nOF>Nb}jznCKMvpep%<0PQt`O}QlxBGJBbmqI~PTR~$0`prSA&W!^Mm%#t zlApLv6b^Sc=<1L_mhxT_XH?DHUUNQpcW zweFJ9wuvB^#~3QZ_;60|(gK0iEA>KWe!&?YiJLMGNpY%j$V8S@|x1G^FiVTqcPL^IS{ZUi~{lYtQIlwr$?Z6hzbY-%I3OE zc7hpJ2g-~23D$;54H%Py<(onw`r<@?in_gD;3H9qF|{viT%ySc3PrVFOdSH&;mG7C zas(^FuueH182}=vFNq{DNy{xUt`wyzg3YuW{zER5?Z6ia*^(T4x-|E5-MC)_Nu zkxn<+*8K2d@CodY;%&Z27x~wp@}IuXDd*p^G2nMjUpn^upZ@zly&fLx3k>;xoH_sZ z-+zD2P3reN_y2uE0SM3676C$gK>PLeuRpMOa6w?rH`o>c79;urVfQ6#T9;PMPIO-;Bo{~pSZB=9)p5lB-}5J*`%gn`uw?_UWSPV z-u4B_f2k_4q@7c&rd!z6NLum~Mmq<#c`C3K#Ps(rq`H)!@WEiB!I-6>WecTXNioD1 ziiRLj5rs=w`U?!z5!xxE8n5ti9a(CG`-A3IT3#WL=~Cuc&Az?R8DmN|}DoGin$z z^cY69FD`?gqa1o}ASl9(Ld_!|yY?;Jh=kQ5+DDY<6)E3OcchsN%a_)roYMbXnExb* zeu5L>-Q$D+<6%=LG&DsV0{H}nWb`P0#K_XKEcs8t@{`vWx?~uY2@at2nnG|~lh&6& zGy{rvDSWPN%`dblfJN72Zs%={{igE|y1-?%@ADu33zq}_>z%jW@U+YMx3GWP@#lnp z{a)w#e_#p#4?XnIaL!z9X_ENQeeQFE|Kgu~wzIfmo=_Ita~UN1%Q+uL*f2F+WaN8k~GQx9g)>0G_8+7<4}gWxzd{^;QhOWA5wNq zC{?j!-y#`br2N2TEWH%_kcn|4qO?z-^9P|VtSJ6R7k<>Z?7Om~LL&(=B2+DoG@m`P z)=c~IX_u@-WEWYg4N$+&Q{<@glzX#V>lJ^|fz)^Ub^d*+2VHUGHyl5nvY;UE;g@OY7C@ zV~hqkW_1B9yo0p?(NF@IVL%NU0AqI+3SgTQZ)rHMhD?*wcv=KKkU@;NCg}k1AUhO* z83K_ZQsHPRdrVT5i;S6&{70~Tf^jj2CCs0N-{|!k{LNhYl!n8K6%L9M?=T|qR~#DFx|~OAi?{A|3pB(KX=xN? zyq`VBm9Qz1j35gFtGMOfAyN%YOs5gbBvC*a9$Q0^W^DBO(?|is)DcQ^X{w0hRcEga z*FK%<~e;PFA_}jeAM|_^2_GA8&^`HMfqj-L_Tdkk{%g6rwa(LW( z@7-#W{@ZW===-w!|9(UOn9w1h6AAQgz*oQe)ve9V&09FxzlY)fzsz&vH~AUA%ERuX zn;RROP3FH^StX=DOQ8l5y1&XUY&^QP(X^pELl2^2lz$wUHR0jb;sG*w%! z9J*O7)MxSnfgu?>fOn+bWEpb3hKZn4Ta#hFu? zg0rR|9vz-U|K+eFr&I`1(36DI%rulG6a;`$34#!*lOlf5f6lT(L0P0B!Qts$pw_7# zY!ml0=`kenHWYWB736?7fC7gfKx|(U$<(AYjf7MK#ZW~&t zq}sp8bV75hLuf;(IsJKk?ZU44c>f{JBcPBwupmzpn}eS(p09Nj?T2!0M#d1N_f(nG?X^v=R$p$vJLX#;xOy&Ry zwtO>^|G6i8(5Zwe3`{D^bqbUJVr^9*vHl)(c!?sCWlu+`)!__(fI^d(!$>9%KUu}0 z7EfVlo`oL{VS>=%PB&u?HTln7P%8hFH2JD%fi>l{-`xN#F>^}&t#KeR=$sGg1fk_j zdSRr>w9-j0y?JB~Vk362*^CMM8T4IXf{^u8E{*{BBCUN`1j*JmGzlt?P)Y9RSS&oK zA5Eay0Ley7Qkd60<)}cWR4Ca}8SNd1r%0bp8WZ<}5*P8H$-j|YAc%_Wc@xQhxI!JU zSbi7-6*@m&^SBLkB~VzTr$hTN$S!l`kw}QIDNHR==S!G58G6Vk*?gao;nXDm)F=P- z`W;UEU*J3TLw?S8ZSqfbb^e!s`O`mjlYg6Ie*W|SeCxpnAKdVU1tP?V#@e|5FdDlKt3*s0z4?*?;v$fr(z&L7rLkL zq+{q~Uk}E?D-V=&WQgJ+#0wXu6bU?AWnNsu)=;Cb(*bItzKLwUV zNWk#J!2_QTi&+b4aOyZ!F26o|+)+LqdK}6pmO)QM~+4I18tvO(s*VQ3@#- z_bVTfK%{W_DZKQQ=D!cu1~Jatgj#ZlE#WktGA62ChLa_=6ouAnk)lE}bxNozh65{~ z%j$U;xz1f=Qj(SZHHERIwpb?Cn#gWFPC54toz0nK_l$vPiJwbH8#l%UPm)IXmBA?1$ZUWD6Am4S|C5 zl{UY_%+kaaBG}&sh5rsw0Ks(0S-;1n8^YQMXYeYJP@0(hBM_z)IiEfhD8?|5!^(3u z0l0{Oo@;N&#JC#x1&|ntGgzxqq`-<1<>SnnF(qoQ6X$bgpMP7mw(<*OY2XWMCD*F` z?{Kf|r~JJ0HR#(61^CXA)<1abtyf=UntAP>&r?r5^}a^Tn-78pfLFb6+yJa+;}`ko zxnPW+J^RGP*IxVY_W7n?;e`J!!eW0&#MziZBZ6&K}ZQ1;yJ91w6ELD+`X-6{Rvte{l{mK!gNTQDOR! zD&YimV?+{ul_aG@asTqKeUt>0>(>bK1s-BLio2Dh)wef>$pCaIa!ni8?U4-%69F6* zN<7MlvxV0qF%OaAbe9VKsfj-clAr)(QY|{f*3KM%C8!o0(DchwjRBO>HAOxjq!*J~ zYN8Tlb`O+@tbl0Qa+X0(4}f~Wj7V$>iftHO&vb%}3zeZF8gL@>84&t3f>~8bHeVPs z?C!_fX(Y3SRm7E+{10t9>hzdKXlTEv^%)RGUo_Fx#wd)^lEE2^SdIq_sn8NAb>O@f z3HWK1jsddV@w*k^RzrG?$C2h*Po}fuA_b1&K*d|piSd~O$DAneJMAJQa!OhJZ>SYI zU8IhjoufBW&Y>krlQ{@FD89MIu%|ixuw$qaC*M#dS7O+|6Y{unM@ZYp!PP|S?P~+V zkz0a`h1xvUKP3vUkoHfB^DgI+TQBKBcn-&Gl-1jeq?qKXRVE;l9Ii0N>`azz=VE?v|JT z-IM=r6`n2&{nf1ezq5;2iz9VLCu{{``4-=}YH0v47-ej1<_p z`|i7U*K08RgU;}cKX(1v^}1e)PBRE&J@ff(cxVX!kJ?}saL(c*0}+htW`&60$zAF3f&-x zrzn)tbM}iU+NTgk)W%n&7B6NG0?*Kya-3-!3-v-)o^N>>q+|bk4eZqUqfV~%n(vx%G0SVnl8|DE! zG>8h58)e{=_5c)hQA$FHL0K?CKMf?hDjhsij3KTm!Ej10I?%h736b%GG6nRLB4j%M zx@%%kvOj353o46xF(=QN!b{fzUJAH-a9k+mx6bpGk?KI?wH+{1JOYQCPl7k?8OI_w9>j%I@ccvSHGm?jr^W6Rf?>v< zAteD2Ch}+r64kMJ8}C@s6h`3R-nVs|B7i&VsjQM|j3OfFwcPxWEpRcQ0jNfrbwDP{ z&1eWxfZ+?7V}}sBN0hTPI*=xd-`XXUcfm!*y~fO{&PD4( zZhixfZv-8vG6~Gs!F;j0@;QWPFX#X}NYa49?!5Yzq!*cEw20bu8dsve76E+{yY4-t z6v0UP!vQp0oQoC!rY)Vjnf{FQU)W2u=wENe-}&Dc`NhxkD=i)Tdo|Jd+N^%G-hb!i zC!To1>-=B2a%Fk)_2cyT(~mbyD04&sc+Y;pnmX8@Tm-P10P8P(=}TU<=J$X9+|`F4 ze)uQr^^$oYbcIVnm-x@ud8psZPSS*= zNE6Jr3=6RRlx**ZV#E%YpLA4q|d>YQz04!a)T#I2h{30e7`X& zU|fg-$pr-q%4h>bwmvzPW9b|^Xi(`UPeV>7WAc);q5(8F6j6K^d0}EviN>!$DgwLp%<#<=uRR6ZH4xH_{(gs9;FQc5{M3b_Jh zg9Dp!QJy)I6mnPpK9=lup_E-X3IMs3NG?&Rq@Oy8z(yB>Z+7RZ=^X=la)OS>*IvO) zXI-=O%J*q0ZmGl3rclh;;Cc48aY95hLEtPK0__KH((u(zNK9eYPZ2C1%B|K53@!$M zlA?e}x&ZsIdK?Io6}u7GbfqDfM11iO;yjQW{C&*bz=kUc6I#ljWw9L|Bcy#jsl+m(2^yC$C9iY!D?FT1ku9!RltChn zfH2+3cXjbeR76C1anCT=B-aAjsocg3@JQS84W%il?LjFxA%M=)XcR42jQ5un@O&jm zaLRGKE=B(UcvaRmH-WV7QaPxhi zM}Ktc^y$-gEjBlQnFq&z%VXr%d4zm|t3@|jk%(3qvhKH*x+?-u2P2``(5G@sz_30{ z(@{|4>x)kdnw)DEUEJ<~I={=vYXlRYF3?MQQj8P(Zn}g2*gd z<~$c$9#I+ktBfWt4ofO=r9nwsWSVOKECv3AJ^+I9=xBpY zpJGL(OPDw>f>2*3#ZDDv{)9*vh9+&*0<*k?l7eU}7D^VRY|qm6j1avZJ)5_n#uiDN z15k{6Ns@j5e63AaoG!HA3l!x1YP4qQSehk;n!afn_{q4RNnSI`&~u^q@&2f2G64G8 z4yWWQ*!dMFxsWZ2)nZhza3s&M^s^++&3zqFXyK-H%8}a>^VbIR9I-+dgWyDp_6~%l zALFmt@|;cBIxFi2O@QCevEYraA*I}QknO4%k&G4J^FeSi7^BFI2SgH+qz7uGh?Yau zmv)M;wy;%qKQ}s?cnkTr0`ITzYo6kVzQeuz@A0#rUM%Y8-{h~b^DltGJ}|uh4^08U zw9tRLaVV!9P(BAigOIy0SVnZ7nCG~9}cbHITE%MjH7F&u$vG86`r zqe*)xov53@b+!RY{z3xP>Hq}*L~}CQJ|fTO44hpo6-slSrueFt=@6#1;{;QM)I@AHimn0zgvs5xU){w0g!#c;s{ugJ)zkn|fAm*FQ|` z{NjUJ07#uvV*Q%NL(e7u@tb*dG|CTaq{~I}xR2ws!U6y=K^qDtVdX_>9c;<&6Go6Q zEm2B}?>P}g>rJgKoSyHdQAAKpA_ECgG%8kiY}!>cqOTh?_u4-1E&k@@-~5=@)bBGk z=)1hQKC^k9o_q8!AAK`?d*=Ob+<4=SxV_{3n-B2U@`L)pTv5PJed-Dif?K`OV>JO7 z3Gm!UKJpPew%_8X{*(vKJv?we%7gAM9-w#f&zsmOpc(X56JURTpYZ^-Rg|plP_rrm zt}Xx^n<7X0iv|Rr3;ruYqbiF05EQ8cFgAppk^(pp1pZU29qbp~ddX%=J8We^qYfN8 zXh3<8cS+Tbk|72(s{v8yJjt4JE;^ZvqAI01j2IDIpg=2Md~L20jABJ_60ewvNM<6@ zfm~gv!X!Iy=oZ5vsUY#Eg#9c{Nd8k>U#^%y5te=^jv&2-0vWf3#Qg7YwupxtDMl1< zoO6QI0^yJoC>-VDb43MDgsGEAM-NH4`Pvi(kYQjNa}%KyDLJCb0!0fZBtngR0Ov6N z=jZ^4G(nuI!|aovIsgv-oeG`BJki&(@gkO+2C~@q?JU{+VSzyM3HMyqDd`>4b%~~ft1)$ z+Hr$i$234xB8&$!X*N5dmyh9**&M=cn%gtS}hJ*LZ(Phgo^f9 zsB$5IOV0&~laWG5z61Q}=ZTWx4%CrY7d0@=qS44s?e|k2<%xL2ds(&1)Fi#(Em5xE z!SJCl217>xWE&{{IuDt#$aM!Ir{_fOpD#+!Zic_CNLrLcvXO8yZptC8We32o-HyjW#jRn_y$ zg!JP?!zA#Yg<_J$*bx-&F}l5<+7>p&Vh94$9D7 zk<{Rmw_}Qu%l@Xr#MlQiqR>$x+G}h=!zWdQ;&NdZGN@PSND@qT5%D_X1II!|@s&tm z5{6fkE!kf=}tTpC_qr#qB*p+%;PizMd0oOIV=atVGTv+uI@<~ z(PI?oCCQ-u7;0Z=862V;y)i7K#W44tCHA8EwCRCK#Z6e&X6q^to%+JU>bP+sz(W4H z0I+_WpYs$K2mYF$^f#>F_B5?lFLOS3<-&ySyBE6)EONM5 zlzJ3lc1<)(s1M#fDRzVzQotxtv!A9BOH<7B5>By&8QF9vsbu>!ielemMK_enD9rZ{ zJcNyZCeMA2_JBH|fJ44IIQTqAA_1d9%g}NcDHN?KnUN++Dbh^sz9|zb6!$JOt_);5 z(~N*@@?RFHb;)GF8(x(9O- zvzs+;daeilq2eD(FDJ=3A&VvYFhtqalpnlf95-E0{A3ny5D(c zYwNjhJ@Uwo5uD?mdg>|q%x6A>4)XtjPyG9@h52xORoum|Rv-HqU3=*ztLo_8=x=`W zo0tEG&;L(r-qKcVD6ntUBzg2(KS1J&(k73(r7Z-oG=OOw1AKl=BNo_VT!P$wP&Pt~4bE zO=cV+Aa%nNCe|}X$?`K2K2ttdhmR+k0Y{Tc9F*V!iH3)ytRJKSaS$d~)9v4*?o)~g zt^(|1T0Y|WGd5-^Hu``@J(3{-;W_yKb_lOw?VD2D1I@_RC^&zN1y!dHjReGrWSIh* z=psfQN|!{OcoI4m2>w$tG3+?E;pm`9xH6w6MdukNn;=IOFFXNuIJL$}$!Fj8 zr<6V4VI*vcJl;bdNc&~31`uj*BF6K0U2=Ddq&}morQ-Zfl$Ki&My(_lnAsyUeW>yC zXd%29Tl<%oB$HqGpR&7H-VFBmf|^Ba0!4_jO+1fdk)KQB7H2#D+?b`cHGE@8H?hZ` zsW=aFblR@+(_iQB3;eI|bFt@d`2bI{sr&b<{r%_u<0Fq;m6HF*AAh{s+1a6Y-g(Dx zA>c!m5Pzr&0M^FgSAOMJaL)}l;P-$3_eF7sfAUZM`*${%)jNxdc6jhy;|X%T8F*{~ z$RdGT76GtWR6Vtz6$UhoO<+A50J%U$zmlNGs8Jdj39V>Oa*sz+>Wm4kVr+tRaz!4U zHTo`}zCAHV6fh(Qkmwaj#&uj@LLhv!TtY^D@Kg+=M+|c$rPtt>PZ8E4MBCF`XeDmBOfk=)OY2cqO8A!Og+4c6>}XQ6Vk)r+AeL(<35^6W#$3EwJc)<8%@iJjzVP zq*IQp&Z$;o`mj2fu;f`@!L!`dtXTZbnf$K;Yf_^~y*fp_a7hvNIQim0_0t#)Mg1DI zs8BJ?=x|(*yY+Yh!>X}JG4S;wA}=)zF%Syc0E0jt8Q=L>9s&Zg0x2OF--pi|KB_9J zkkyc)oghB^(hhuz3K5DJI@eWfI1r_`WT>)rU0kLMQHo3@FWn!D zq(gDbyV0aww$$kwz{e2F1N#7euT8qdAC^xTYWs z7lz#--h#v_g*J&`H#X49!v6UbI0dV05x`KF{IfGJ|pEt1mg{c zNDHRbEfHmfvsiiC(A3G|acdKHd8Kgn62x)W}$f+Mhx&ca+(p$gN zVkJ%-o4S{=ih#F2_>ikmFFB1Az<>gxQV~*_MbcV5I?Q@P$l_C1pp=aw|W52KKpE=gYfKc|MuRws`5|)|N38l^;Y1- zFYw6w7!R6H@re5b=bv{ox8!!dayRqePw^l;&Y}{P9gxjtR=3|)G|>Lm4SsT|ohIll zseXL1RHq;Bz|+W8;qbv)M1?+rvs4M)Oh+ z!P@&>AgszW>oLc@o9?s@alGtX?>iqj732)@A+^F?L?+-)D?!Nx@a z+{I(@HXewltW!YaJIViB)mDQ73|BGhj*VXRXdc%#uw+Rg0lpeowUFSrMR2SKH9Ko~ zl?Z5VdQbqiTELdH1B?Sw=pLZrqC>gOwnU82H;w7{U_j{jQ)%c2*A|0Z5p>d*m2;QTkIWsgqvn$&V z2v?m1v(>z>Tzf_!`HoN!l=TqIUe%075CR7KF-U$Qu`iVX!}atCo6AIjy_nNoEocA^ z0U2Zh+PDaiuK$XVb3ri(!}ln4^!SpvwMdC%xF?9M>s$#pDBin*E=u4LJ}&3-> zjhpHyYz7~*5M%=_0|=y$M*Pi_NZN^@nA{G2r$l~Y=_mAI7VQ-fguSb`(o<9zq*@9P z(>Z5})T+o%^sS;+=*_cPn3?b+_gqb=RI{6r%0tk3d2sDgT^&4MTZOu1nzAl2$g~#3 zF1vRzvx0vVm@*6I4v1iLzRluL(C~B*ASC^wfuy(eV61(obh{C*fldwq$MhWTa7)u8 z{M{vB?v7KZrBe5e}453`ARw=yz{`e? zzx>!^f4(c6Mw9E^fB*fQ_}kyk;RpfnA^%EQ1a`2IYjPW^bI#DVb2*t>O+XG*y*Jtupwb#sCAD0YhVt6E>(gzDWC+(%mj_=d?2`e;djJcn+NN?4EFyxKYY8@24F70wiTu@!6t48ZcnrG+qr`v5vCRZkS73>%=s+y zS4k$9$g&p-Hn(!`<>>{0WaKUu0N}J2rxgIA&pEvSpxh%Nxk{{~3f&@l0~Y{fk-uyKfC4G*7|vNXuqPG(5Eq;z zs1(ExOhpBNERy2T3INd>pDh3&bG*2WG+L$yKhghBDn#U_5<=s?I~{d{g#R$9lt4kPcC+ZYY!ZuZ$ijKAcLb!)=vWHOs$ZbnP5)RQ7j)dlxJk)IgO2fcw$> zWgaFW{h~nL-a- z-(6o{U--s1zVTXk4i7%~;KuXMKVMzEcz6AUFWkNk^SPrhGm6!H=<|OQb^fE-d+yv5 ztKa**-@C{o^1`B~zvChL*F1FpAO89O^54GA1%V%P=l^G1iK;7h3fS1(FkCEXpewEK zW-}`+HNXz!1|iTW26T}VMuIJkxE>^Gsl9F!MAA~vh?1U#G;nwn&_cpIOQF8sB8h^o=c*{KE;z#^4pX&!TTLXfUrL+-uaQQP;%bi6aRW$!lNY5ADFYBsYeT z)?MH*FNUWyRh$UI1xdLD#9f4<0EQI$dP&}ok}iefjDx^akzy}1y}JMbYO4$6#`ht- zwvYvWNw^@S2U){%5MLDpC47u?3ebu6R8#;Q*DB@KOWyC!#lk2gEF|gU)Gu~L_o4Cq z>w_^pT4a#t)E+)}_*OzrXby);6D`JaIO6xQePH<2uZQaV4Ax6eIFL-BT`voKlRgdM zjG=L`p@?W?lGcc3nk73qekoKuNR6*04si_)yPD&D(638q6XxFoN#}utrA*y`x)x+_ zMi5^^QZgo*ELdpZYk)^~qv2Q>6Bu_z89ok@9obQfKwHi|KcZX`lS!QfKD@g3MUhiOrxQxR=$t@XmR#>`S@lkmh)0lq5O5wHI*qa}lk_hIb}Zc`1ql zp_TsKX5|tdm-BT4U?S58FIwjk?u6 zpbqNKkZWUesP3)aW*GGae&v7RpWou7^*{2{{!_JH|Lum^`04icH{Osw!qZRRj#H;j z;pEAu=WPb&2m$b+qXYE%0J&x3;fEhS&I9!ZV0ruQYW?ww@F|N1;Hma0KFBX}DtHSg z{|$GA3j)n|Z6zz2hhh`|ZZ0f%mQ|KIOlim7Jj68z>oqB-yQHEUsXCI7l%y z+-#=g9GMi6!-dU+;C&*>;&7$ppAKfZsjfrCB~wy5zHn6`B}Ep>z!U_E z#ki%1$dSM(US5|}^#R2yH4^2V>LL_hCl~pFppq#LJ=qV=o1^fM4WYJ3NV%&EI<&eF zrjjrRKK2@YOkXmg6lMjMWD)cLlUu`0j2sF7k)juqQt8y)0g6-s4uUM4B(f+{M*{hZ zSj$Gop^VN3(sif!y{VKEBfX`7;l=b>88j8WO@N{`SrkKc*RoE{zaFY`t&|YiT&aQj zw}R@ND6Q63O#My120v}={XpmW?>}H7z;oRBe~ojhx3{*ob`y|);zllg*oFR%3IGSV zJ~n6P0YoeuQ9b_n zlT1Q#0+W$N33%>Etd$XP0ExveQA8;%IC;yVJETYoHf1V8kufYVDUwryfQDfJ&Tk)NzxoZR1d5f(hC`%K9BpGK1nV5~})|xRR#< zfJ`KT8J3}C{fDvwz_4{e>SzTGI=B`Dp(UxSiCtT*N>2P2txjh%#IEy8FY!}e;LBr^ z|L?JT-?!Jh_`!ep50AX;o+7LJ+x*H{Rev*}wU3hilCQW0NrzH7tPh``;sT?f=nZhu@ zdiopR`1*_CDX?Z+#kpDa#MP_SGyMJXlTXr@zx-vxHt$CTfFlIJ;UEBbkdY(|*nj=e zAN?PvwzumuW}~`wiS->k!0+Jl%3VCRKgvV>7df~87?1tCPM$c~@LQPkV%UPv?CxG? zdVuu}WLkiOOGB&A(0`L>|KT=}^j5jn$B&euixta5@&Kg89)%qrimdKTBzEt;6?~_| zcmaZ7%a%|nQECIo)GLtu75;x>q1ghc0Oh;LC@BUNisaD*_&X9}dOlt^OuB(g4OCdpjjkuJSowFK|vuOt+kH&L@J{dXj~sE69NT{ z><1KKk0_wQ_%DQBiWs{KFp7ZEAjsN#yiv-kG(#Ds64r*I+^hss6bPOnnN*f3^#@!` zgP77FH}grf$cpq?MU6oOkb5enSxAsCkDfre%aFc*kgT*!5rreV2FkolDAJASzi{Oe zHkF_W5Ey=EaW;j5>%Ns9q^m@JX6jH;Qa?af&BYX1@eBb5&ny_bjM=_Z$dw+@~ z_|;>+%#iQj8zBPH=ZJt8LyW)M?Ipn(a5nTX-7n~8jG!8k_G!a1tZRX%(`Ch@RKg`Ke^+N3;fV?{EFxKHoR0V=~aeZUp&s= zD%}5}hpLB+tJijf05~cDn4hnHz;FH5qt!qD%%!cJGv>_3hPj27)m_!ne1bFhd-+m- znxFP5o)dR&Z*4bo!P*7x@9*1*vEtWP;pFf9Wl3`#MoMfm1t|}7MQ0TNPzseVgd?Th z2=pD96fYMfn4qOrim^6-0i_@mz9}mJq*T;{aj!FniH07xj6&u|uqpgh6lo8XY7ottqaj5>w=Yf=d0=o5!RAT#c9y+#ktHoD6fsD}anE@N zniwMK*Vl3QKtS{%(rHkx{|jQ9O)99oV$R-1$$g>w01XlV-Kj4t2l`N3IY47B^Ko9` zKRj=_{teHc>Ys3B;01pE>x{s@a%AoI?tCqeF2S@frUf)z`8)2oqZ!Q%J*gjf;7e=n z?l-y>j~K%qmFAtVeB~>b7srq9Y|+*=E?>XHCV*GC7RH#PkxCwXdY^K&WgYw`^un(X9 zK`{Z9Jz^DL4i~vrAR>1#=&k=eO7sw?y|35oo!qONfktaOMR<=DPbq^5hCGoFiV}-s z8RgOcY#xbnqZ9K>eg$&)PjaRY(!L}4i%m%wsW(r_%;q#rvGWrM_CW@S4v?BA9;msm z(9(uiogqmBwJLGiW&AUW#D|9%8F%!-s`MER4`FSV* zT4Gw>At6z3)>}hJyTKCWQyn+MaEMzmNDX_Mx**Y^FK3XTzHpeTZJDAP6-UUiL~cC* ztOj7c!xe$c{D-&r$*=NXU*xIyEaRPjT(O%PmemWpJ3FsG_Sj>);Ya%X=Rd!B?IRzd zd$+dMmo8nZxj0ZCb^Fbc2Efnb>#zRmuMFz~(1i;Z%-wh2jW2%ji*;3Yktz&{;7x6G|HKyXBZr?tV^1s1Sgra&|>f$iXs9cnZ@e$7pW+y3!1iMr1Mo| z#T~wBhV@CBntmY(jf&PknilAl`piceR^B$RXwwu5-{Gc1i94oUb6MOleP3qT8}_H5 zl(ng3?<0zZp9jBY?dN_rytHy+Q!_WNQ1g;k`@16E4~q z0*+O)SPtVYf{LA?WG+k2|4?QXsF1UJYJcy5M@6$fx>zW107WPYNs?$AIiDFZ=Q)+! zQ&O}=f%gwRS@K&;<718wLW&mSB4tD50S&7$QLzt=Fg6ga(xX_NBVtcRxR)l@BLKv9 zGm!$JO>I=@CQ%&qkD3jXK_p-rt9BT&MB=3_s9j)+imeG4DWruFfk1hz4>4R2Ln#h9 z=^N5GXR~`BNw}&cAX34;QONwGLcIg{q>owWh(%tYPkN3#@4U0>i+hLZBz3y z|ISc<^9)&WKekZkXMUSs^nK2eezd6fUOaQ+;-!E4Z~yOI=|0?f=bdzx?bV-u{(1b` zul*XB>wlD(9#H`PUanhib)&C-^{bmFPMp}hc5P#mX#ZqY;TE1FchCYK=f^+IQ{_{9 zw2$)vf8p5nc7p=22!Pf8exnh<+ z?+K7!InxZF$D!z3YzZiKv8cHFn78Y$nOJ?8W#pIeAjfu*NZ6}^U$sTVs#6hIy8q)TNq=c*==dTL3kH~dh=e8U(mK- ztwp2tXNCUkhih%sTU7HJZ}T&r=a)Rq0&71oR^i{6pH$WQWzK!xx^Ux-?>zanudNEg z13FwR)=>fA=<9HV0;!`4tXb+J8k`S4_~6F9_x{7PTPKK<{>u7Re46LUC-|Ye`Gq(0 z&y)PU)rbH#fi&^FSU63rLF8k!yDANc4pIP!7JlQ&j+p5MfY9fe<|Ugcof(w6KGCFW zgS6uGVHW^U=-mQ<$}Mj3UMm0;y)IoXL5z^3BJz4n*#PRS|G5@+2Yt<) z0>GqVMPUITzHWl(T@)A)mAqT3*bv(+O6QT$rWuYAzyEuo0HAws$tYCvY!JsnM_mus zYR%vK_M0DGTZMoA`L?O)b$;=)RO5$S5O|s&|MC9*&a({lzbxfS_UF@2KaI0z&%)aL z9f|vyBMpG}>X%La@4x?k^Z4VBbLFcN2kF)!1@54ZD7@OZY(um)l}0uoq!fr(^v_TgH#$4LVqzMO=!!9 zI3mUpLQS!$&xt7^@DysnkqfKx=+7v#So8VOg?{ZR2Ne{hs}Sg;G*3u{Qo9CXC>;!` z9q762RcaxH=?|Vb-)LlXwl0`fWJHEn?olZyxIE}+!dceB#Mp@Rrc)?P4u6F750hk1 z4c+abGiz)sYwV{`$d8x5lR)Ko+(cDO!ls1VS$Ff1&3!mPTM_2OhY=>u{>I(jpI1tg7Q z@AARtXaV>|4I~f~@O;>)gsTr^{fNCusYP3UDU%K;CnbauW)hhou+wy1D>|taZypay zBl2?E-e{s?X^H}kBBYp>lL$m%*bYI68j|!05cr7@{B0X>BV&Gg)!_Rryx+q7xw~&W z{nQAd+oLpwX1n}{%Y4u``6X}ii{IjZzsbSSMSkr?e%33Tvar=(_3~=9^NN)G_lf_# z_ufla`J4Y?jynG42m$b3M*`Tds)f8H2P#tow#>KPry#jh)KR7+-aIun)E+?^bywu=7ZwEoqkvgaBu^JN zeG3}z5cv110zjDjdrpWG1g(6d?csPnD%UsFhU=^dME>$b`aK$zRQ=2BxgsUa~mUi z4UdQ`hbQgi;yMy6E+WW#p3r^vP&BzQ?{-D!cklbp1lhnzK~4CObqCoqjAPf6+It@ib>7Y1~FQI&RBKnRmSfb44>s)kG zgd{Nwhz5jk$`fO)0Nvlm<{>8`rZMts`Tdn+=V$Yh)h-`|?fmcxzv_9;WuD`cevuFG z604cL#ub#eszrTyxnA#Z{&MZMmtMO5wI`q4pO~W_6#Z;{yLS1xX# zNZ?+D`5>ilK{4mqK!(_>UiK)Z>!x)MP{z9iH0wBc4LP0L?ymWsEkKsfKlQzelK;%A zk#Qh}oJ^6Rw+3lYXDqc6Hfq6MB<0EOm=d-x@(}KZ$w%xWEbjPq-`j9OpmJpIRiZXYuh?(FCQ$N5ED{Bz0EW0Pmy37*?0c}gBMJO@E4s{Uw(D0vZ@-Jk6F%7h~T|_`iXOoD=M?}l}xTy`XYL9{W0!@uX~xFzyO! zEOm91APtuf5descP<@hv7u@;DsAInbp?#Q#{f9*ZLq3SQyr_g^#E$}4UsMzVmncNoAQb%;dQ29 z`+>{fcNS#bt-w%O0nh1UCe!yoSkp{#ewk7Lkh1M4X9OlE{2Cn~v0p;iG>ZxVGQ%W@ zdn8FC$|bVWUYi%~3&B%m8aYs0lwxFzbWJtA+E~{rgP`#MLcu(779nK0_=@fWB07xE z*)k-7wYsVz(x%$hIjkNxLyO|q=qiH)iL-~;T*9J`nR0xcck3O_G+3pduJco`@l$sA zSwG{azs%{y%iNaaj{o|_)v|u+`t|yi|MZP-yr#{C`|i7M$sGTxvsH(M{xg-jKH~cy z6#zaE;(+!awh&-iod7FjQKw$`FaE{->__;g{k~ftr8kC1Ty^1yo$gLvNeZ zCWUqPle}G(!sMSkje%%yMOsVHj7C@xwj2Zm|EzG7Nk&otbZBv|r~v6Sq1jNSz+jrc zm)|i$VIE)DmFD_&Q~=q>M%-?M?nD`c(Ajrm!_@h8?XV~#%`?A0~l6+ZCm>$<+gdCOb%a_7pk zs}nm<1)cwJkUj9g16BWzBeB1u0>Fp<>*0qVwhEZ``}odkb(;3}PIH(4G*85vd2+Hy zV09bM(7Skg-o?J&9wa0l^2N8w9fa z85%)h0YENUkRT?BV!q=XwR@SNZ%?rfMbGubp0rq8Q(c-+=unOuX@JBU8O9Wfn`8Po zgh{xL%m5X|ILXNrfYk9-=sZbnE=i?50-qPTnn3APMSzOnqNsMXdWOzd&plKKO5Bf+7Xo|MRd^3C9-oQ9rb`qDe$oTPnE+Ivd4@)8Q6XiUy>0Vs7_h{P->+efB9bD<87H>q4rj}D{a zbm#m0cmzX`T<|G(BhWpGlazw#W3QwH0Otf7fPUZ~WLGV_g;8m_lYN-ST`?zh!1qm)JIv} zQ32rSE3gf`bm>yXu$SgOFjb)b+~+>Gl3w)CLl10jZT-Vjr?<9lTrZbr*%ja{(c(^? z$iKj|^ftbew{em0R-VGAcrDnr#RFT!vz5N4kN~sAZ_R(ROo2|l(}DM3j5s`dmElqN zVk#J9;d{}+T85NTTX%F(1a60{Q+ah3&F%$dmLzJ)no?ZI!oYu$JsgVr%5(Mu~Ek zfML^?^7fSJ0ziPmP89&W_f81n%`E_=s5wR26jGz35;F60eEGgSbDroK`D(AEcEV?LtZ7M&9`Zw{gO6uN1ue0Y~q zfPz6FX(mGC`NJTZz^&wZnK4~{?#E~(b(M#F4X5bwl#9<{-qKrOCzRUz`g2mc`ZAG7 zvbRgWV6X*&!J%3KN|-DFMR`3kb99E*fQV-cc@C2GUbH5b6#&Fw5D+&MGNJo`!cUu& z`pY5>QNwH`HIc}4a8C`bWuHX`TUan#^_g{x4knv}aL1l(*WQYY?=iaY8lk?-2jFBM zFHlV{a#?_p0Q6eD+`q_K#?Lm^8*e=N=%d%9!NZn*OU`wMle)lA|9dW7svmj8I-wtR z`^}LJ;D_eR^Zt1G<(E+s9-cdAAvBNt>?>dSpRX_0ckb}Sdy@+Qudxuvvpi|JfO8>9qm|AJ2Ya=pa<} zuvj=NU)_EK2-KXrAB0E_xtA3+E^N73>&E25Qyev#H(WA1rWWW&6LKjxZEBHt3I3T9 zdZ8$3Z&X!7-!&>Uc?pzc!)=r_gn-V^Plu5dqnhRPK&&-2-Myl*4t=Bf3f{ z2yxCAV5f+3>?Vu}0R$QV#W%o(@F#t=hm%xDCRszz#2*_AixDOi#&V^SShGZ2BGKTU>LM|S*AI%~U%+s=> z$jcIKUrTW~CataGa$HDQbYL5Ntu3D;Qt3=a8tuOtNI#91eVv=wdf#$_%?=;UChpf- zPCuB>PjB&Qy~QuP%ul_{Ki^{1G28XQ{$%JsF7aExyMT#PTWlj;GZr2d&i%J+grUijHFOiSCb3&S8C?!}&!!-62Di4K@$NRT=5u~N)O0bHB9fD zLA!-8F{_&-Q9cwIlq?T?9^e#LhtEgw1f<{7dW`u<9mhGUX+N>)=Cjva*l-dZu z5(E-Ne+A5Vtp&XAKJ9ze2m%rom;6u_{I#w#3osu<+- zv%GD!3tBN^?|m6V3aq*sKx`~xg+5m1EZn9JO%Wi}gK)OsrU1~0gm;jB%je(QvvK@I zKF%e6?iGH8Rq?;VFTBJrc#WU=Dl3J(&L{9XR}Ov#T3uoLzU%y&J#HSZ_?YW!*RJhf zxpHNX;r^?cG44?Tz#Jg}%!e2aFevxC?g7RT4m6#3U{?UJHiEm-!5C(<@v)D6>?GT5 zoW}O{&3vp|`MI3%*SGQda2x0IXB$yLm|OPuS7-LutK+Bk=8A`kVRw)lLEk&)0zl05ypTNf)03ByN(C7UM{6PkY6-0Emj}` z(5UW29oT`|<<95Ihd`L&w;mu7;B~4;)UP%jWgTZbVFTlSm^)F{JNXCSa<`k7iSo7aE5pIQ#>6{R10&0 zA3x4>bBh;_tzJKXSCma&Dp)tb@ZZ^Tux{)Jt@Yp*ABzD5%a%f5tBT!1=b;7QQU(Uj z;n02dbq0JpFSx%4;d`6#x`*~!RO6cv$p%MID&D>O0TIeXXo9)Zy^fq?X_h|i<*Hm` zE5Vvi7XWln7vk$60Glbu)Sy5JX)Ih%=}=6P5AHVKkas&x5=U<&lg8bEAUT=4?g@L! zw74Ti)u9N{ryx~oCEyaO|3U2x*_;=FY<%P@hjgo^i(`f?4r4qGF(W~ugIWjIoWwZY zquYnEHWGYZimnzR8v6)ALP|Foktno24`|2{NGM#QXqEthN=gLhhtrK3*8uV${1K$S zrMHlb)FD0F1%z@fx##JRQ#gYIzD^lyiU-o?vGm0A2 zeog`xsZ3tLuf8n?75_jzz;#XNpGhvHpPb#azX>#qWpXt^Oo%Y21p3aO^Ma8fjLMC} zfs$Ng#3Y@)F~CEhpAW;B4Qw_iXu(k7K8M1pY+~<0@;MHXdk}UXJH>(71IjF1`(qra zVKB1@oqAnLP*;H3D+naPT2FSt2`bpph^y_-x^{?D6j+MJB2)1bd{g` zCg%cg^7ODU{~bQ`JKWaepLB&Q25<7~-Z%!lsqp&60>C$4d1Y}=$MwH(;R5ct=briz zTL?H(^*<^A9DPl<4|KZzlO~TX0zCKYzrJn1_;|-Qv3YF0Tx_<^0$|F(%69&bvml^# z7GR4(ux0>G)mA?Mbb|XiC)jfERMqMU9OtRaKfwZnTrl9`!J?{;ZES8BTL`EY%PtUY z7rDC845-~Or1?)B76@WdCck(9gknt{{EFbP&Ih67_V+QkG){tyU^%BiYXA}P5sG+F zNuKWO94I_P$N_<4&P0%^(%#|1WMc@d$ApQdz)7d94~amB3Oi#|F8U57ozyUVag+$8 zl=LQJFUq=qk@B{#SYA&Kwle3UNVtb7X+)Lu0{K8_Nl5-f>Q3MFh2!EC_z`;J-Kctc zafU@vm(!U`a0+>5OmO<)6K2KTbQ29cXky9)s=YGL0c!Y%*#8bbE*u6EI z!U8}jwqQt@ztEs)Q7I#4*3(3BIFzl72;bVh$ApA9S%5-E%>PGF!1J8|jUu=!CZLd# zvY-q^8P^LGmUu~8>jbh_3^;^STORgOhZcj#3O2K#TvhT!GNma^Rmqz+kCIgYJcCJ$J_k0OZ*0J zaSU+z^uZo-0lZ#w` z+;q}81@9GYTWv5LB+o&NHYUS};EbbYK~17!n#tM*Qo*7|DBKyL852hhfub!n=tV)N z`1(N#lmsNzVoC}j}>!NMV<{g`iD7FxSOp0CsBV>u@TvCs^6xqu^ANs-bUnZUs?8dD@F^%*1u zCLk-01B(1v;v*;aA9FzXck%p+*TAA$YjFJZ+{>I(w~qMtNH||X{hu6>H!2x7@yv^7 zs?TPC0IZMeL7`|Skar6xlY9Qx+q`u#haVStn|*^Hew!h=Z*g3Hg_*@yxh8RiWA(SG<}SZky95YUnL3X6`9}qSqp$ZcDxj;VbrgZ~OcA()*hRqpyLpV2Hqp5X z)cm|+-_3^~et3fgK~Arht5fxAdCDwm?*1=Na6}{R{@+d-B0fE+k$|l z4>Z&OUZytqjhDPgZ8RtY!osSQwwlLicR%ovo9CDX`k&zEwQa1b+uf?>Z~PdUL33bO zT03DY2Rdb!#j3Y~36+RFc$uU-08#h`33xTd&_j*iE2zdp@nFjZlT&DTh&`{9!dZGj zhK!_uJ)NUSlV4ZLLrhz71@|iUoH`)i6eX??L0JiI5o$6VwzvSG2-C%i^pee7>ew`@ zcZVH03ZUbVWaEMWQ38@1zg8o41*7>Fyn{<83O}6Nbs4NqqhON+jwe*XDL621|2&}C zMa2jxsT1jSGU3@)k$zPTMFFsqok1o)ApE@Wb*mF8q*rp0D|-VN!sE{p3CW^Y!U^JW z&u(ibdbz;f&F%CNmBE)Ohq|EDX%AMr!{;Z5D-m@VDd6XaJg*X$i%9S+fDb79gj4{4 zN!v^oF4;xb*`MceRZ1&ZK&dYh$$A^`JVVH&VEM)8f?V)K zf}M4MghHTX8LY3;q?G|YXgd}A38WHUe-8NID2mqOaI#4;ABAkt;QGPj1*OP8CyThZ z{#9Q5XI$iJC;Z=-T%mISu#a%J$-dg!?U{9JvB&&;GAnz)_A%{MwV1eI6GAcoA$8d0rNlEGM|d%iA_DV%z-h zZTlWP0ypeqTq3YZ|B}~AD@i;>@wA2A}=_K=1-pDJU$z|HuZ+5E5)X0ZH|8fP^KQ?6)o^z|*>Z zP)XKt07uT;56#Fay6$cfzxy3CD`yTA@0H2UG7UVI!qvY=?(qJzAfWPHmBG0`fu_0?0EHAY<>~qv#A}aC2gU9#kS-Cjz2`@4V-BjK+-5n-|9ig7_zxHx!h&Jxf&+tefN-^^Lsyy7{hsfm9nuva&wY zTy1i{$8qkymECIsL$acLHla5wE(Yv3$vd#i$G*<-?sfiqqpWY5!hntPZK7`&jBDPp zFnx}VcR0ee9DjZ;`0uphcVD%yS?|>={yP@}I6!`fZM?6u+COvrm#hImd$ic!-Cm!* ze&x2U>yH4oKuN#-%YV5$H}|kbfUEc3OV-9uNc<1pSvmSTDgYdP{XKv2QuV;2U#~uM z>#bxiUaUU4vr{oLpxWQxuNX4KV1T;X+}xz=*Ei|p$-4R5)vGoCea*E3o3t-a@S1Z2 zEe+EH7AGpIH(9)3*||+vguroL$WHK*c$^nE))k=R4T8Wv#)&^Kv;31-r=V_x3CR`; ztNz(7kYMrglVo0H1p$&Fe9N@4?{OGjY`-6-7!XB&Ym zNS1_}COQ>bBIm@wWnF*uIEO3NqjD1pyWFK5`$@_+LMs-ccAT}5!EMWo;r$}fV-Nvxf1Sj!_ z&|xL9Wfpg&g4TTSdEFuedFKK_D0NKmC~c=F-u$5Om`H$C)J2rc#s!r8Izd@6K1dy2 zD)WI$+B14%ASleyMCE^!p>d>nDZB3Zi5z)dU|-E#D)hR?B%&h3np~I*ipfEJiIjo_ zMFM>Yl-0>Q7w<7a^<0%<5U*=k0DuU)Ls)cE*U+$dM~OlLR3Lbs`bgp!kc-S1I`0EA zGTBTZGsz-0X^B@%Lz|2!&ua?1*A_ydNx9Ba@z-b9#3RQY*fzm$c=s&4x90qt%&uv> z`BisiZQ^f}^sD^OYaE5{+73S1zjK$L+xs^0-|rIrYaIK&({%7_*d%|4TV(tdwuHEt9bqO&(!zdKMfIFm@^(F`bPzTqp$b6h~QICeU=`0ps^98`*|gJ=9y<2 z+JJqGDFiNM;85T@4?XnN)5}x)XBYu+Vq;lva?^jY=Jvcz^0_K{j2Agh?ia@vsE+aK zd7M!LOdr6qvYme}7Qng>nEE)n;z0XvT3Y%5Y!QHJiz--J)Bq<2b_Lw9kK3QTMsGGq zf`(QQWEicDq1#V$(*Sbhe1_+ttBSOHNo5m}UQF4!PIX^#sHi&*dPUv3Ken5ykdq03 zh;o+irb8&G)>++tSNmdYEswwbJT1q922rx%JZgoIKyW$XKHLzHf_5lTnkSM3n7Rnw zKB3!Pr~TleEO)*RSxQ*2R*&Di0g>dIkU%6LXxNe?@L0oDS=VVGM5Yk&;9dt88B}N5 z@bHqwAuw|Tcuz!%+Pm8^{wwDs>Oq;@v}VQ58@pGxa;{w+X0k!+23fEQnp-#M*Fn?t z8i<&c|p!NW2*9Sk=mc|iCZL$we0%5P&r8NzRg&qLW3Nw$^35uwO_AcPf1xNh|L;!?>xPk60=82+s4yf=47=t|I`X z=4yeMK;Db&^{mLqkP5i;@>m+ca|w}G zUh<^phva`$AEwGVT-7%=0L5Qlm&)TGgB;(pRBMoH>4~S{~8~N6L#Cd zrxxko-cJk5w<`QQjmo~UD1d6;`e^fQ;pCjV_JsYWE8isd7QW8~11e7&%+pV3|$d)i@%JWBtY?am1x$15NaSi$o_|Pyu9CRfLnO! z(nNw6u2znn#Srd}pQWzhBHPIisN(uU6^xHzlp*p*1H3dIg)Mf9mDoM&zsilJ z;rfrE&CA;1Uk!4p%Pjq(H!xeyE5Uo5*!ls}}Bk$FK!md9YkLoT_GOjZj5EN@# zJIHYz38!@B3>I5nsVTIZNGgZ=2d!LJNVm(`xn{eUpkL<|?TPky#D*E>Az+mD=?g@xvxE zme6-Ra@X2Agg<#=zT_1@&@IcgFpvC9yOO61H6Nhms@q|_&%f+j>$s-&)~>NtcM8p(m)FVt4Yj2Xoa6+R1q3#()857gu@a$0FH|^ojF-nMtQ=4I1G{KUew!vftTYp$up+W0@FghXF zXkfKuOT#7q*&+)zxYS^OUod@vnk0s|hq~zwaaEyecae6#Z~bU0OHnZ9Sk<^8VAHfB zhFulCEg|=LU^OYV&wmZKwC;28dJIqP=~j%Y;LpVmS?kB8Ee14NGGaUtUiU}`uC^?Eo1S_&$JpMCMDJl_m@3MJ8;)NOZT_JM;bzdS zy5tz5HUU7LL|C-RR_&?PQ{_GeDmiaB^Q|cs)flcKC|??hB@$}VFVfh2w%55HfD8N76=J--^j1J1l7qYET7YvKOmx_x$4}Est~c$A15r{)O8oeyB*p-S#nOOugvxR z>Rt_gxCZrD6zp#7h$!gmD3!m45Or@^JLA%pZ7BTgwdo_>Hj$>W*kw<2cf4o;}mW7Qr~ zjvT~Yt*rHsDmQhv0+4{xYKnFNhR7+Xl(8OzPhWR-lQq1@v^By1JashptMEB>h(7c0 zG5QJFS0cQGpC}$IWAQR|Zk5-F7PdwGP|}d7OI;qlR7Ob z9E4|TYT@En{e5nOE#npd+KqrC3{I-6YTp(pxJ9wDTzHON>i&9?jUCYv?mL&Lx`|Af zH(%L;2Gz~$Yktjw+e1xRV9}HZ7?3)YUzqD!?KYo($zAzXwQ4%0ywBR$vE~St--2EE zSkkImVWW!)_HD4WZlc3=y}4O$@b7yTqR)N)<#LaET?^QdcD1#xx7Ww&{ry#qGu3)` zwW`;AyffFY*B4KmfO+;=+kvkyT)2QQeBsG8)9Vilx<4oPJL>u$6#$ODK1^ew4g#XS zSRj4%^nyqD_s^U^k9WJMF!hUtOO_0E+PwYt+c%k*wYjyiVV8TevANlt-#e^vaQxUY zPLg@a-q_f*oqYQ{pZ}t7px7+hR)MR^F7!*Y&ox{NW?;b=bcy!Xa%oO1S^vOTJp)D^ z@TSu26?M}|U$hBkx7RG3^ILb{2l`suEzS(3Y{p42B*9qKM2lqL&w}Q2acIIqj*Y-# zH74>^?fmW)iu#|!W?Z*{VW+Wx@HGgMxBjutmWU)@X*v1N6+LRXbuiF1K=HBbHo(5U*fARc+>(M+``I(e-48Oy}0kiWOu0{<>Zy&VObA&sT_jNB-2X=8j+nPcEN9dIu zKGylrQ(pvvQ*ddDU3ERsE5=~6>YhtcI_bFucH^l(rVGffJRfcUz1rhf-nnRYUg3L@ z^Iq<_iqH1|#krysWJI5?>-btPywBId)z|$S@M8;)Of7-U zQF9IJVbQXpt9?>H5^g)^-v_MyPR!Cye?D*D$~DLUy!SkuN&SsEb7(c zn(5EqaV)s&c)JGG#-9&WcMYrYz3LX>)c@|C?{FV#Z*TF(UTw7Xb~s#qN1DGgOx?7+boPR`Np?SN8^mPW_c&O5X9E^* zL0AmIQFH8d_c`r#X@BQaYqINoD|)xKB6|G2z+!*VKpxw8v+2d#g7|WSpS^CDld4*; z%>KR=-Lu^Ly<^9kpZxGvz2pRawX!sQ8`3SB3&fgN_VZrjF8R(APdu@Duf*ln%DauZ zpYq0a57i&yb=}hMD5*dCIw}AheMJcsMXmy@6>vwV6<{p}UwP#fdh^XU>7yV0sIfm! zo;=w+|MKO_!}zK0uIlBh%bFkOs^z)W`{^2d{-1w->ytNqa_i*gNp>kXvB|q!lPFsS z!WC~43?pFA0{(r)Y=z|ttM$g33jsK0kp`Qa+s7A+#u||~k!@lqLW3CKoyQgq*2@-Y z&^=+cef&jAh$u3)v0T!E~WjBfj88 zKwT7M&x?0QTKn;Tv-!7M(0)oKie2(4)1FH-G1<)}qY^ zoA+sZ&2{tHY*O1l?w8iaduac??hpX|nNf%6Y2QCU0MuQ#Z=n^M(wSVnp7#9ye48MC zpw_It@m=)^esl1;fgts7Q8k@GCrz%qf?asu$VX$Won(yMLXV5ur~-d;C&_P$jolO0 zT}R!9>5GoMkw-v0!ET-S5or?_JE`;2R){Oos-4!g zT@u^1KYw;0X(Rbn5C@@4h5wElzvyF~o z;yY(0GG-KLiud=6j0s^nSGg1|{-`3d$j&_}K=(woJV4Dx z@xC5f32>D>HCh3eIz=g<#%vTAE$Xx7-LYLDdIEVCW0w>e?~IKY(U3|9q5Xfd zEAh@o`-^#+PJ?US35qi##L)5I5tIheD)3#3{;yl-8gR8nZY zXWZyGY0e60M-P@W?o-=z++s@bfX?7Ff*jxMs|hr~J`p+hF|l7}+w+59Wt zG$F6%p1}w1e`^H)uH2>?@TmJ4T)%6!0 z`!7Ji5#BmME@q2E!sAJ|UfH|BD>Ogla@*5QI}vA*}03m@LT zR*j=HUTeMKIIZ#f$0*q6;xVilPdKKUbtJw^jwf}#!gW~VNF8?T_zZdY+Yhe;|1)oV z(dA+to6F9RVhyfzZVJ~3&w6YV`m!9>kXRZA9x4)%5d-km1 zvvbcqcPw^*Z@&3ve&;*i$(WaqHCssT=r{JwKFWz6wFZAICwiRw%{RXBjTL#>b;1cJ zY(H?|z)rbPj~9b}ycqW#a`7$8@0aDXoiM&~Bf!9KlZ$<)Y-^AFy%WYApUE0Ec z9Dv?=NbKjJ^E~i4EMy+coc9fJ0K)q=Fz&+~fPy2e_lwMP2jxYK@kk8(c&Xj^2E{eey=KzL#zkGA3-PWjG|E4okC@u0(J z%;FHxemDWL{6o%R476W9e^4g%{csQ@s@f-k+@WpTwmmc+?-D2BfrswD|NgNAb^A+p z0yYBgk0poTsCNKDM*xr1iT%M3ez0xl&Yg(j&ywXICKvh1V%SfW<$ErMB$s|VHv9`12)I8o*7xF*@x6Qn034}q72DnsGC=m zFM(V+<9^FI4H4?nUa&6b3F{vf}k4gq!#%A9`tMHgN4f1-SMqP*YT!uEKcAb+2@ef##27=9_v@s)7K6TSIQGJ37#o(;P(_2AUW;vJ*o|6K8>@Sc30B=&mDeFd2XUu&XmKYinQo zM*!O(JSPh~YSuMs);GymjGYAhZr_3ZZr{HBPLPBB6SRRIkXLaA7%2m!A%G(MPTt=s z{)40xMt8~FdHeG6@=v$lcH3{yd&3(J8vD8=Xu#6`{refVME)KhZKwbpErvgLVg%CP zB@jNK_!TkU50eY+TzP+{e08S0f2us6D9?xQ+O^BUz{9A9hQ0lJ++Lc^#n2kV?#63i zuXY?pbng;Te|XU)QlYj{E5VMw1F(qb(wcY|0naWXQmi!oR&ou1jO3+ z!g53S38{2VjRQ~($_o72dpXXW78-i26I>AvkE=xx9l-IZCf*IohchQHLGSGJA({0G5LNveX-za`m)ROc3 z87zI#<1gWC`{%5`$9y#7>d$j=2h{+)0TfB)R)KKJ)7s4T#%WrwyL zfb}QzDFVNy-r3K7_Or(cRR0V)%a_PU7t42#l2?@5jpX~KN!n@@=g2%fDd(XlCUj2Z z=&2Mfr*3W|BB9zyeTX5LciC{_wDy07vbK33QuFgnZa0Nslqv$MjaM$eTZy!oh*517 z!q8(E9=B}Xcpy7{mNbFotR)=Jh}B*=x3tJS{yh6LgX2RLMm#$&Bq{@v`&uLtCXj&S zw)#*(u)OP%7u1a{UK>sF0h4*Ozh3tKr^v7H?w$$vV6uN}6O92YFPTs{&6~>q{2Ev44gK{J1+kBt=m&;9N>Yz;` zmKD{dVD=2r%;G0-u6!xs03`RZ6!8Z9q{xux42*x?3jBv!GFxhMoQh}@#y@r4(7bi` znMvLpuc3nw<(0i-K9k{0`3#AYyxKCl&^a;h(ad=@^(h7*A~Uo9Q>C9Z=XuKiV-21= z<7Ethe1E9V1ppT*Lg^_m%c?nI-E%$!Kt5(brn5$U_P-(#ZB*aC$$Eakbj=)Gx-DN& z9f00yD1suob+@DN;FBi%piutu((($e2r?ik&k)Nc>n<_#Ij9 zTVhMTd-25=-=mwn;f5QQrObTv!yo=|?G0~u!`hYuu+ER4|NQ4C2z>u&iTE#)6Lq;f zpSyeaZiwgz8+2de$@zKu_J$s%@|Ben_h7hpmfUV4Z%#vC;~C-QMU_1*4sIiwBt@7oWf|5=G7KRP~i=u4t8@O!BU>xsveNa?%c>XkBjSIfynQy<{9y1)#J>~ec_8Z{uaEqG%i8zzr3*EHspSR;t8fie zK7$xev37Te*v}ltXz*?oAoIbZKIAg};vRDTGe(}obrxJ%{ZW1T20CyH_cRCW z|6Gw^Zu=GN>?$L1gge*sI1ni)f9}%E4RoeTaCr5#XNf{tDX;K2L<=|p*I$4Ab}{xB%HNlZ7xrTL{&Di$`M?7Y_!sTAz$@nr5v)kWN*3eC5VbDH zC4!H!K1$S;51C`Bz+SdW6e+xo3`Q~y-Du?`nJpc($Y+%iYAP>GHEEw(h2Wa$68Fj!2b%_{!(=#xQsX0~KOyRD3zC+J2#|&%3z47w?(~5xJX93C4 zH2$5iKu2cm!2^&o6hfd^L2+pHkh|~x`*a*0drkv|-gPOJaQE3aG<@97!x_lv~vUnn0vO~@H%p>*TH2Oq@pN(LOYrFt&W zn8tDdY9w`QrA<|?GIgW+uvkuuy%tuS6@GLnM`$X^nPeC*3QEt~)i4rEkO+sI7t>%4 zR3A)kYoHhnSLB4L@*MWAfzmkVxJIg=`~_W)IUWZk%H;fGzo=uvVVUKurKmZvC7ZP$ zXYo=_aKPfDKy=4o)&(-WE@I#LaCi`CQ)4uH3`B1S+B>ad1aMsP*J1VSn4C@giw-UK zQvVI(kk}=-0^A$F7Bo&LfU&v=JgxEV|3_@oufr6>LRAIO8*zW##*B_;n z+2UGsES!gfm=XXP>zr`uToyIWT=w0?+!=qdl8LRh6*EDB0ij((dKkE z{YW=7b_=-PYtJFiTOK)3CbyctQ&L=9w5CKw_sAigIH?ByXFq(l%k_G>af9vWIB$LZ z+I*i>&__os9Kg5reR7?dcHqRU8Jb@9WDWf)g8rd!ww{4X6!+$_sJzf{NQ{I=phqyd z3mZrCkT3nj2pvW1mh`IFkbH{w(HOd3Z7|F`TFZ#O4X+#XEELbGTlq(wW z)T9@=f1i60zkgj%%@{?s`O``{JFOH&=^D~^LQ!ni@cUMDnuj5yT+htXPt7m&l8au+ zpY3ZT9FguKPXCS*&X1c461T^Y#!@M;3_1C>?EJU#`5ogm{(_v5|0N}ZKby!Ti+b&~ zA40d;O;IjLhr2tU4~GbQ@+9Q?CE|a%oSRn&>F;cOE~OR+TSK@B3I~7<`R)yK01WbN)v?Gp0BsL|sGg1@{y<-ZPIk<-%2ZRl;f8hjB02fXA%g_><$3a9$K!KCw7yo@}Nt}T3_-(Iv#VgLz z--~e23aZ_XDFgsskL)<9a6((&ca9vm%j5vQMIe-CqA}0C_udPmvf4VdD;e@osoy$R z$n@qUFtbX40jG~E+R3Jr$CBqJQ^Am|Q%*!{Jg)|O&hQ3NpxIEz-u^z#ckM zi&hjmMfTQLL1d&{zCR(&TS^7==YpUMU!rk>rw+u#LGFd4z&^jxfq@cZIqd_)9kKlb7mU-B*e%?;xlmMPICPxNz}jAP9RiE-YGzKi)dFnuVBw2MP%5Rpw>mVR-rMBFIQLBZWplh-wz&Kecw$t-S|~O09FsDav*Ia z+3%OX{N=XCJmxX`Jt^Op%UdY=T_*XGy?{-G`L8vTqghILG7~q|u&HZrbRz|@Zcadg z37t|=Td%RqbiG*Rmm4|BhpCiWws)aVL#}7R;5>h6jFs1P6@nF()sqd_bO$^(Wm zrzw!$Yp~;0{ky5!e@_*Fyy%~O=YnH6lU$-5pg{zTx&pdh1Wp`R8gQ-?M~4vjcU~wj zJoS2As@*e9!439D4Z`}G7&EC1+}0=wN_D^=sbJKuz2ofbpU2`bT-lfa6?MWkn{F*C{lgvY0(bA9POTG+Rv0h zUa*95A-&WW$06#?GgUoEnERqSKDh1L5<9l_w#*KF9M}C(EDFpnt-BL#aB3)BMD90a zerX!L$J;T;f`v#v(@8MqA*39Do@3PSoWLm`1_M4J2l`|kh2 zD%1ac_nil>d&3)Ea<7MwyNyZ*YzPSuk-xqdL4=b8>VLEB;X{X*zBB=VIy6B^`3izGG3QCxsLFopbw z0cB*IQo^e_mQ#68M+tIe^1H%y)I7BDj-@C$%NRqb-Kyen6?(58nXT86GH8QM?Pn!| zA`K~&~VD{w#ZpQ`x<1UU*<#@eWEYTJ9E z_EgxkG1|ltrJwz=Pl>PXh68g;(~?o{677-xfA?D*p?~h#g$a7rWob&`o33|Clna~) z@()0lLGQ*i4oVMt+&&i{1oAW7<)IEEmNPe9_ zhX#xqML~I@U(J3YxQodi^;O9FV`awJb`WM~~cRL$i;g)l1hVYjbn0bB8LN&4|>5(MV2@ zeAaXT8|{p#poT_^?g!xJtEJ`v#qBVXyM$B1w{%Uj>34o(EwU+#=nXkh}h4`vNP`h(_gN! z^8^JTA^eSf2Yv`SYJlQfi3C_x|D1!5ON6a~B*b}Z*Ne?@YKd5>(z_X9bDlM|Qw#47 zy*5hgU4-GUxu#RZNy|x@A#E~qE9(Wc#~!RNFoW7&c3vxtJ*)7TbM6gIjSv|uD*c6g zH1;|+ve~wmU8wLELi;@dp5Bgj_zGo|@3=;Lz?#rHRATzlFUqGLykQ<2wx#?m9e<+& z!7%g&_sCNSrihZ5y6TQ=G zn(XxSH+-nFvae2cxlJRga2Ov;pxrS7xpW6_SSMsL4X-Z-aSKvu+F3+o%5EonfvK^~yS zmzam5;}s)05eI{NokpYdRH84GR{=21DG*N^=fna&WUM1CU=ruG+byzRdgq@s>dqN= zOoVP`WH2A!orua2DI{w8 zngR_SU_yA8P3_vf>(qS@J@nTS81MefXFl`)zVxLpy~DM7IV}>vM+65z34dz*5%~+@ z;f->^zYVg!xbOw`N2wol0G4NI+3Q6~)=fu4kq)aHF?I=T0g0nNX==08&bJy=@@!_Z7l&%4<1cj*#76 zL;gxBi`BGE^$A>tW5l!QnimL!rsAEECg?p~hR7GFnAND>x!}BYD23m8)mRrNt_RjG zjsvBA-K-8(t%1s{pDi^(|B+HI%-uHy2Psr{jC30;NIpEb96SP2YEHQ^sZ43?YnGr` zdESN+WGPr}?+=i^JP{pV3oy{;qbVaWP}b|0@H>ydW{{hwEV+=*JXJ(g6+ZR}g#O~* zXY9)db06xQ{=!rC>&SIe9DqDEz5d7}g+1$X_y0}T7koF3%>8ZkNQ2gAF*~vO#~rDW zI`Sh=h>1*{TF}&snUD{|Sc|<|);rWb=~vddfvxb?ArCeee7c& zdl-KGn%BGre1LT!296e)@5cZ&@(0_$CH%cbApfT->@Sa1Z|Aw9XSgc}XDlMZaO)`O z=<#i5jJx&+BCL(43m&DUDFaKEfECBbWG-$&(R#+bt8s)+r|-C_A&f`fLf=fZhVNT$J9mZ;c~|4tPBWwbO) z3MW;v8Qi~I(SIRz4U$h7weC%$w9@J|;h!T?5n1w>W1hwBRRxCa4meP#3GTJvy!EF; z?p^cKt*~V)*=0)ULXn(wmN6)si?83HqLrtzyTQTsYbLuAKK7$(#|EFE-@doI&U@vJ z?Cry}kdM6Odz}irb#w#pwngpJnqA2kThuB?uWJF6aiI?|7UjUR#i0Hl+ji`D_4U_Z zza7h=qHfbw0U+uJQ9;@M-~RTuPuQ_z$E#51FMmECus?Qc4KjZdbwBcM(iEA@B}jp$ z)_%(K9V$0E3-j9(fFXl~NJCej>gpG2ju&+FXJv+`wB>1z&UTiJa*xoMVRX|R40T`m zj%Mw1Z+$jI*)Gu8&sHG#>-CR+N$T3`Nx}25?R`-tldLwIT3@j@fl*@16`YqIVKOIE z9Pj{XNO`e;ZT6I6V>WyCkWcriDMCxI?iF%p)1mS&C!(p4k0qTOWw^;QxSdVxb&sS# zm5yF2sJqT#NL{1Mj&{MKsFxI5g<}xt2rWV)B|Dvq={0?DbTzu$N_t zy&-ZD$Qa$HE*qZCeWC)eDa^k-*1D+k3bm9AUA;R?-CUU19aTER{MO0 zQo)&2!*UX^vpN{HIr|xM`_=4hUESC#OUnIr7R`RIvCKRrW+@fDqM>yQ)QB%=45v5b{lp2iofUltfT_oSG_S#)-{Id6Yq&0?Dqu;t0Y4Nq;r}5>`bVUR zUXx-Mv7nJWdEV-Pxyk@B2pE1i0k_|N`z|r|7s~rL$yYB^k)JRUOXYwo44O4rSUKRT zQo(`wOkM=bKSwsJDHqFdBdjJNWR(eqhRTYJbGijqWLRS(5-5*LsLc&3*UOPJeb{@J z<~8AjkSa+`Y3qkv7k`_%vCUxcIVZ7fkn^&g>}I4oRMpe_D2G2k1jm6><)hX$SaHqH zRHm3B;HJ>2NFQG9O`o0HD#J#Jb(-4rWERslYNgr$7t8j>eSi8DcD-W3$%WGI2;in| z^Ee^Onrsj3xe5w&g(Z4jz^ny#Y)bo*$KvO>5+d?<$dCNZn*aX5IY24`$bh<&Hcrh$ zQ~n2a(u-5*fs9F9QlsG=^TNMm8p9=JZOgYd zS55%7z#q9RxsHQedfl|hex-o5oKX*CMCth}o69`|4CC*E?a84fDUNKiHz`yFs;LVt zQIui)@&CSk4}k!c*)lb`(L6Y#x=1sy=z9YOeEa{z?Z-|WV_-u12(N%3DM zgMOWK_1km*{r3YEz*pNd>}ks7;zPZ2j|fKtCMJu-lZ28pB|?6A5+|U@YJK#A?mt!5 zsZ-^#EY_48`OIXgR6!%Yw4F%jovr$*IrR#xby$eS0K?&zdU1x_mn39a-&GJgE#3d( zHRf$AD&q`^cA5RpmL}Gor`+`w5=9LGqheh@$80yQEVr$igV;cRt#Mk!U)xB87i3SD zo~LDM+MnL%PnO@YFOBb$4?Wy`dOx@x>yrIWd>#3Z zEvRDJSD@9?53gJM93}JgxyZktpZEJ8MDoHt!e#5gc{Zod*QAcPNZdO)J~7aBoejL6 z0zHD{^tcB|)(F}4rKOSEwrypbr~eU@4SP^ z`(J$V#s5vbl&ggBcM`aMvJ(e!0Q`m@L!*rhT+21a%7E;$5tr5cB{RX%j8W4N=VewR zR=5rFov@mSa~(_Fs3!88Vf(2jJgH>{orj9CQH7sG1GptLi#C>9jIMpv*yg!L7Fphy zO3CDgSHV?rAv{Y1(G*Uq{2A4NY9y&EI5(OBa46e50HUn&jVi^+JcNMCvcWwq|CG!J z^(+3#I!0j~gSD=(*53=vy_5v`ZWnhv0uImuHb4h9hoqsYU&-Ki7UcejnWOTFaa7^y z%Neb%uAz#~EiErUMlRhe#jaj*!wvu64t#sgIp@sP#dIbIK*|w1X39w?opgb`{O`vd zcib}$B1s`Ce;WCRWqP%xJgEpXB|ek*jKlJ|Y+Y4-sUK}5WI6{Md*0t|gcD$ufi1*H zt;g_7hASa}H0V4OxF9sdc`>>SE_DAF=mgLL&Ue+Z8EX8!R5ewiM#KqZyN!y;F-N6z zQ%W7BI{^(h{+Z*!HM&XEC;M^Y80CzAw5VMhO+5o|(RqV<&XO8|*T^l8x3f=D^!bb~ zbPB?57Zy2y9^)RYoi( znw@;i`<8y+sgGzm}Q)` z>srL}=5jv6E+Xf7%zBF^m@5AZo87UU_)g{Pi*~(`f9I&3VSJB-a=jdv-1W@xl)y*P z;K@rwSky$XK_NwPBm0JY;^|G%7@{6qgHx0;>e2%CG1Tu}SMA{?n?nBKWcdj`unb;Z zQyfC{GWlXvY%cl*eAi8S2EdjdTyF$`lyq?Gt+(zG<9`Lz{sfX(`O}~Nq)JJnjUVl84CzVw)G2H+seTK7L$Wh zRTu0mdQNS_*3`*~*pKQVEs4E7Bq;l#y?gfx`JP@#wDd}89CU`8^b9x%+dLEr7!8U3 z!S+XR4>5v$`}RFeK7Nrf{vWpjsXx#A2vpHxMQU3Kt^OvN5?ovz4=J63m`Bsy6CkOn zVXdAN)|=H_ij)=!pATK%qPM@#@rb6ie&fsB6{Q)}WlcO-7t%fh!yP zg21V)O1uHRmVUDThz8DsGXbm3<%g%ogHqR1I! zq5F>gF$e#;mH}iC4|~gZJp;yGPTsZTziFkvr}h_Ji2goUeHb{*{!b}@+%*IY8bS0_ z6xBw7@CR)jtYD9PdzF~*E5GrLZ#30-mtK15XlOcZRBQYN?%&o<25aY@d+zxHKU^(; z!U=FK@vknYmdMlAYO-gDV^M}6HGvHk(aBAITk!efQJ#6XKcv~sW-4c#_Nvzb6k`#7t~ap=`65urgo~jONYY1Z+ohjyLMaZVOO(64=BN?WEc`gv|$V3~g z{bZLhmQ77y-x(p3+!s4D%xO2Ifb~#Jx(Ipzr25kI#EwJ3BfE`q!Y3pbU)R)^R&2af zp$?4P^3syP?g!76fxYU}H{Q7KWtUv?b2s4wjA{jfmMfzFM(qzJe<=TxI{ynK5Al4F zDBLTbW3brJvO&JfM%X^-u^{9^CBk1oWhFx^e-MFrqeyt0DPmtQGS{q-?M57rS;kZA zRHVwjYm9Bs=qh?uVL9A?)>9nx=85-KduIPVh($u)ZP0;v4ftj|zqOS#TAtm|vsCe( z)5}vSZ0jF=j6-)Q@P>wDLx-cTQ`@IH(=V10b1&sLDDgZjf) zsCJ%m$|+8pb{*}4Z=MopBXa9j@O}a5Qe{1LFh79k+Xj1mx!(*uVc- zANarrKKaHszVYstzx?HgJl-D9pCjO53W57E|lk$P%p0QVMn?>|c%~7Dn>^-#)C32;PeFHL`~R zmmA;Rd+)utN1G^KH?H*RR6ce@3igdKo5U?;mX`3Rw@tjs|iy3ky`H%!rLOs zl(rtjS?B(-^&xuPM<}4d6MrKDhY?qU4z@3z_zbUVop{no$KCbEKfZ9hHoi{&{0@F|!37tr z&CCH1_<*jw@=Dj{8czW8Uxy*4^_!{~J*~GOKFlU^PEy!|9Zy8`&^EyL^Kl3fCV|!xQ^YeFP;g$74?bklUud#x66_gl#l0ce)F5|38z3Kco=Uy)yV5B3_qTyopzdg_`@IWPCohM z&H=y^ziBIT8t1{U@efl0`o4AOo2UNP9Yw^-MH&B@rZ%puPOviZU8u^5vcg%(h&)#l z4-Snq*MbpTdDuAV1PoRrw6Yz<$=1SLd%QUIaM-T-qa+QZ)IHUzvGiO!no0@5Yv=nU zXn-Fe-&`uxfZs^^@B8xky;u~%d@2=SIsi}<63zc{+wt#NGReJ0-aZ_YSbJfPa`rla zK4}3rc3AS~GMxf9q^5tKQm@S(nQxR+vJs_+>tSTnuxjcCMjKHyo^C>%b$|H7ADZa? zSHJpI6XD->+igw6|HnW6vH2ePc`T2}9mZVlGk5B#r#8kNMjr2B_+i+^oA1yBMHKMF zcGda7`dWpsEC(U{+ZXZL$le9>l<_ShJ;t?#_I@ClAOUh!`75Oh2XnRS7D13*p)4<5 zI>@Fr0GTkottpTyhs?0sL)@MEpJ}>*vJriZ|8uDblXK@JZT;CIYIXz;@*;Vz(r93w z5(E9Hzu#-Br%daWABJ95SCK(&ib4g24la$Bw(Z)z`z)DXo-16Shqn%ZyZPpuIpumS zTjW2<`Hwu((lg}cRdVy4B;+GN|4shCkoV?A8UNYKdNyCgadebR9b2i1kUR$i41=sW zbr?{<_Au~(@nQIHzy0>+3FD7fK>BbR0LkOG3gJKOVGnDbAmZVvpCf;d$bQ$ZUClOd zF7SI8d0)*>-)p&_rXYr(TAB(t+Dc;=W&G2%{*a;>Qa3WB28nzx`KNp1;G`>K{G<@nqFC! zRO6V>lKcAU*I#qZ9rAjgYf*g(>hlhO)(zfp!wutB1K?Cy@lz!Vc@!oH@hn#5bvXmi zCnfh6-SfN8wd_?0Y=~hwT8)26J7JOgIHWZ4aH1i8Izdb5n$BGEIbeOMka1u8+SlB7 zzx&-L+DCpLY<#V&M>PNRr$61DfByOIagTePJM+vlJNSOmMk7}BQzpNa$_P;>HR40M z;LVaEvB;{6Lc$hl)EC{sFBa;FjWZdNu!gLTt=#9O0<4TOC5?~44G5aprqOKf@denj z_krdx^8m(v3VF(w{MASf$7n;Twm&Xg>@UZuv&4o>Qvn5{xTSB`-PkT$*-^hO*=7JA@;`|-_*au2?2eBz0>%O78G0ji&L0JLrp?S7@(->EVIy$McLc&gi22M?UhAO_UF301-acsl-4(qSxHL>iG{* zqYRC3r~v4Hu7p3Iyf_OwbT}O0a6e0O#zP#3_5KF81`Hf@y;XI{#3s52z;K9Qx zP{dy8o~=QUuUHsbeGtgkxNJ=x>FB6e`H;@#^R%{(Q!Z7ztA<(OgdwH^>`yrgB-bHB zc3%bVH2XSIKN|roM=cao36Qj7G5gm5^(1Y*_C)H0Pb%@?02~m4zO)QJN#0&6OMFco zfIIIzb!6G$o&)gu*T2528j7#2V~bZH1eU?}9i*HAPc7p+SH8GV`1GBNp$^`WRH8Z9G2pm#%#yuj zuAvB8fe9C4BF@0gMhBo_f`CEwf9<48c^EtDV=xe=XG+QH1-eOfsT)p|V-}eMm6x^c zf^d(w5P%Gh(wm~Vl7wBJW%N&-NWVmJc2{aMtP?KjGc)e)mc5c*i^L zKK0Z)$JVLuIRMh}Z{)2IA){~jS?6Y+^Od?T{D@y8V=dc5X&emySHJqz<|$;FCep{}SO$^3)Y`hIJ?&}kiBEiD zlfzfE0Qq}7u|8Gbb6(y(w=bcw4rPMXLDBmDP$f`N1>LUl2lO*dVcdkWyXF=a+hrZ zom5`|4$R$@6E{Ub;zP=r#zt(i@;OPyW24a@*Bf|t>CS*r2`Gobx|=8jy7HJK<-eZP zU}r-^WPw#-aPmST5!}oRYiq0L${h9lYp%KGMnU!Ov*dw$4uBAr);vDIBW1zUF+qT} zY}NCxW9inGSid{AOZe8)LGz6(j3x~AEN)C>uZN5pkdrKF}y?u);(KrA%-gx6kd;4?_fO-YU{iFMz4$OxJp*d{!*W74$_cZC!Xv!|Z!>TNHj>yim_#(=KIEcpHQ3O&xaF2x8i@ZJ z-}pxJ9!3$&d_ek7deV~`gL%$5=QPm1=J8>mQQjAp-(d_s$)-x(6`t3~T~z}y z7K{-jL!=|{9*Th~9E8vDUY!s)26(Tj2cIyAzky=lRqp#HO#wpVBHK34+O=;M;$A9x z&PV=M*;zs=u2{a#nLN6|-_DZKQw;QU&Og_vhieq}N+i8Z1kROBikLYT2VzcoC#6=t zT&#%TNaLRpq)MA?n9hb!)XA`~1~}QTsJ_v|@3{}1P%^6$vJlA^-V1&MCt&31f1Dv7 zohSGFNxGb%lN50PgdNzeRHB_-GBG?^R`NvobERc6Q@=wbn;QLoExuoy9~l0Sxjzd5 zjgD4AUMlLf7}y(G26&{E28QERVx89QD-jO{6VU#*zx{3Zo$q|7iS|*t2cwB-9}xdj zp7N9?um7k=J<2`e5szpX{P?Zr^I>Q;qSkHtUHd{vU&r9r_QL?<0V51&0)jz!f@6U3 z;5|Oacu{tU<HY zL_?ii3{8nX0RvJE9ODR9E}H{`#GXO0st%MD)|8_s*G-9(L>ii4GrZ-b@QoX&M<+c3J z4up?};0f)+0l-IVf@~i5-~$hyFY&~Q`mOMpMv(*XSkn>b~@)FEx-q z3@VuV$m?Hp(M9e#&v{N0{Uc3)$Q+TlHs$e2cvdr=kf`V9{;@7>->>J#I-wAVQo>*S z;up<(MEGz35ba~!nj*j}j624vYJo8Lc*W-`8H^{sM|Fbs1<-N$t%tEsM}ii>5Qv;P zM|m?_$OWMoN5-qh_!IXHQ>wg=qa1%IbQAIsxL~2uIdYzraL9$D&y}6|s6UPxSv7T6l^~6i;Z$MB~=RQ(&OXyDBu=R_os(6U}CTo{tn( z^VY*f8vUxLkH}YzI1DOCegE}e{{^j!hUE|A2zdWl&w3Ud;-*#~Fh7i^lKHScmHV~Q zBUUM)lJ#`^c!eRyHo+?Z$q-Kn3Bm9q%13k$=RoCvVc1b&fcL02z_@@;z^H4(A3S0B z)yONVfTthz`+EQQ+ZPh@`3Z;XI?ItYol6SV6*T7AnbmE5s5W5~tunr}8)6x0lMctZ z&peWt2@l8g@`z5xu5i4#Tzj7&Kyn3E?jt7sV@ZkL2{t-mbvv|l2`Jkw(aOn(#KepX z5FI2kQw(O;#Gv0jCxh6oNl?mp3rX`FdPLkcw4AikX&Q#f)JIE8Py66UKl=1j_U`>Y z()@nfzi|LAx#W_L6d)7GY4YPoVX|rU$=76sD=Aq1a73b8kF7_|_2SF(s$dUl_P_r1 zuQw4ta{PDz-Url=X#c_sFKnQGg~bsWw#0)S(pX#>>{(5KHI&?*3o!D7GrD8SKDg;KkGdAS^F zU})~ucIyi)J|)!#1F~E#B*Iju09Z8sxx>NbDA=lhn}zAdMFoKJT6uW6-ERI zu)Ks|4``2UCvv7`nD79oLGKx5{q%%wOG{6YIq0YIHJHAnX@5Zfa{rGS2SA8VyCkA` zwY>cwlIJ`dxki~l8udRv=U*tb%c26B>u4uY%B{mIb6`3G@X1p2wBFIxqSwC>(cq%D z;pf^l3Pv>jgCG3B{pWxFr~BOJK8JGGX4ywS`qA!%FMMJ1L|a@KUu~$PP`%$!CpSU( zxoi9$+r=x4I$(SV27zAy9RWBV$nE1dC>hk|K0qB{_(3wnco5y|e)ygId>B7PbgbzE zu-O|e%~H!>`+Mf$l>%mT$#wh!cI75BrETUa3$n+^Qno9W)%CygfrC^BzibMiqUwM- zXZ+LXL9aP4=fr#1Vm>DKpOeB$xqc8F^`+ry2v4E=}NmQl=Q*y!_dCs z6|ZQ@-rxYh*x~`Bjqf$8RzhD&TVEOXzFHt6eWV$nA@Ke0f4}Jq0OtdL!=ZrK4xoIL z5<)isP6yz9!2NItRJ=!93BzHq?j8NxZ$F%?oCy^IOU;hv?cp$JJv|0oeI(CBb<5ZG zw#{h#TOIkzop{SleEJ&v#`<%`18nuJGuBk%86+crV|ku|`CnZXV3bmJ{Shca0?0^Z zn6hX%~!sKHGIUseB?O0d^~tN$rvH`*UOx+N}tHK>(y0>0ckVXYa1?KT}`Ao7iY0 zAmbxR%5+u-W;6E|Sky!F-Pl)E-a{_^i(mYr`}C(j?Y{iwFE{WzO8#K}!<^f&?5vD-#Q7r|38&{&UzbIMHp7LZMyXA6Nkois*#>YirO0 z_?4S<2ONV3u9%PcZbG^;igm&jrxmDe+HL z0WnrQmFXYK17qEAj*xP|{%QBZ4ZD78A$kjmVXpWsXGve|WgxQld)Ux2GO?kKKe?HE z&K8XSpo~PN+8kG|w_L!<5q^~B|DVP!RUYZ!yj&7e;CGd&po$^`Y*ot;)YnK1{_9r+-haJ ziZVjoo|5`Nj0KSo+kvwIUI9`LF#0g~&wS=H8)FZ69|m8`@URXnr|a_#c?~olAZ0yn zg68mH+_4Q*8sL}!oxphEl;8=+1>Ym;#}n&B)x(unUfHA~u&lrC4QVr7x>gMm9Q8z2 z{CT6_R3@FO@FTF$8{sbXT13oK_KSQsdb_Q|r@$}N)ZZkHxOj}n!?hq=}Pfd0?2L`uyJArr+} zs{KEtD_9Qx_Xbf!4|0+JP?FNo(hTQt@fjBB1k8in_{YJAxdCs(aDDA-UvnS%$VVDM z9~io)KmF;Q;n%W07)#s$3YnL-=Mw-`@elR-s3L&zN3I`{GV1ZM9XK0c^uy>Q;&&dY zFpSv!s68wl3`Kg{d$KXuDrkgt!pMUlh$m7IAQ^)9fX^{5ERX$!amW6{S;7-zQ(0g* z6?ld70;EL6iPp2ywm_K5mM=2bw7%tH3*2M?o-F0#Ug;dAI?eg2!4}7Wyy%S65OGc| z&}Gj zEtAPYKDvI=+|S?pJ~4OXJF9vwL4d_9;->mxNZxFh zh496$RNZsVh+F#qA!}Jlf64km zKh*R9zaz(wdVWOU$n#@aZK4yFzFAQ}wu9ejXFwQLpbH=r;uXR@Fp_XAUh|sQG&z2d z`e67W$E(Ph=H0`RJF72&k7}rrAD&vt0A)a+8gM{>K%lY#ulNpQ1#Az)j_pH3K#e_) z6`pvHl1N_>P&Ea#d=bkikHV@RSVYTTGm>YnES+_gb?~)V0f*IYu|B^w$usw>w&HT~ zK0DpoMlg?40`{h0|0CLXHsa@eSHfJazS*!SBO;s$x0!3R;%t`RF;VjTaVE4UQ~Mwt zma|>wH;CTAMsA3mi~C7fc#_@ymRwHyzF(5>EV8|NC0=te5jdeo5HFvitC9JGmRq=S zhpgiinUHoWJUEn$fIvUBBl9=`TaRP+fol=QN%QX@-ofx+fBp3h439_~`SzE-^rcO{ zA8oU!5Ag@si&;k`!M?G5dc;{B5xR5MEYo*`+^s|peZR-vH#D}=n23HfJCtN0?X7S{uKMv@p8gdXKjVg_OG z|GL+`t|8xP&RwBwt6`u~2XKRgi8 z`$&W}9z70L+G&>ir?#DMCxK=nGHIh6tEvW_*DBQUxZhu`j2Tq6*UhTkaa{?Y9m%&y zfrmikmHURV`2EOVp?|Y+9jD9Qm^oAO$8mpyUes;r4QBc|RKxQ5$_%!yN8^le&2cOc zPNpCZdp8qn#L6@ni&}S-R}*XoN69ymFlvCeVT6%;hw%r|4^c8AeU$dS z{N*oiSobjgSPl$-zaBovtjBNQL;x)SlOJV#VD^Jo0OJb-i*|YI!wLu< z5xbw}va|$z=K#hTjsqTu@>L}eFh8Pw{ zI3oJ=Qyx|cq3>u}T?!Y?9myd8gYVyvaL1g9fVt3tt+K%RBl=l75Uz)YzrS83y&)=o_q*TSMCSMoP(Px6knfQD zN0bZ5JVo*g#dMTNi1ove!}x#llb>||@gM)uu>9c^z~IBVKnWp?u4`KegL&_!qFKfB z;CBvGNnbb|fc3Q@5qcB=-iLF5$R6DYv3+P30B!JPda;jqMM@y`D6#pi^R`GQ+IIqK z6az~LLZqz63GtPl*bduZIVZaN@vv@ve!QCE9&n^yM^-K4v|oIx>@8P`&YApkAr@v= z%h}+_g7-e-gdQB%l-#jRz;kh4B-b^Njp~e?l4D6~x?j@AW$(k(a^g&vXrJ)MN%iR- zwZu}RWX_$>4wdhaN6cBGnf9Ldyyv*T`m4XXUwA@ebpUqAm>Lpbsvf^J&|E5dFbsL* zK6zz-JgW%8v0Ng!XgT7;>3I2ax*8?pGepFQ{6F@wk2NfRK;18S$x9jn9t*?sJ=nA5ph<1ylhYzaqgZRa8YiYzrs@ zz5_cS+k-d}o*?#tWQcKrOMq&Ev(7rJaR31I<2~ShtV5-Nb^H1nk_#=;UZ^8rIa^_4 zq4}-_8U(+lQg{7$A(cE&w3X!)2u>n0wmu5wx6;(dU5l zHzfj+Np#C=9J{10ft5}$74sRRIbJwiEXT%BEQ=bOQ%ELA|tIe6o-tasEL^qq3Qp8z0Ictf270FB_26ai=>4Kj>5 zRZ2Y-duMv6053ubY3p%hKGb+COC1q1AaTI`AN}Y@8>KxMMUd~{000_?QHD{40YxNU zipI6WAB-npcQ_ZQAh_lz3ih91#AstDi!0AfeP4unAOhg=z<8e?@}@HZR^u=Y{4 zfJz0B2}OUqNiSd=*ajR9MEoEZ0@_#SN4E`Rz)CZO;|Qa9UmY;@^m!IOmtn5j{?4(O zk6aayr)MSx<`vqwn6OWA#)}gmle236xGKdE6ayL^?Wi!MY@}9X4Q7ymoa|k;gpqAy zn-JjTK_pJDB5zUxDHuxFV;+8G7c0@Gqw*zvtD7lT3`aZ)1Sgnp-SLuIJzZ1)Zx=Lm zRY`#G3Qk7EArqI71;sCriCn2kOGgD0OwNB>(&gNNq<bDWbXTEeWmQ^`oukvRB_h0~N2lsawE1*GbjI&LEOmv{K%`cV93**Sq@9I}D{skiz z(c+Bg+l>D>hU&@S68PWQNdr!|4^9N0oJ-3jBILYspAmV-^_yl3;Ivi*o2d#e`b1nT zZ!9|%JZ%7Jxqg#Wg5O1#jr)jgM1ef5Ar8t)Cux9YHW|4S+}NEZ!N;xg*S*VaK64vn zfH8Rq+$lXec(kBMoKjJ}=LAsIyl|A)-ye&SKaMvHKMWNjdNl8WoGkr3Sh;aC7>$NGURz{$Y+v2KO?{U|!M4SWyh z0{0EZi|8Kmz{u-=^PAsn91e^bP6hM{Kq|x&=>_l?@OwB0+K|TuqDL02?>8mFK4RO- z{#TR(gh-?ql&lCNuOvy`M~oMU1JD&XBuI_m^IB(oNU0Ym!M(a}0KBlWL7pv&VouIX zYy#MvRq3O7%FeR8Qddt{MF<>H2gg*wzURtu4D7I6UF*mL4_Dl2s8(Gv_aSo}*^}SZ zkkdCts-<^C*o==ebrnCN2OzgTQUKdJ5+G1gi4IQ0WFoL5wy+^>A)E8Sb(@|LEH9?m(8c&-Jm^?1=47bg;{!2Das{M}z&|^TVp{%_6~ES>cclel4tTp)E0K zQMnB^$9`yCevj}rD zxmC5cwIoi1&!{i?YSjA$gnzD6Cv*i)9lMy|N90N9+G}Gg!_Vm)IE7Vl%+(Qp2>YAF zy-0#Lb6yNO!!+HI^0nCB7mFmuX$e8Cp5OpxSG=L09_UxvaSoKOl%*~^oLmi2a1j|$ zHQgwUM#lQOp2A#{hVR?eor3S5w03B%sYzazhy`_ldr&JVli(;(r%$!5e`koQIh8Nm z=;-`N(-b(;#;eYps^Fof|I?rTwEO3O{^!P!LF5JzDWY>g(=dL3vQ^|Jw_cUEg^>ls z52K6<0;Ch*K!9-f*0;X3SvMka7-Ep@{NLu*uj|H`us#@D@Ce{wpqc%(ucEiEBbC!+?*(7e5tMPj=+W^f?j;DB2ICkK23H~`oN#s@BgN)@Aw5#s>= z0H4DtK(y~S7eeH(y$cp${CiTUQMsI5w_d~E_RIFzvM{OWu3i$)Uesj3b3Ke4yeC8b zKdrRKGi5cn6af!8F5L-?_s4R=m`nt{LUf7Entm%IlhTLaQk!Vw$HR?356Vw)*{Nlt zu!REKn7QJM_L-)~g?GxrC(69Cy_sWVk~m#%{JmY%fC6;y!H~XJp{;&7SO1?u7}nLG z)us)Ov^2nxG+rg0!H^^Rhp|HB|A7yD!2Re)KWYp#>iYr9!*D4>U8B~V!XB1Gqzl6a z!~CgFeX2ne&@%vt!k_)wpSc&k=tT|N9?>ui9}H`*M}V#$_k_ytBKk&zjZ!|O6~I{l z)Q)X~NC>DO+kml!GoZis^#E$r2^DF;{@_^QffNId6OaaQ3{YX91V=bDaF~$JfUyUk zLHh(~F91ajR2I4E5ay8%bL8EqGv5N_NvriHteR&I+S9rMyPQiCby-r6jT(OTlD2U< z9Gm4~hOm1Qt_@)UPWCL|A_|a1TquKHa9K^j(mY5zD0v-!wxOhWU#TZ0Fj;iW#j?bH z8i9(rdrp4FnczIvh$-HSpo3+oMdZWzWIEZV=oE(~g44nRDOQrz?vN;Kk^+!#R%EhS z?st7I${vxSGDf_WNZ)!S3>zLWig+M$MXnr#KSa*Yc*ZjtBMr%2On+SCJaq>=N4x^&YG0j(Dzd@Ibx&N=IU$PCXCbR*f<))uGw7 zLP9xJL9>$3JilaC5!8&G$~7CqjDN);sWAR#wQG_r@28unid z?Qd^hVLY`iT_Jxmv)651 zi(#@m-sPIdvZvAj_i(&>l=AJ_n#AT2vYB7oC7qZvl4<%k~}(PJj<< zD=`n9{Ql{m{;4zmfBn~g-57r$12EDm3Y63I*DiW6n1IBQi$}g6P&r6$NF`ix#T5-K zu6qCKP?U7?H9xKldywl;L4aNX$hpH1gQSQm1w`Pg_=nOuh0*<#MXHB_F01GQ3_Xqy zj6a$YpIa!L}O?Z|ORIaS<~ zEYdkia|9e`DE6h!d71oY08!@>2OQ+mIbg@Jqq&TKuAvVn;BGGUD9)AgRch*|AO(vA zm+PZo>)lgyJ?793zpGwpKgo0MXwGKAY$y?O|N5a80%tNrGRP0&$I}CssGQ+oo6|zi zb_4j`2y@Svw|8ZLGU*;C6XP~7I-vq&O=&T#UMpaVS&kHaCfin#0-m)e&^&PZW`p9_ zYaAQxRA?(-M7J>1$oc>4zy53U9%Q*UzxmA_?2pJ_iEpVY2Q8aZ20Nl<80LTZmw#!R z>wxVp4KA88KcES^F4g8Q2@<(hG8hIF>jRnq;}02M5b97tfV2W4a0vUrS$C zLTm>p1DppC3b7q<3y|N(*r65(&Vs510{VyX*EEAF`B~lm7IDobuM?_k0h}sxOHR6- zE%gE)8q#7t(Zr`WiwDYYDUul3&I8B5Y`U3@vHzU`sAwgdjpQr!cG$GP`Gk2``j_TT z+GF7)R9}Yi?Z%EA05+Z(jr__CEFZ0k6WmQs@(B9`Ozv&B+yJXD?L5KFf(x@X_w9Y% zZwSeLS7?rmy(g$=bpq@i1(`$W(7}VdXl-d(5de4tD{9ztT)xUiGfcD7`L}ujxT8bc zU;ES}Cl2@=#vg_iMjrw{Fml?CH^+`wxLkYQ!?6G?k97j}M_K_n_&2`sjScJ%@f{!x z$b&2TkmDt2t6o(M1k8`>1eEsy1ptzOC>*7IFv?gC(K(E_FRfcT9!l`T-xwcy1Aq*P zN(DgvVELnB0XcqD6rkY|jt@SEg9Ic)k5f*sVBL)W5LO+#8M;eW687@ReET!1kM8rk zoR5ODh(h%Qn1}JJjsGHq6J7ldmGt{_2tiI>*L*jW*vSLQi#7WnTwwVC%TR};^qiFJ zi$rP3Ma~L9b!0+r5yG8ow6?55KtKSt`?}(ZiHtKUf8<4(9BBQUDT8?lgf(qPj~4zw ztq@WU&Df~@)QcF#3r0)x^N6I;;ueM)s(9MzuGGR-OV)IG!1Ac?2ciHY4>(@MeNd_g zXTmel_tS7?61GoSw+&l^Ja%M_pu2TTamFDG45!^-xokEm8=YrTC!8V|hS77Mj2AKetr9wRMS+}tcVIRm^SlGm z{a&}<2FfV{V!owsj3lJ*se~-0k^qf~DKP%Nu)pC=aLl3MuuVchIt2+p!2e~LxMcZV zBOXm00Qq~Te6Y>W+bYZuK>BVD>0hdykTy&H+aJe~#MnO^W7K>$j3bN}j3c6HMClOu zfet^6KXUWX-v^;h$$lw9ni2@%NWie;J)&ml2B6dre?v0>Btk^ZDrJjjDe^}S9WXr7 z4_FVzho~QY0&ctQwkE%i&VzWu_-mOVhLm5&N-0YYM97}avIroI$q zdyXXAqC;#s60FfUr_Xz;{cy1i(x-2hg83;?)iCv$bVcPQTGK)!u%k55=2Qe=SH}U6 zn|!xCw|TX}L`ui?qp>4}AhW!RW`hu}?@X=sIe) zReI<+!k!wTBM|2FL&qWNl9v$WY3q6F^xBgVKG&5MCL97zEk|{luUsq>?B}B+B4ih- za+&O2lft0P{mq6$L}VM*iW0&+Dn!O#@d4*pxu_qp7ma_OL&C}9%4KCgwz(K~iot^Q z$yS^+>;iLK1;F;sQMxos99L|+^$&gMLk+7PMifRBb@SSF z56@C7UnTs(AR%%F(;pB$j5O8-5+TTbFj7h)EJe(!>W2p^5zu|`nrp6UoE0?c0m%=> z8OnYN!>eOcDj!rwL7Do<-=mKIrkieRo`C=1bO2QV!4HTCegkv?QNJFK(spv!q#AoY z4mi$OKR6M1#c@)d0&RYz#|2J6(>zF=5g;@;&W&Tzb{q8XXU;O-YL)7S-b;R3fin># zo6@=|xSN>k0^>xb167o?+r8+WOVjPAo8+**ovrP2-Lb7y7|hZfQ*ytFb6-(7FzmC8 z^O)#4KkVA$Y+im&1;@}COlSqvRAREVX0McR3O_`)d(4xxt55=VXbND>FDpniH!r6FtmuWou@r8Px+pIzv>v&F+!CAI1Q-2z$>&2;Miy>B9>M0 zrM+VK&u#e#;A_Ca5`vK1--;tmj&n=$GR$zkUF!gcniI`nxX!TIO>WpA5Yp6Hur%T= zxIfWjfD6!m8E!78=EsfHpejM*f^rBur(@Go;asBgxs+P`R>z zx$s#|4mc4UMw&^brcG^|LthQEo3niO{*-t?eyWW z2Ea(;J<8)yItU0IMiixZi2kuYJP_6D`cmzC^#NlC=L0qOAP^!KkH{E$1mF>Xn5Z2B zmFSmpRMg4T^<(=W6XJJY{pwen3IgrUhjzda@qrTrqY8ryK7e-K%Q-_OQXhT?=pIk3 z2OJ3S8BlEjE(06@HSXAV?4KT6j6?Ml@Ey(<)`Q~@F(Rx3P8(7z+A{f}hxSbzqIS9= zLr#VZu^juk%*FH3)r0XHBtS_Wu z*O_(yEt$H?xG0}reFDcl=Si9ZpL<3#8=r>e0Dj4lFZSb+^=7KmR}x|Acd2s0Mdqxx zBK*Zh|EhWik{qCP5a}Sz3xf*dk32Vw1`I2f&B64FB=~6rM9^?9ur5UYfaJlaG`DKh4H;g^d3=j-)?2uM~fk!n1(haCKzW2ArhkUn^Dv~uxw1I7iBV8WU9;rY>B)?lPyIGdnpH^AGHSg z<=%gFecT;~W`^@nd5Sd03FE^C2fauxG~AE*>X}i$TM>u#?G{T$QL5CC31~vBVQ}u4H6&Blx$WQ#UjsOmVlBu-89&!gzIWt zdp!oGs1X@s>vay~e)YrTsFJp1U~g6J3Og>LTl?Mr^Tkz=09U~S@bZaP{XY%t>gQ(2 z->+NlV9NKHcyRzR6TB>0@+!W7r>b;?pChgpR4%bJa|YP=r)iW$kQ(})VV^*a$e&< z)LZdL~6`opwUkE@BjYqn@R#86EM!m$D=e6-#2A&l_Wgv6o`Gp zco8AP@c~Bx1V!vSP!E*$A<{;g!H3#s$@Oc#UI}}E_EA!Z5f27KUNN^+*}ELeWz9sBT720nT=y9ITd^=!0SMg#xVr6CLvuc7q;>UHM;t zdQiG!0#R^I6atuFW&Zs?;ytJC+R|ei2M`0j zO)+4sVDTfp0&*dueK;@Z8-R!(XbX4@d-t9o+dj~s4qECL@&-zd$ZD%cfbH)RwB)Jc zU||2SO*l~?P~!JMI+moD$o==3TzqwBfCSPKQ0(- zz|VmEVPugPM(G&J+*CPF339n}tqpikBab{bDhTjfu>Dc)2czv;aT-+w%%yZR!i9lG zP8_KO5EB7u!}tT1M=o5+ekH{~|E5;X10Zya6}=5Wl7j)h>Z+@nQbHJOCHiR^VwU5p z#|;MDZ+VR4gFHXBiJUx)u#)<4+`!V;T%tvgj7rgr$+7tz#tp86Y#+x8#|fwoPz9WC zEb}NiH$WV?M_wW$4@y;0^puN7X6rvYsN+O`0Lqu&ahhFFO9;y|(FYj++#Fb8{L}mb ziaecEH^SbvK$O-jh(wG+QJx&c&Y?J<%QpVYiT7CKLL%>);0$=cS>%Z-k>p$%xwRbW zb*zG76m=kuoA(&{|AufAVU)TdT8GjfqGWXdO099VdH@7E zKtFXK_{sAnGTtCJJRy?9(i2EQfjz|_pRn_)#Y=7Nf zj1vYHV*xWB=?O&QFp7x!G2RO=ys%j}qH-J?MK@|Ae;@8g>O)Hk0lfoh05czB1|*OD zfdc^J4+H@Fa6tBH|GxdQ%xZIk`;Hfp5%DHe?tCXe4TU;i-~`}Y-71`iJA{LP)Cy3C zJ(3PlVk9aAQd;NP-tXST#=X#m>N@?U^7zG81k`J_#!1F}fDFXBxH=LJ;(7Fcu`M3WFlDgp0HZ*=Qa`iBij6 zWl5k4NVV-Ho!N+r7ce*5J%4=r+PQ0#mRq*#Q_U&^w$1`Y&hq5`9+N5oo+!`Ve#f#> zvz}Qr`@K$q>q_|BdaO@(KP7(wj)u`jGzi0o=M(XNrcn4pG>vEzrFAJo?bpI%TNoS2afp(!K8*2s!dm|e$zh{c0KNxOfw9#_=1O!_ zVIMdtfc=p|067u+j2t}HiG5L{KSa&X-v=BQ{Qc`+|GME9K>iqp7WX9f1$llLe{G?x z$7j;OM+t@;z_>uO60U9j_RzPCFu=};VXjtd|r46+vM0>EKc7vZ9s0mE`!|f z+*X~B&*zP+It-WT=0O*^ND|0dI)7{k7}Ub}>~c+XYT$7$o^v-y0LTsRTH_M(!jX|R zxCFid=cG(iveP70YS=RK%v~{O5$sYY!06*-Isuj%gNL$kCj?aJ5H#uU({uF4a)0x3 z7?l%I|1=npzL!VnH3|yg4-Qa&t`HZR=|B%%en^v>BUQV9QRsz6{$S9<(7`ZiD_-RM z@dRUB1$1%-P>l&307U2?{_uwzdW)v6zjq3Nw@x}OXTz@TOKxhTrCg=~+ zE07U(L`fqT0`pwi0+=)1?3i;*d@g_n=`uO071Yt$n!}E3sT%$`8s=K5UG9&IU5vXY zrFkNiwrvL*9e~w|`rx|g_ea-W5C7PD zxFezD4~7R(CyXl?;fS^Y0fYF5(mz}~+J@JX=2c_~P6P}Xa`T9&kxxJU^wZs2-}=^u zLxAt$SRkTAM4XG7l^39V1gQJLfPxi|963@FT7wSdhh_)+}FMsF;%PXCHv6S>*3GB(6sahOq~W!8~<4gHF*xhGDEP$Joubbrbk|( zPL<>HSd`}T9oI=2AU={AdFR^N&b`ZBh(8qv5%h1Ais<{WfD!p0rc(cfA_kk4gtrkq zg3u_!z+qi5{vY|sN1BKo2Jp%&uWX=cETaZCRi>xoL3EFDviH9Cy-oBF1BjY@H~>n} z^Of>)W5RME<-u?wLdUp(ARvu^T)F1ul_OAYx-sqe2t$jU|KI)H-!=Pp?m z2$x;DM^W(`G_;!qeLHIpYRY+(G)fiC6Bb1R(a(_``O@kZ1sh&G5&1A^JE6C z97XXQep4u6G}{N$fP0k>2E_7myI$x>ecxtpfF7#OwSGHl_i+lKA3|0k6Z2v zFCD)!at~WBlr->+#)mEafFS=*o7s47(G;e}3KKsBZy?YE0|Z7rj4YyeU*a|e)dTWZ zhCZTxa0o!iL!=Jls&Km2y<3h%*!d2BBVq>ZkDR%R@Bm3b1g(TW&C925W?e>W?O~7s z_rt)!AOlH2i66KLa3r)|{2;@KQ~<_+V})}Cl;wsSKHs1KIHwrrlt09-#HF9M;Y|_o zVu={5-#TT3xH1~Ri7sZ{2qGEYRJ2ZBW4~DCuVcAW0ArIG=2vO~fO* zuOxqtiyV4nJhHnFrd0a;HpZ#+K)?erOElSKoj^-mI5&R`g7-76m+Kk<#DY8t0)5k* z(wF_UHrDB0PE#Hf(I`DH+G{n$Je`XK3s{qyYF=5kZFQyx@&IXHn_y{ga1p%#Hd_RJ zq&N9NgpT|@TyOcr_6_k3Yku^t$I^b3Fyg|P$UAG!fBy4apC zQ=3{JqHfjpN7q3ZQRK>jB3vT6^%%1n&6EP6e~vY(#uFuXNGE{ffbv8T{oqs}>V@%x zv!iK|SxB&OAn<#nP{1vK)b97b_kD3bwl|yw91}P%2lgLu`uXRkN`Pi_7NqI1hFKWY$*NzZv2bj zGP@v4?KuIISUKei&e_nf;fSHhb+Rf&8eq=&mt=HZN?nh+Lgv?KIAG`|a9I9FsqTLH z=Fk?vP6Jf^s>I}KL>se8e;o;sDjW``LUmoYGICMR9yUB8p0<&gLIo_%zInX5?0X|V zR8dmwOYKMicTm_GxIGvgX(D?S2J;x-dExs-%LX5b=>*MD!x$m2j{LBmOaFb(p~q5>5g>g;%xD^fiUk}`APcBeIOm+R zo9}%+0FD~!6!gtg^qLxYvyZ}>ai$V3ks*Bk^PeZq*h+Igk^9Fv*t2(!e7~-#saBr;GwAUm!80>+PP<>$Znx!bu*8YMO96cK4^G~Q_t%b$}h^dWYzqFt_D zSMpDt0Ah+d`w>&nI+&_bChWMHAjp+0dRqa{I;wK_50vLHkn$x(PpHl3rO}nur@|PX+QClyI98!Ff4! zXcbCfX6s_@0Elx7H0RmRzOdnlpkpAudzPp;Vw(qLztAth87gf|#>CArL!Wa_0(p|U zV^8N!;m||^xjT<4Mn@dg*HfVU$9_)ROBsx{4x7^*{+eSLedsb`M~2a@E^SNK0x&1K z&9NcWL*>c>_GxBDyWbo)U=}pM)Y{LGIBOsZHUx!I_j%G5wW&9@fn z!9761c7WE67#x7(<*#iXY)MwlJ_iEdVYnWiATQbv4=Py=ge>uc&FBPdBu8<*_B&<# z)p%lZhukk%|HxT`1&rtPIoIU*jEDHw*kv|*&j1%j7``h2%I4*E-bo)cjw`vJA zP`JE@(}NPfPqt)5q&@!TZ~mr3EN~1a#}knMP&AvfUHw$ePc;L^o`F|ErhaQ+J#Xq9 zj5R3oep#@`r{bdYYg*aPp+qs6}lefxjL@n=&%IZ0swC3@~cvmsWc~ zb=FhItJ9e0SXd`ny4TXO8#G{))&}5EqBW=p=p9GSq4RM{B9BgUn$r+SGIN6HSf{t2 z%i$IXnAvJUHc^aJu_*@Y@mb?X$Um*fpdbigzEASk-Sy|3*(BY z7~}ql=;mY0-~8scnp+-_KZuGro;sFsDuPcy=eF8kzq{CYyw)8*oX_KqJHBzE;MDy8 z?7fG(9o3QU-D{t%3?fP(Opp*cnqaaq4q(989($bAJTsnq@ApUD>wCvxf^8fDV-w66 zumO`yG9m{dM3O*RC+zjruhy#W)!nOm?;{Zew)`9mowHBpURCwhTkpGJ!%LFvyeMst zjl~w>I_y&Mo-o1*%EDMDS_elSc~r4@(ZXVf{Cs;VL()t@=k0qhcok;Cocr1b>Ks)@SdU`8Tb9h;-(f$L}DWOYfgx0jbWa z@UB0$u=5#a&C&OVjEM9sffOPl$Hun1P=s7YPe7#453<3iAIAxYfCK*Y% zCfV1zbx-JZ0!88cxc*y(b6|P~1U-Tz_nm)pY|lRXOtE6c%Hr@3CKwA=a?#X0zT!$>X5x- zqeZdj@{$v}K+f9)crRq*3%^GqK$8Xk#;r+pExhm43~-4@mv|8|ePxh`@;7x`~r?bCk{@|8*a4lLlZc+zVJwx;@rq(-kA;hMgRICbb_ z(Wg>J;0A#nDoVx%*~U0X-4;X^Q5Hyg=4=OTd{6{b1L>g~gq@lW_09U5R)NVGqeRHe zKaf9ce*$;v0WjZtZ~OxYaRh&m1(XEg5`60NPZj4&pWF z3QCIGw{O!n4v7ee4#x)f;+SJr%XGt0ic~C$PKN|T&SAY=llx_?k849h^l?FK&X;71 z0JmM-1_KHZa{2+{K=ONB(>1Ht6rU8?Vi1p8~Lvw94SBBQBGX6weS;IKB>m7 zx|h`{JeMxvbj0Lv5XTI6&Fm^(6CMXi-6r{ZNv1i_SlA}lnM8)~?~EviB>;irFr)5? z0b$4+D5Z~S`plYAB|#mui@)uAT+;(&w3Brr z@zLNf$=1lvC7eT7y4%(G3oRSm9%G|oUhc(X+~K8%9*Z|sIz4(8S=tAP5CNf6(WbJveZikamc^IPQu%-X``=gQKS>GIKS2?A z+^mP^^K4rSV#SITdJQ;lrW8Oc7$Jo0$9|}yVfve7a$YyVP29=!(OY~JlN-<(s!y)j zU;grtR`4JVoD=tjaYF*nQAZuED9j4EhWo_?U@9g@(-IU3V8G;DFM=f~&NGq-(gHeG zk{Oz-E7D1tH+qTQS-coFl`@Ln0a3%!3*uuZ%zxf$R#->eg$hp3$pCQ3QL`kc9 zS~+qq3f0T;8rz>_h5$@5L$ahu!yw^%)I5}ZL%L6?UMly2!9M*=B#wMB;k37($M@XCpPyXAVgkXZ&WP$-a@(DL4xPb&GE^dm5ctUq2p}x z9ex=4ES}b%pG2?Q&WrtWP9zg3{Xru9mw)*e4btqZS^XFD&`U5P9fC=S2$4L2nlMU; zzz>0$X#)aJ)03dx_jm1aCZDh3=x;k8f)Z|hyLN8YcFCXqbeALn59((yY$5^U`m=8p zGm_Vx@i+bepWD6(EcEix_1_*zpd>R)f^c0(?nulCrgheIc&?U6GJza3%J3{-7-&1tgNIG}C>_=(MNIy+ipB!S0I3be<3^CNqHg35pIf`9LHRgW}oEB>V_ zpY0906f~%bEv6d5-nnKP;UWpJ)cisRU{VM^k_3HI)vwC~V9d;aT8+B*dj9?0Fb$fN zjQ4K016cT2*r*-}x&PO{0?zS5C?r?{ou4l(ct#1~E3kjg-MNH|;C-Io)C>7n3nEqEKt+rMewIp>KzX;E z7smhzkAMPFa<~L@0tx}0YrsKFNwu#1@ww3ZPu2Tf?6cFmUM`?WxOF-SKU+@`x9A)Gha*iZ5a4Klua39z{2nGS3 z{yf)y@sh;>reCm71F0s2!;lCVilGQV+2^nKZ*2syQlj)_yM+56S_yO7PCyI^)Pkf6 zqT$SRNCG&&tuo~z193&O$vW@e`K`I?;#9D*Y96kMqh+awbvijrBo-uBF(&-n~E*PKqD zd`clJg=|(eu7eopC!W)9P5zWu!qrDQ_plY7>C5W(4G4oIL&w^up z>#lJVbCu@45*&IKjU(c{t(XL=Qi@!H!p9C zwT&!-YS2o+))x^Y#{J+CSfx2%ZU6L~Nl0M(GwMk4#E2u6|4BlcBsl6gVG*j|Y!c-s zTQsdl&f-o`sBJ&=}eW9N!^_9-@iQTLRJl`Oo(# zkA#4`duCdb%D~v4(tt_g#j{2-L6y%aCeILGK>U_iavu*qdn@RHv^8E8bYoVYDbD+#0}s-64RX}0rFC

    R^#&2O)YwMlpd?^N(F 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

    !n}0w7Kx2i}eWvX{v3k zkvBx1iH$TSS3Uo({LWestP84HJnzpv_dGox1ha=8d`Ma)zf+#jV${EwBoHx$f=;{0 zd8YS`o(1dpS3<%JQ2fqBY|Li|WcL}U2kVh4`m<`PvmiX3-)a&*8=$5f#}eXZNR6?IClLW|B*SMDr`kdRa8wZh z3da_E#A;W(OmZ4?N@(S`LLm&?>4`)5w5S2vUH) zKRxyLqeL-i01!S1)w*?S)V6miN@BY?&R$w!vqm|-V?TGCkQ@)tfQWpyN^gty!TayN zs}(3ohmiyQ0ExDIOP#1#2)r7r_~9S$3yh1n=SjG6%RE&51J(sM(RSQ7OchXNGyZqN z@h56~0=r(?WuPxq=NvyRmLUW+2=t6K0XgpY<5Z`go;-;UlL}_PJSv{6M9S#-utDA* z@iBt;3lf0gGoTX?uIac3Nw*0|j)s=RNY1ZBAOR~S~lc+P~&ed9j(=5TAtM>tns*z7aO``AED5>IS zRouP8VE`fc*i3L{!Sv}yUeolR_ccnU>aKj`A!K5Z3^pfm)HV|qFhn#K4jDdhqK%)m zs6$Ku)<&TfMF38haKvY2a|DKDMjJwznf_$c3P!F< zoB-K`>D69y?KNtHO3?rCgAcWe(j=+L(FtFH=Z0E8eYqoF^zU+_ruSt%SWXCawb3N4 z>I=K*eoa|Bp8Sw}Hg;9t@@lSn)#~0iQ_(K(`yC0pw?Vr&?*wM92cuq)PH7WRy>VR# zsLXX^QM(x100e$yYJtH&{O|*Dq5Dur%#5ke+90^`Jjkes%KXbe|5Nji|Ns9NJR;;i z4WqG5J5A&J<2nW3rAcP^Lt8-Ye%}dpzZ~BcS6-zW5>G$%baT^9H%S8Waz|q9L$EVk zAp_@!WM{iN`!&reVwQBy*=H+C1`&}&0|I0?pQAt&PP#!x7AAO}b=EmLE{RMt%zXOv zjKWs$pNMv_)~Mo-Jn9Jb9pSvw^M+=Iuw*+fE1yCh4^Cen#N-|!v zrbAMbKBSN2OR9JhJA{1>kz|o%4i!}H0q%ub%Q$VhaE(!SWUQBS^qfoskO-*9g1wW= z*IGx6PEcN3E38C?C6k{`nl1(>f+s=pfRRA&(yHECwZ7j>Ox%2ZjZYH+2sMN9?G!+T z>|ZkVea*cLNP{j5d9SF~pIA_~+WuzeYEb*ccVW%@zOYW38M_Ki*4Ns`QN$&T;Dx>{ zi?^8~jcnsq2=Tll{eP-5sxyO4U5~Bp+xIg6gxAC}81kPf|CQHG6DA1+1R5$f#;dNn z`f62OqpBkKbIjUDc6}(ke?f;7J1cBnUm+I_sQI;tj+C;Bk1J=&3lQ`4w|D13;Y+R4~g@lxMMp^EL8-AgG;$aU_5F+SO6yr(z$g^i3U2A#Fae#X9 zJsO2^Uf}M37iTaC+y7r;0?eHK~Av!dODawF9^*MtN>$$(NMY?qNh@6*`ndx#{(!I5GQ{Gzly z&|^@(MQSYM>quD8n^>b>5*))8;Q)YA!he9I*!1*0dLN-&^DdK3=nQ^+M+mgenRiW} zXOg+S`|f`(pD5^Jhc9gd+qSC{nUrqV_eB=whx*O~q`uWo0{c%2De!Yw+11ZJ*YjsN zi)B6AVng*l7j@`!4t$SgOeSo$yUfztEN*SGf~tr%fX+|L<9NKu)b?5z(ItypgiIbd zwbR4S`_riqM{2j)BaF5EYn#Ij3$0TQ4ApddBUi}gx zJWO+ZN8)&vo^RD#kK!^gm@m5MB2inrM8~!W7|I!-lB=xLAy4-KW?ordCrMUr1LCXG zBHcM{dKRGl(Z2ynnQHK_kv@9^1B1Q$X%2-=rFT7a?)pfIN&iw%sr0mb6KK99W-@3o z?2L?UNN^;R=(`i_;BKHRz`g&0IKR;fpgIgBy{#Hpfv16qfswff;0OR?LnZ_@`sK|x zYj9>x9@Gk`ygxD1=VJ7Pb4=FQuV_%=o2i4#F1xHbTgLcE=I_7nJ`H%>jQ%$lEX9w>qvXsuE|*S+IsLP?Cq%vhFCgto~Y!hhOIr; z5+f^nDC>D2NxqnvL8s%{=bq7a2iHym=>RN55`nEW-#GK}d{6-dehA{e;2ossTR=2| zU&5Yy?$sP62?II+XcN3C91Gey6W>`I=R1Up{Gxyaam-wor6?`USSO>Bqll1H)kgi` zjK1?FA;^WFCGGsCpgPsKGQM&HWB^fz2ox_k>81fncsun5?ofx~t2Q|>B?<5Yqk`@~ znr-h;g5YcD;9!#Ar_FTsoyo{jB>{lUM1~IsV5?NDEiJ!=#7wU|j17_myeo@fvt;8q z<}ECYBeWE56YI?_#xXuc3#2L{2}tF6d&lN#NpDho`tBcA$!(9V2-^@74I zXDV$hNCG(;>}+V`IHFfFWYcy70x!7D5&Toj-xM)v+2L}25umy3vdeX(iA`iwYVy}E zTM^822{4~cRrL9Fb<&@oy0To$wD;au+X>ZXL{28^1@x&utIq!TY?QRm{P%&;l+yDHZ|ImE@U%xN!@w841s1u|F9=HK*D=EF!Lkgh2<#e?c(Vz?8 z{)@lRD*NX@`&o0_ZMW&#RxJvMh%31lLIo>Kk}H?N@~rL83Gx94fY)P0&dhU~IQi2F zcg3E%ycZfvwHpA~RBv z1sD9kgLNsJTNoQ07qjw{mII)fxGV8*19Ss)}=~xqfV9t>VhZXV9e{4&9C&tVeD2mML@nzJM}cJ z%Fw3gxi(%lte-#Z5$>nLsR?G0EK~i{5(pD9f$(;Sz`aUtlj|HD;xk!MWCIm}`cH#Ak)L$o2f4kO46pvT?_9f~IpC4P-zF#t1kq3R;1v5XeVp{jC`k z?pnp#>O)|wv+J1YBjKDcIENvT7!BCX?vm1PO+%m=RwYj#=0A;reAdX}EV$nnqkhwj z8-`+npRr+D0>Ea#hz~t*zTSA_HCcf>(1h5dT>Q2P-_9-)Lnf;i)U%#hPkI8&-+UK{cpq(Pkh z7%?Q-y!-CEnjio8$13mpd+8JFjJs>{wbC>_5e=W>_%@pzc{&^$($*)4CjGa*^=&06 z5}?u6hq%{7rhkynO_IxU6f7MRjNBX}2J+9dq@q9lj4xw7MBku#N zem@DoASa-0v?YM@-;o5IU>H0<5>|WTGvhK^XNQW&61{v9>BmLA6C_UEI>)0xF!udv zBz~wm99qdAspE?X12GxE?>JvR8B6pTW)@z9Rs%#r+8FRZUXnJ#wpRDU_r_#x|55%# z6#b&eq9wTbl`v@%q?BEUd!+oRrar4v?a_2qyGuu%vim(7{H=Bkqt?o#ZNawVg#}3w z8KTK79e^sh_Ys2iG#uG?B)F}2c zslIq!-iKB-9sMEIy#YFYiyr5je`&uveROaaDLH=XA+16N-Ou?u zzpC}mnxN{0&ey(2Ma^O*C(RXTq zA^~Rv51C@77IaMT!3Q*fA&8q3C^{70CrROeS(J+vJxM?3g3o-iBucbINSYuyJ}K3; znJrmwU%%s24(H}vL0FcScQ*G4M}hN&4nxkr>0eJEciIQc)`LLs8AxXIz4Say2CsJ@ zd@iWVBBOYm2T(kn&Pn`2@)m?|<$?Jb>ltGsjL<+2=;ecIDzcuo5t4)w#y{`Pahr@X ze3JLXb7CbXc!j6$v<7hP^G(yy@dJSdOmHa^U zT=EApPQmU-0(NWgpM%llYh zZYv6um7rNGz%@Y(oCHK+P07-2w5J+a3v!UbkJ@`p`j#y@vC@O*3yAea*}&PG_cr zC4~1C_3}bQXtXIPIm?FYCu<$Pu!61(Pcfw{zWB+-9(Mc|B zB(@OCsNHSt-^>Vzy#j0EE3f)$bBweEu)~Jm0NXwoGA(45;v~=X(m8(w?#CYcjmqTq zK3^&h6!!`e4A4q?`4D!|Ss;+BuZMTlwVEaX6buOaz$4&ub3J+fHnrzo=$so&`vPP)RPX6Q$?I6lk1H_-*?ijYVkoTeB! zZPD&7O@Vn?`n$Jn&=2I(jwcWBrHY%M+5+&UDqDo~4EAABu{i0AzaTw2s-QJ$m)ecn z(brlR5dg$M&^|}ncB~0NmjO@_lUNY^&C%~YSuF3r_kP%P5_#a(*V`pzx|p2ga=!UU zSCGN1Vl9}AF!D%cU>2}h;7|UoXu~832Z4_8T_%o(mhf8V zo_B6@%{A93WJrS2k-Cy)?3$ran8WHVfah~4-}_#m_XCN43i)UIOF;t6CM`!9=0@T}Jd4O=$SNbc}Ll2Oo{fsluYL1b)bdnYbj8DtBAjvw% zr#klK>sLvp-TbCqzjbn6NMz2DmH@E)t#Uq)(*|~L6+UH8AebW7h2{p+5hN(er;)^o z;vz^l=OND4m!dL)@R7_MDd&*Kzafl#xD$*Y+WN~$v#z!3WnHqz9V(SPz5I7X$O|K{ z4`jb;+X61YJMV0XlIO0c!WsBPD|(!uIkboP6Zn6NJdZJ8 zT~l$AAX{%uUxK&1)pIe}u8j7+H#m^B=i_m@r~uSqv55#kVz}(&v%4S@khbAQc?)2m zm;EQ(-#_3)f^l}bp7d5!3AK`p*kR2N69Z8A7LTmd(MpgQeanwnm^4ON>{#){3W-pA6U3kGoBAjxHKJPsuXK=hbSo-U@ z^S$u=3o69(#V?+(EinTAYp=hmEd!p*Iw07;B9$ir3~m7T9()oIH(CT22-1Lj?&FU= z-u&pg>%>j(b`6kWrhIKn9GiGQ*JoF9uH5rfsstjChO-5g0+|4MfAGPFbc%uXuOoTU z0dZ!h(WJf?#@gBS4 zp6o3{Q%+RUtReQP&mMZ1jAQPv8VwNgC_>i6iKA)@EAB^Yqvpx}2kfs1%L{RO!Ja!A zny!NXqYj=M72V?t1ONnNLH3;Ss4__qrGoi6*PiF8Y&1M8OfIoV0xMc(c{J@SeRmZ>SIkwFe{Cgv8uDa38GP#=Sr!mJ!U`d9W$y##x`-EBhi9)t0?tSfR zUlTRIH=7%!-_5)~;!ceBLE=M!52@L2e(PHbSN}rh{qMZ<4&?*Sj@z5N z?!2=(?%3m-zxvPrMT0Rsfd?PBUjyQ`*M3b$$IzYs!N2~iz6O6^js{tiR4}VR1Dgz` z>24gg`;|3H&_e?GJV{dMNdtoe**_lxivc1b2~O(a z(e9xgo4CDMXj#CA0Ev7~+A|~(C@WeO-%i*Sqq-oTBuJn^BoS}Ral`7jqM~Y6)pS!w z>t!Sm@*~HI?KFFdk9$kg5*UXTpO4}^l{T06t?;ZB@$oTEzb6y#J2Q!NQ$5 z6o-s~?R9E|WNfqw7~M|^{%*#*~kd&pafJBEI^YBw$*A0!(LH48+egwiUmA2>Dz*o z7^Fi3nUDA08k383KDE42j3j01V@=6$_qotH#|Zl$Q4d}g0umdKj{o_3R=l`2R4_Ka zl#cnOQv+zGL%6~~gma^)z<4y*sNeim?(FMcQ}FY%O61y{zMms`_x_`UUUR;N`va+V z`kriZ&I#bYXY7@YpUNF!A9OGf^WpbbUU{Vky+z5Oc~+p zaRjCWYqyIQyYAC83fLRrn^yT(9+#8#iW0DL;q#a9l|E6W*J=8Zu8IA*kN~J(w`?v1 ze`fq?2ei_;F`%;nv4OA|vN66YBp$c`Y)C+Y)+@FdTwYhrGi2ev_2yf_`7WJ<_gx_6 zz19o+8U{8C{X={IRCb~`FO$Su+{B2QLLNKS7$^CF0S%OkKnV-dD*Sxnp!Z5i#r|b=WIuPql;QmFN|6&J6$uX@sFsG_SBDzehsf_AcySeo8V#4Bi1UsKhcDN z!iB_x_a-3%%4e_B!ej`JcOi(=5(UK}d7$;d9>N&oPC+$Pk0A5ONJ!C9Tnv;IRYTsF z2`Zh6DxkX|^s=0&O?&pfaMpC`#uxE{dhquZ1U)00`|#BCCX=79h!9ZC=q3rqJ#t@# z>{mh$ekf25v=;myjiAqz`QwUlsi}K36%nSUvL`mxAu->SU|?~#_;z6^5lBXQ4w8{3 zDt6#Fh&iToXCCM)(LIo$P6EJ9ucWv)gF(l_Xd%3OgzMNWAsCS{vw+Z1HyO<8hE1Pt zZD8}WShuQgxGy$+pJm0ZKSp(Sy{x)j>tjH21N6tDG&Qn98?+-e%K2f_gx~~>+9Ya! z0an~w=KU{i&i~?hdafY)Op$T#d%pMzW#0{x@$#&%MgsIrX0{zGs#*{qtiIilO5YUj zT`_+>lt6etEc#jM9kidfGF#M3nEh;%pn0XY`;T|6N+j!qfDN-7A{Y?%K{87F6%l;e zD1tz++es9U6<+`#eMEym2R4Z}J-zb3{LAmk^N-icnNp`4F>Qj!1U29_ zFOnqS2pK;EMfl+lexL?aRLivDxCbrpcB+>}QBh2jOrk*$e}YPo$iP2<5JxYb^$c0h z_&=#|S6-F-J}*Sxk97-U{u0r@04aP>OoKQV>@UpV^V)X3_S9f}k{$T+zr<1OJe5@ss}Gf*K}`T zhCf3KW#-0CKE{L)yg~CW=}(+ z>@VSdZxpp7P<~zzF}_&?&p!K%+TkkiMuInw-MZ~V-3LyQ$AFx$-_VBOcN)a~WFUzT z$R7M1ta?EMa4|LXqE+XsuBpxpZ2$ca&~M0izZD{;PCc#3)%nuf#=I2v5;<>@0K6SQ zE>w|l+x8aq%Y0c9l$GCs_UyNRC^+CRpb5Yvgr2(+bt$|vn%puhPtj{dp`VutTnpj@ z>x0iX3OvA3l3CQo^jre_Pt^rTT?B`chp8%Kt0Vxfrem-g3*XW_XU6E_lsVp*N2Veg zK8NnBkY~0*R#f19-BiSW$5;%W=;qOF<2f4iBN0kxZe>%TO>j_b1F+zM(2U%ditW%x zWZa3zkyN~?{Xh{I8ZESFge_?k3PF{R*JspryAJ1f1+%v1$5;! z2;V?e(c=53!A3K4*(~3e#rirhYg!xNP}!gni=n^g($1KxiQLS~e6LYs)8Xj4 z(MMBo9{Rcvh5624J0UTX>O&Rv7S1`?n=IX8ew1fgpeL%{Mi-hy*ZI_ka8E|6O5t zs$YbFK*$L8mrC`_JxQD3;fEepxEv$_9f9YCu=fM;5V-NC8=K8S;?v4ALgRB!N5p9x zp2xbRqD8e5lG`TXH6U~qbO=^j@?Fz2nraHIjLt6uG>vm|lq4+|UwnzO-Z^IW6jkd~ z&cIGhUYM=lSK0<>2arT*(r++g#S6nll|=q=h@ATarIKe~fmmnS138&4F#&6W)Hkia z0|ccwDry3NAne$_Jrh3jJq~R&M3Zxbyf2J?3JLZ|_P~YlIJ=*7o>CAK0`cIpS^|I_ zxtU1mu~qap5`7n+So*r`wv3OPq_fMe%K~k(F7ol``9rXp#?TE z8wZs%V@#HPHzfif{^9#1V}o0_Zqr5Qn%sh<;{dEzK_Uc(Iu3#xOixEqJg9URtk0D+ zEfbj}z?jdS4nSfJ8sC@S0rgR3Y)=vZ8;b-A#&UHJVflU_*?j6YSatdGGlKsxD6ngc zIKQi-bAI}p`eCx`P%nMg)%=F#kaw5jrk}ce@*UkYD!4a}7d1bOfT+lCmR>*i1Ofa! zQGkTp#yB7M8a;hx{aIH(m3}@t0gQ}(OC|=66%ifU1~*9mpPn*g!LNSx8V$$~$aDa$ z0;<)o$e0}!Gwb4JnK*zi@WKnv>qHI+!(@OMSiANndu{HYj2~j~bBI*cj|+nz#XwWo zY@%{z{np5@2?+@S8XW)v<>^8;WIv(D|B@gg@5#?OkE38(uagw)4@{ObR!30WzC9pl zJu}W&g);Zg`G=8D5Pw(vOH8TJ_Nq)LBM|{3(MQb>k+Or;$+1x-#-Iu~sn^Wr1C?hn0w!?>T0;aD)29P+gry7vnHv*Gj>XuH7e#k27SZ8CqEP~o1a&fd$bCTRJ|XpK z0wBR0*o*2%nX|$x019kJ=zM3M!ISrrEE$PWNIl4{`e-cFjyd^UG)E!BQ4s1$XZh3X z;d&l}!44L+w8gb+@-dz^1D?d!9_vD717qVY#ea^MQ6@HJJ|`QKUAkPir(%s;aeDKG z`(DWcF2A!Ldk+C}6++Q~)llz#oVFNUkLm2o9=@MO$jf{Et0C38+T61DRg`YE$;B|2 zrav7K$ZRWmKQm5;+am!O0wAO}T0wvHSN~bH=W)J6?;mwQR0Yo$%VC`CZu#Xen@taI zQY7GN3HHZ{ZvUoDk2L=-YJl9wmtB6jnn*E2&m_RT^4cWEU%lp9d5!axlXA;1Z`N3+*sjW-BrR78A(5oufd}qW%>tWvaZxmES_0cb@2=CmKs*U@ z?8zs8Ud6YbeB#M=uP}lwtbQhtNU%XtKtlN3#!*TQzD%x}HT+pY07w8dh|9jh`h?79 z$ZgpQ2Ix$KKLKs{~U0rfJaS7!< zp>4!t-+lKz)iAL3N$IswL2g}Y)!Ts+;YrSAcD|X2o=+Bc;RalY?PcrC#c{&kt}vPv zppEobD*g71!1@-#gS${hen?osyAuqU>-FZ`aFX%Mx!Mqw8PDmYtGN=Lic%m|y!^Rd zGE%bcd!+J5<`i%93opDt7VAm6Ns+O$FwhncR--`~i`PR|l+BOK8S)OA0G)DQfb=KP zI{>G_HBp-MjW=J{iaPWSyM;E1$EceA@=Z@JxDtl8LsRGg7|GFk?Y#GLMN8aww%IMT z1^fx}u`ZewWxYGsDet-HPWX;2RHhxaMFhb5h>Rk!*~1Fvv)C9@*?9EB-P8UpUkR=` zImlN`cBb|Csvv((7M$9X@Au%VErWbf_vIUBPA^rM|EuQKOxrvL68b4W>yx891x;1v z(_k~w3ibqHmIFsWBc?#~yJ-nwCWLDqi2=QP_%Kxbctn60TzB0M)jxnBMzBBT=%dtf z_$KN9!xD#2aQWp|YT$rpaL+w=X%Cu&;meXtko3|{_}R~Xsy4L*rnTBWTbhyNG=0h1 z=-+`-9*7om>v!C7NAnv&Ce}m|B8A=yy^o<9#>LS&?hHH-u=7Pf0a;{<3rnI%n)jA~ zh+!6w#W3mj(HU`+S@pUe!o(T|UBHh4`rLVmdc3A8pd)EdW0So_EuE(6ik-=hyA}WVQQdk7&9%cmVz0>RfxF zZ>l}f!Unpx$&#US^P_Bc{q@&t9}S2R_d1A2BW3u%DePc^`v)2{)i@+>0zhCqu8FEx zo27+dneaG{4U%c2z6JRO`AuN2H{~@+I(&o+Q=uXL1|-Ng3BU*McVmdY7^y>%!o>=L zzmTLXFD=zK^P&*!#ov1dwtUY`I&OP^BoeqGMcZW;? zzzJZzkSu&f5C*>Plz@D{ui-Nkg+@x0!9g8<)^wQzLF46 zqJIcUABhWv4yfkaGD;bEP~!pqOHelr-Ya|S`G!2L9DvcNX|m5cH}X!kr`)U?6A&cVFaTcPH&5h9D)vk zb{s`fSVUyrC~Dz@P7jSmO_1a<W|)-kOLpv=_I+t+M!t%s$@8I4d})05j%+g>;T@>$sA z*#vDWfv+Qkyo5b0Nzl${NK_^OzBKsEVE?kR?wWX2{f3l$H4wDYswMX|s2BrP<$ROG z(^qr>N*Lbz%2Z)_e@}0bb=Rwz+9sIQW@`rg0qVrS^8C(d;|&B(F1PAO$Uj*xhs$~( z;bmH6Yjoqo!(L^71-_@x|MXK&>gXv@`zfbGfjdj1z)(z8RvAsL+@MObnCOZ&7JUj>tl}KXPFGh*MSE;gRw+QGT$;lp)46wfW z?qs`t#M@IB(U1Vpo}h|HQ-FViSZvwycJw(J=fzt>pfy`$&y`M&OdtMy-5>aIy_+-e#&wS-|T!2W2J)%-j~^{0~5_sRMJ_+6@&hB8Hy5e53E zw}&{%reAH+UjErqxRW99Wp7?h;vnobPF z_v~tNhpkxQyMYtR`9d=Q6CrDB@y&GgL!XdXgUHMY+QMSy|4GpY;Jzn->&Tx3d)6HHD$@eY z_#b%S!IBs}rcnO>X70V)Wjl&A-<7rZG4kGf@4bY)Kp??14L#F+@0YmKPk)F0Ab0-1 z^V}J_r=bA>4RRnPA@99M-X57__O7_`i_BarGgs!Ss(lX3-8=_LXO~&EDl;SEjW^yG zq=~V?JHCIX?YBSb%>N%B_=8oeBn-D{z$ej#KR{c7$3QM53E&glqpxw-UB5Ife%cc| zcI@oQs>^(FzVe-0FP#4PHL7aKeq>CJQNGWNlOqXRzRfU=Mn3$BGQYF}*f*RLOi94= zp`b8r5ls*%68Lv`MIV|t&qfgEsef;f`50F`N!yVmTbxsn2)m+>U)q94E>mZ%%o9yh zfTROdqBDNbY{c7%K6chB(en**GiFCzAKU{xKjK*>mGvd!$)hAH+*^twu7NgZZj;^` zhpi}n%5p@zabem`zLz94NGZ_2d6$dV8iJkw4fqn`!M@-9^NYZ{PGR8bSS0A$%nVMY>Ne7$%`5m1@U=bFF% z>TC1djxxPH4i);AR3lU+9Jm~enDn>`iDww;aXs80#W~^#PQ15s8OVhjdpAsUg@Yl& z|9oc1T~z)YI+=ca`CX<}ae)TpU-hKAPJ67bbrMKJ{HZZ=S5xGxt>t-wI&~#irJ`~; zX=pU^AE@&Z;$v#4-9quLDd9}0V{jVm>?)aX0QD6nk6ajmJR@@y5`$M36KnaAj6zJ{q_e>J7 z7n#?mGA9YZkKj8xIY32Ba`4Mv{-S&PowtJ^S3wNyn)Zl8a;&Hv)v+1xoN|h(bH1$Y z3J8vPB2cOK2;>M=8qeU*gZ2l^ds+b$4Im`&9U%IGIDnj-r2GQ{^2d5c@H-}fXw!{G zQ^OC_JlykBPC3nBb!PfM2(7xg6bd0O*<#K=mpu;Yna>E?$v!voubZ{at>#{tbf+d) zhX+PKXODL}mBf#vRAlU|Mag=wq(ij?_(6|>KVB|*HqPrsih;lpr>`4H0VaXQWi9!! z+ht{+HtM1H5;jKZue6xq?+OdwmPPRX%qiYln`Md80u&TFbc?iWa0q7iLVtcfEf6=Dh#5C$J78k0)F5L zz!zY&_aGw%?&zz`TxvN@P*ask>4}820?yY71!TP815nK~J(C+ONke>UxJM)b?3GhB zSi>=(_y4Dd|781%YM2KxEp&>WC%a9e_s_l4s1!Y{xSuvmv2fkmhKC>F6-+5$u=$=_ zHSQ`rnl7{)Jz@B$qmK&mynBt5$9v8bK9D5-y0N<+iz$i^kN^?@#v3=IA4+5WMPo4% z2hdYqgY(U%7ZRj%svYGDI*yDhaW|>jC6$ezzm^oz{YBjNQ&+QE|9*+U?p7TY{NI%X zda( z-!#Vc_nRAY%kXInz;Ng9VEKa-u)(}_ra$n=W+B7TM2_jLUs^{;3-g)12v}Z6OamF!NU*8ric{h4OUKG6??Tke zd8=nYGD5k$_vi|Zt;Y4o0%b}JsC4+NLj7aG^mbb!<6%_%5f+OBq}|HW!6P15`u z75;%kfD0ZKKhA_y%D>eyz(>_10MCIR=ybqMH{EQBEy=_KzyE_hrVHbKsid|{Xg1J8 zw}e^)@zF;eV+jD&IoHd`FNqE3f{E_Ti{M`lwRD4*2*D0FKDZAc7YHCxG+@p}a&XMi z$0{@bRNGUKg$*({K7({J;z0$4y}%)${ld8g67v~0`9_i#jl-O4nQjGILN_MY z*Mh$!0(~OmLZXsT%T(e$AaNpVYsvwbg6&=E58SZ$Z9HG5NO)5O!2@mPlM#g`bjYR9 z+|DciF=RikxKaa#f_W`dz10d4v4AV1fCqOv_SWb(63{W>xaOLxRj%_=3u-pQ?z~<$ zgg{1QsC3y_Jf88LFLZN2fZ>OXnPlMHbIvxOae_3u0e{vcfZ0})#+>JfVi$aXrUr;5hQZ|$1Ph3H|s_FPk1)feHKb968 z5lZWDL2FFT)lDXrA>K90i!4xAS`6Lv{%*JDl2*$yJx1A@Q(E!aq-!a!_Udplh|I8t zS^EUbn>7%#ejk4L;qE<6cnQ#~8T5PnI$oe6r$W z-2WdH{1%TAt#}(`5ov1GNk^%uPG!@URETmb_YtiPnIOSifI~n~y79&v ztW7{$0E3`=G@vtfhAc26hN$7a@WKnKB6yco=cXw@5}?yOB(CrZ9#%`@FI3BdePa7v zy3A~+b}ys7HVF+sBra$gP+>y|g!ALu0o!^8`=T4m{v}E09-XZ+$`@R4p`ieLR?r3J z`6*1;!`vSdfk9hj49ZxwJonxVQJjZ|Q8et1(!M zZ2%4;Q}R*Akmy_>-2%66mkG>MFYzjLuq%N%b=b z3XI`it*9LGzBmFBjM!gr1ipjukdo*S=b`t{XShy#>9;E}5N^OzPd?TCO2vk#CNI78 z5}iD_!@LS0#@(w(07(V98j$Md*<^r>46w6x5F~jCj`Ye&VAvavKJusT&q_2j30x!n z&hvr5BJuVT*{*NVk*MH^NHGGz5Fs(n76K}yLy`olaYS~a#PCRGKll`IW^kX5)iZK`$Tl^D~9=jZ7Rwz(%cyAot6)kTe{o+ymZ+vjRs!vj%ou zfA&Z*Z*Kx=o4(BovI1hjnKcihQhAFx9TV?grv^ohfc63slzARQYH_uju*r7Gdw1)} z7~m+|n6UgkAmTk+Q2*WX_)ypITwm$+Yp*5CD+w+oOai_Rm@TZ|UpBM4_K9=zPn-kOgf)pzF=+G2R3Da4i^^q#t-Qj1lqL9>)99 z$bJHzWQQMA*MNMG&|K+FK;eQG839 zLy^OT4$p%?%=jXNVbDZBHWvcoTq={PEx5Fm4KWvYJ6N9?Lx%B zKmk4sj-)*qFuCI3IA?*IAbxyc)_)umfc5oEV5hpB5@8<#@F!86XRQ?H83drBF&f+h zzQdh1R_)zRdG62>s_jRy;)yd0)PidtkpNe<@sLcb%WctxSC(+ir1$E_09&znLh#2O zK&f80KjVGNdVsG6U{3TOr6!W$CM&d>h)-*Dt99=AELCB24YVkZLF8lH@7imxF~SRQ z-nwaSC*dG$c*bN9yWju;T4QnlNwfavo`23u{Oaoxd|B}HHx0hjP5YX93Ye=GtmA$o zcmx7I!MUpV1$2hYFnzN%w>DIp3Aqr9P-bRH02nW%*T0t74X1Dv8`y+~^!LE^zW+Cj z_jQGqo(uE;J-U-QXbeI@d&uTAq__%olGcegp7lc|4obAx3E2waSEzq zd}GtT^mV1G-@pIC-z_nFziO+Y#s`qPMPj{wFN=1nD>w_q+*BF3T#NrOCILrLEA|M1 z>@3|sUmHK>4dbVvmPudz2G#RNAn2KAo>kWV!+iiC*+$_HV(kN}1HiZ+>-7ws7`XP@ zYYh#cGDbJx>8GCdlEW$s43Ut)#2$rR4>D%Sf$9gae_v4H9Z&`KuO-=L+%+u@C&|Ci z>OypjAPcHN!2Im9PZb4t$Etd&_b!YOHu(h!`exbyy1y^K{Bk2U(o%T&m6z@B_-|T< z>}3j$=-n|wlR|*zeiKYVLU^EV|iv74^2c-s<<`K3L*qPAh=tQUwF=GY)vz;fJdA z=UGN#B5Oz83{@@&a@*GZ$Pkby>{kvCst?SEsB(7i-Q6DyrtTGtLO>OO+S!nwDh$}$ z0;jDF9?vYW9!yjvY$^^VHenhcPLoq6 zY)B4xGDI*0vS>GvBs1oBp7z|)wI?v5#QC+77Gvpq~yA>?Y1ZUiOFA|qpghh)IJ=Y5cUWV zdL-wjOyrspBll~s*#U1eKMlqh-_dpgNCg2L%UtdoXBXTFfAq~pqBK1UsModKTuqNM zG=W5mAkI`!^kb>%s+hYU?a#F}J8NinP>s`9$hCFWf1p()dp9-o? z;0`-?9(hX#d)^Xfm{~6`u-EC0dD{C&T5a=1aDU?6la1;JWY`VJss34C8?^wI0x%mJ zekG2=0i*yd8T^;{3@f)9YSoonQccY*sIa6D&@9ZSHE1IkaNiGz@;$jpXJEXZg0v>!MGIOLAj8C^CC?}rLm9F<8k>CGSq%IBg_$m@M$8T?v4 zHkkRzcl~qCz^B4W=Q_Clw9<#Hb%G`)+@Fn&&6U`otD0lW5{h3FsucUX%$Gt6MvruV z2@BxWu6JuT$3>#Pp9EhQMqrV7g*$-l?^!AY#PrKsnivys2v}76XmEfiLFDs_n+Mqh z&c*6208#E^mCb%gNqhuH3}_G<;tzX?fc}uO@(Gf>$8E~%fnx*Lhc*ET0uc30H{YU3 z#kXu=B4`0*AA(iS!{)X>_K)pST4f6NEqmZ4(id;E)kWoQ4 z9Vkf8h)!Gsxn8QS2Dl%D_@fj(;2aqcfT~1pw%P5hT=Qdn+!NFQ z842vVA}*$F-%vD>JfJ_oIb|xQ-(bnSnYX-LSM|536~sfqo_XX*f(%AyUJ0OArrXns zc_YbdiZt1Dv;FPsClG^M;42_g7P<#i^G^^b|MGL z-C^r!!JrJblscp=N#+|71S?j(%LeQ%j~Z@aG_fIXR%23EQlz}&x2ldn&79GzVEVrP z;R@|z@e9mv|s>*m}8H&gaX|FDr!uI9#fGZxGa}xuiYaAr$*^BrN@N={h1VIDf9z|8cJRunuTBb3e2nq=V z#Rmn07YlL|2!L1bGt_|z3H@*0cV9Rcrs9r`0C!!FSa4sM=TD&Xk;~}dG&-|UW6%KP z&W(&+*qKX}Ws|7?Rnc>)u~=Q%s9Tx@pK9I2z_rJ4S6yi{y2c&QMbGcvP$3g4VX|iqGY%5qM>qz& z7QIW1IrbQw=1DwM&1B{ri09f&@z>VN{K|9#wq<(T90pVwz@ZTRsE&Z=da;ZM<8RDE z0&uit;q+Mv+&zmoof+!0*tvZUu6bkMMn8d&-f#0NXH$mRUZ8)f3;K03pXYPQt*o{2 z%XFROhgjko*iCY@vM4#4!!7%`3)Xb4y3a;RmzhMF*e9z8|7oJ%v`0oojd4jC?UKD- z6IiM_HFh=6_Jq5@+9x;~cceFf+3Tx74IU854$*(oVgq$U6bHjD+F*$Nz#(`=334$1 z*-uF3p03IEMVDM`uld+xkEp5FW7d{pk3y_F_q_A1qQ*zy>8GFao&=g0&;~@6ll^($ zz4vx+YMU==fJ8EIwr&d^fS(-N6NrgtT1$_8{Jx$yB-JAGvCE*0-7#Gt+HiJwa`3s< zex!X!;sn~p*#&8m*WIvj;;zY8iT2}%1Z6*X0Ei)(n`p|ecgYNa1a&?Q#a>r~`Ui;B^b?EOuaY#_7xuMBziS{MbA6V@I2o6(6frz$Ge$3ap5300Kh%dpta1 z)dFk^jLISRm2TYKfw(u4OOE}*(0_~byJj}zaw%Lk|AzJD{DDRVUYJiBOq-F)b4+j*5%Gpaf7^pF7gQWGM)kKQQCsh-y^fa zl7Sy9Hb%VH&X%YT;#-1y3~h_@!AJ$$9_?o10;tgJr}){atpHxbI2gg8C!5MnzDGMV z6;<@Q{)gJ%fgJ4m@{5W-f4(W^!BLdtjx!7X5p4h$0({G3q6;nBg8~PDYuKD`B;rhN zOnD8ratOD%Kygur4r+gmk=i9He^@}4Yd z5nLzoxh?H;8L0z$0xVT2#yXKC%VbN`41hNSiI4S0Vr@uWSjT7RNZh5W>c<{?_s{QI zV$E~_aP<{BMtF^S2+)Hja6YA#K70UP4`U#-Ie^0<3j+DG*JuGSGI)g=6Y(AoD^~!% z0KIS1prZKlq5uvO%sh1@6KdR(jeVnsgzUYGW!wISgT&>qz9U@$f-lRxRne3m%v z*lw@23mt&q8`4i#RLdsaD*4J|Br_7qEW?&CJFMC4&_bm|?0)Sl}ZtT~)N~LTi)WBVt}-98v04t8LZB zg0LlB^)+%tVz@j4B^!rB04SU+AC*1=?)K2%O35hcl|-d2Dia8PjHu8y0A42;(#yvn z2#EhMWz0ebyj#VEfGs0*Mj(aEbB!Jd5QOLp(&LY;XO94Em?_Xj+Q)=5;8g)%w~;`r z=1rhPj7`h+!-U?Z*FcXHMZipOB&qJ%#OMp421udWg2V<$kJq3&IOf=63?_{I&A3Tx>ucl`?OeBjg+xqRgCkg0R zsG<%NpIVOcElI!~jIl$E4hX+w z?SYx^0W9sSE36CHcLrjAKMhs6NPo_XN`(HaQymR<5ijCUqXiPlN+lUAYNGEA>RtO19d5G=?&e{xcX#jl1%kr3*5>AXl8Ihl zBQ>d@m2C8;M^2U%3=?Sv|NYU@U(wQg%Ns`)pd6F?Ocu+d$^M)i(l+ffp`7i0QiVht zfgARem%(;}RJOJVj!8nC!DP__BZM85b7ckhbMsk29BHP1Kgk!13>Q-3``2~7<2D)vvKwAY9u&c zJ(C2W;ZH^DwL?DYt$ds7J&A0z1=)kN8sRQbMcYW^LEdeT&&lgW;&PSx76LmhfP;dr zKb|CWk7OUb!@(&^*g5L|zghphB`d`@2SlRb`-mJe1nSdHJJlA}Mqui9 z6>=fh8~a%FAb}y#F-P?->UT>R(ZiV~Kp8ojOerzltcY zd8GCuAxxnk9;XYuo7hmDMco1p7Csjh7f@?d{)*o-Y+eumdXF4Lpb?-UCWbPvxv8}* zo9DSwU#9V06#bGr12kO&3oSkw^*JUUU#VWFtgyRoIdiI7)nC~a?{wmn(LKLJ5C%M387z<(ufN*m79lRXI?VeN(0Q&wU51@if6W}Mn zUU>3}#}&eV*j{%d?BfB(9!n)hn_tcZ_8G)N+I3trMFc^deb!}8ens#Px0u&pUwLWZ zlP!5cGl6UHiTb|ay6LLgC@#S9T{y=mNH`DJ!*hvN&2`)B7>unaAKHja{&4@iky^r` zD<=EmImM_aGUQP_Y#y$~743>_^fsuGMyq6-JP5i4y-!JXKXekV$5mZnC#-!(mb-4C z+ll83M5JDFC9uqgfZ#7PS*~oT_5EekS14?P-QYhc$>UumI7=}HS?%@Zh9GVv1zH#X zlNBp}S<1uDjRc&f(rqmRMoKJ{CVpUyd|kW*Wyo-6Yo8tMe>VI^FsUe$kN9MQHSju6 zJLV;beE>^(Xy4mk^4c)?ue$oG?hHj1VA*o05NypKKp{*CTmXI#1PO~5Arl}y-_u*S z9vfdLWF@45@xy*rEkVipIZU|DSuj;Gl9V1UCRe|H=nY&<3bs3Kqh zo64ZVsF9x#EkHRKI3Un9=YyLJXNmv>QZ2~{Z3v#j<2fXOXy?6cvCCUDrLuk>^xdS% zY*&G?Cmpq<(vK|iLYc7WO?ybI@M@;CEk;myCksaQWT6E2RI8$WZFSalGg!-V$%0fy z33JkBf@s&e)InaGKg^rcqvyb9bkDDG-}^mJtKZX4KhvsoI5rSrV^1C~&k4t$Xo)DT zFs22*2&7mbXp(7?ZoC4XddeFQ@k8=@{kLw=w%j$=7KAJap%#P?0w3eO7hZIsO&&a| zZ2~3@*n@Cnx)7X826L~e0ervVr)4-RF1q+4+lQ}(Nd`!>y`FxSpGu8>%$ERrVD%u(#r;WHqYuq4o%dyHtn!tLvl!=V-74%|M0;FAGB(j zgNXOx8hG#PuDjl5jBy}@q~r-X&K5eK*dRYe3*Z!xAUv;(W!%e1WDJVaO|hx(d&;R^ zf|tbP#TQ@jR-bIR5b;dW&k}&6EaBnsiv-2(f!LEO2Ljn0ebg}q`+XTW0JGqFHh6c> z`+h&Xgy?z}rzPBkQeIWGyNA)-4E z>F?fp>&>40+AI52$&=2mg~uMI3H9;EonZR|qGHqET26{Hzk3yz`&$jTu80Bef={ltXXOe(`P67c>;I-FYwWO8c%zM%f01bRd6Lt^;);#Y++u>W+ z-)IR4GQv#nyryG^pkV6}^-3A-sqee;ER>kqXDNBKer6i#>Jc zg>^Le?=>jiyL)b?1m<+bhu4S(JrE;#KKGsiqDKjn1a)-AN-p6nVJuo|mV1TAL7Ma% zcPS8^C6gd71FjUFhTTYQaTzt+1&{0FGo<{|3RuQOg;A_c9c{B*Vb=8|z$x<`Sz;`3 zf3qF8NV;&pEP%H(df4g(FF6^wq5z@KaD(xWR9W`mY>_v4<6d za39oyK@gjg#Y(^LX4M#vn&iNTBmq$hl)tAhNuSa90y@U`tR?|$5|Ru5UK^_Pca&L| z(&wai`Ie3mMJUcTl3qXXC%r+)OrR6M`&1dehql%qf_!F^^wj55K>!_62}Fb=j@!_S z_1*z9tD-;vU}tBQPV6goe!7Aw3f^lzpGeS}CK&DDzYH&BN|jDz|HtZ9weYPm==W_3@tNnUSP^Hqw75(YgG_E^VM=-&R??=$19XuxHDKaTc6e2pSn$YaCH zL1%+D*9Xe0`Si0-EIDAm0HM?GC;5Ixt$*R)#Htjt8^-k@1v6#voVMKF(c~TCF0Y4D zAX5R}KjcYM7bM}PqBcZq!VQuH#~a$rEwKj1Cvo9CFx~s;;@PIm*y}_VJW~%xs!TG$ zA2f5r`v20L$4}4yb#vL5ac<#JuiyTGW zvXRTB3A(GP?PeMdTL{0omWK37K(jKaFMFXy5+Dr+z)a(KD)`qQnn0-P;p>Sir;t_RR8z`C#wIY0A5w844hAC!g#(X@fn?%XoeH2T zd0EGiP~l1yAnApYP~c~9{Bg&dCIFQRo7_l)VZ(7B4h^!R1bB#tR9RhM{>wa)0JrJm z4z3-}NT{^NMJp9~wadStuV1S|8swqz zURYAFr-n?8UVni%M=IK<1X4| zCXKCxHqbo8hOW?&K?3Bfy3gR|AYOCGp$A)Q0*#LgF1XNG{7)z;kUa}R5r|(^^RO-y zfj&rgMiPd6NJ|e*fO!bYlDGusB6dS%9mnI2)QJK(H=HZnqrLGwD$%Xpy>mF=qK}EO z_er{}h}bOlf=eX<`;S8rz&SAw-$l7^DjIIVsAwQeau&@CML$>Cgoj{kV~0I*JzvVR zrf_?m;=sv7{2eh{WuePno^{QZ(f2Rdh)s*a%6n&CrBbN#wV=eRZD==SWcm zBQM2P0S6rk0Kpx&66pTL7hkd@U}rL32$X-79$u&FR0HBdD5G(Z?byC8z;(_JLaM2= z&N{O@RcDGp3Et6`!aNELMFL+S{h%*k0D(Y$zZ*V$=FEU2`NlXIhiV5J(mEjAV9yqmp1jtxpS@i)PMB+W!#H0H^WC)rE{cn$=#$w32v_u&h?tOO)BsF?}rbyL+}cKKyyjDk%m z@}w9Mq3FeD;PprVF4hX>v{O#C!-eXC10t4dJkGm+iE#=zAmI}rlWJo=+Xt62j|7OR zq?sU)zD`tMi^-BpHl^KP*3Hay6%CU1Ja__EN?tTAo_Sv?_{$`zuerWgl+vDbcav3A z5Eqaf3x9(eO4gP>kcrEa3KGze)Rr7(maJ-R?;q9(XA6H)`Pg!M`7u)O1{akjBhe3I zE1r)6CMH;hl9?3lZhIfrJFNqVuJr9e`(AzJRcm#{UOO}Jr=D`Ewcekxb-0{a5Pk?<$@O$KuM@*lC036l-T14rn8-ScH`w9Xh z)w@r6*2c2TQk;OHO(MY|Fp_tXBj@u$f}F)s)3HickqG+AAM}C|!cCJAcKw7;R}fa? z_sw~~9>~SL40vs?loHgg>t%e+J0#@+HAZJ5(RgEXGfY`c>j%_k^;dD+%#xRSweKbq z`2Igg2K*F&6xobR1po>^fQ&CtW+y3;uTUnMQ0Hq8=w=&O2B}G4CF>7`1v!QUB}oqE zUYJG$gqLjG=IiD8ICIVTK-l9_F=_zjx$9v(M5Hp=nc#$xb;|}mBxIqkYqf@w>ozkB z7reb^Nme;ahCLRvFB0PkdwxQwOqNAoJ@f5D=Hp({bG-4Un|hCcU;gr!I>Y;(k(nfT z3!J5g6+K3h5~N2FD`B(oJ2ok=b^2*%SRz5j|3|ejhBz3nYs31a+w4713**2fxWfft z+;J8ZyrN(cFeVblj6(v0a>+KsKbKrKRPQt9N4$o~hRVDT@@ZC|+Ivdjxgn*g()2`n z)m)~0Lb6etijmzGdhV;(U#}*qz3l7Ojj@_7krsO3y37)~rUY1X`m1IKu5QabnwThs z8`5h?iiPbagS$SY2QJ6_25IRd>|9}9eivl9P&h=yiQln5NK8LgVW53MB*yp1kG5Vu zW?2MT)cturkOr>9kJGtvUpSr}VV~{LGtB!Oq>75U&2m@iD9l5p&NVVc;Ut8YCe*~bWucbb3cswyikuy zQ^dpsN+$QK%iG>#JK)OxlVYyFK6;Pwb;$6KSEK-q zayI^pFTH4DM7)MaV*G(h#dM5v1z<^|;P>=XPupWint1Ohb_CjB^U|MxW)`Z95T&ga z+pN0bcrPXqIAFMco7RI|vzy|pfwg>wc13cSTSQiyoLrM7iEdjb0Uihq{-xbBRwUsa zn1e<3!&7;IXFw3MXT9(}vmSxGNnBZXtm{$BC`|ygMF+u-eGvCT)X(3$cb_EyIB-%? z5{OBRV?xRvWqqHZ%77#b1ZZS$jiehi^epha6@}GRZVV zLkNQec;16E@e<|rAQeo}!?+-XLC-BZCuGI7D=8j8 z|M)pflNboVkQk#bNVV=u&V8PHq9Pr~AAf?M>))JLMB8#(VhTQV3m9#NgAmcjf%Mzn z51dO!9Dalqn2uX!{!B|WHbTvoj#UfT?z|h(O`n|6G z(?9*wKUXyVN1cF*3t*SG7hI(}>W4=QF2GT*@o&NNjn2h1J_F%g+JU=4nNR@ z8*jYPkeR(t%XgjcBW#-vJ%32ka>SL8v$AQ86+&F8ImiUF=J6^w&#a?;xgwC?cYcW zY%5T%G+E~|AK*m%XJotUjeL41z9goKBFwXW#>JRFr%25^vn`DG)gC4Tsb?~CcNLYx zX*aOY=Yjk}RgBUcM74KSVls+vl&)7X6O8BpRXDKrv(I=K&CF`n)_dvQa)M+Q`dDlL z4g`AQjOs*_2}u@&6zz&gvUw9?kG|(S3U7l{n62n+mI+IQFDk(OYxX!k7eSGV*w4=H zvCZl-%V8;kzvKiQee@v*`!)&5K;K&onfoA5+x2?k1Y{06l*q1A2QvDXj1YkYL^U!- zn8?PN2!LsfM)y5;-)&VsRg#gS^f4q77@OcDKq5g8k^>ZzA2te5J)4r(VPo<>=n(i= zGc?G}@I((h*wP%(NEW}Re&wdv#Yh#Egg%t9LeK&TQ5=>KCS&Ww&t`j#0s4>-Iu{2i z$%$%R#l5kd?wGKM>Kd^4UjjXwN*1;}ij=BO)|mCOoD5RwG$_s1SnF9!jNnRM{kW+w zC27@g9*~%Rq~m%>^rBQrHEu+ywY40Y4aPUTIEOy#od6Gcm-kzn`Jlm?2c062uvXtz z{fw_|L~xt!MfM!;i;^au8dUL|7mVQY2WX%7!YBwrAPRs~`3U2<08^i&=mFC?Co5lo zBm(Zv2YTJUMLM&gl9b%r=P^G)0tXeC+Y&4{(GgC3`pa_Ue3HMQt(m;5`Czv9%`=BJt zOAyUdluXGPv$3&J=c`88UN4r)pXXhD&9%nOutX(QP#p1g{6Cr2I!`$lS6zLrSt`>u zmb7IuwO1FQ64e%Ysis?NN%t1Q{ZSu1tsMi!9y zU~A@{%_KEg4{UfZn&U};APF2QKAD*nV_01wMj^)Qh)4)TAdEc_6#D4n4|`~`b+6;s z&vj!@iVZ33=Eqc0^u-rE;~x}*Rsmdq?|tuk#&8B*c|^4XfJ=?Qr`HdO55gd}!yFu- z5mW&t`V+EcDj4(yj#b_8&wyR^;3H8ZP)7{{DjN>aE0yhx=npLiS~whTgT8=A9jL14 z|1-19-7v;-Xgg35841*U5{67)T6)Ue=&K%&!Ij&un4ohj!P>I4NXTD1_-BMBmkPhS zm4kmd5-AhY7gJ4&#nvFw$z zS0oZfQXu>kP)bOD>;Yi(wQcJ`70{bpouP`H$O>0`>(SAWYVPHX!2*gwKfYIcg}x1kY3I)j=UdmCSUG*9Iu?6Y*ye*M_b? zfzkH0B6E@QRa!z+2x{3^%Kih9jbprvG&nW`VUc^sIYT=lviT>!cfIf22Z{oU70!mS zrLl}e+l=?1RXd;ebVUk1vfPc$luDDGfaHLxfWCmbMA`DzSnf#TzQNsDAh_j{r%noVAzK zuR{C$yf#U6bkppj7Q60M;{hmC4P*_f#(uGC+xY?nZ4LG${d0T=sN_FZ^+3}kK#)rl zkV`JT#E=H|7^8o@2J`pq3#xebG7{U!13&-#vzCZ#GY|WD$+qmPVw<|s&o;9*gD--D zZfrhE*&xo&H~qO{y8hMzos9_BHI>(F3e0&T^w2Wy7V2X}t&>@p>#t3Vl}V!XQo_+r z*fpfAE91BcVS5ThuLkxS335CsASs!?9PdlRX=YRuJysIdilK1*=L_+eKRUz4ue_vJgoyh zRvgyn*Qx^e?7QFnp7AW;GQfqPj|eY-*8zIG;)=_y`oNUrKK(t2g^m3kCV0O~&Cc{joAbX<$(uTWg5PTt;dE03}g4)dn43d4tej$*q%{-Zo zmReN)jD-im|Mb((uzyFs*Bb=+N^(c2OxbI^7Cr!o2l?K!htf&7S?7EXgt%yzeG}RZ ze5TX1_kNL%-||CS4vx`RidaPcQSChTTmO0j>OOPu6{qUagUra9*5V=a(T!AE%9#SLGO z9X%|)X}u)odVvk4Z(Gc~a7XAlj_qsng$8uTcYA`uUr2G%FC;KW!cSB8J-%Nc=#hkc zS7H8JZoO3xqg~zIzq`A;U-ePtYh z#Eq}kD{M#&ucG%q{=4!1tJxlR3k*`She=)ZLz)VI(yScXV}?>e2ggce6%)2X*~ybi z=kws~*Teo58Q8i1z9GdPv~!0M^sx1n#0Nf(#PP$(AC>;cpM0ci{GBj~w9UJ7YSPYr zLphM=qK-+w-?Ruw%yqA@)-Sm50(%TRCX#L&Z<~3soTD{CCV`+`fEmz>D%3?QBeo;( z5V-7$D-0#z_e}WUL(rK-_O@VIJXlw|9yRreHpFNU(J5hz9!;EeeG6~55qj7BUJdtU zQbAL4ef?Q7&fH$N`E$E)j`bQKQm4+lQ6JuoTE$bI9A%KNApqr`i?2TzEY%b*M|+j> zKK_xy1(0|p-gd`6fHbFHD#YX2GGxSx`rkCKdBS#Y3|}nH()$}6Z{DKOpKNqPOgv^s zIQaKQX~?iJG8TQvRlJbZtlt ztl!8106qsMry9VHH!kX_OgxVD5%_=~jcKfzDW3r;-o_Ygzbm3(5C49S!iI1GICS1n z|MFk`>Mrv!=No|6aI%gk(JCN_)0ezkWrUx3`WerFHPm9wbOSIO;@)9w0)Z0;HL51= z75#YxPw0X2IT1V|fLqnHV`raPGYW;$Ae#TMwD~-Tn$_vd?k_Eu^Dj8xdT~@YK%udk z+rG`Ssp*$yj$%^x1DvWfQsuDa9rCcF72EU4Mv9St95Qx4qL}p}x z&2~GqllQ&tYo7 z^!I}TD*KH@{8DuxWY2kzfoBcYp9S6g^k!SduEC$r#bZDrw#7uD5Xg~{0Orh!D4x4# zx1U^~|KA1m(Hev*l?3vDjFL`Fn#ck6x>(vMuhv)y74uYRtS{fv8CO!6*(HUtWYb9m zx%oWj090iXD$>6d1tO_QrY04BM*2%1DAQv3d6ks*S<T?y$wY$80UX>1A=P2iD0*xS|XC0lu$yxUt?1Jg( zTBacgzFu)`!w67kXw`A6q683N9(?G*?mzzHKfHE8=V5s&69PBzJAY4-U_?ID71w4) zR3g}uXq<7z>8A65l_~dQry?*L1_AE1%H~3yd8Q>o5&^Q}+0dUJn(LMkBxM{h!n{{=ViY` z@}DYiMk{Knf4DOwzn>ei*F)n3MBwH_G(b|N6J*f~pvS*Aj2>==Dx7v462a^($bujN zmUKvGXB1omcsWeO5d0z5e)`F${oR2`3!jF40uslh1Kb;)8^_t^i69baDLkaFzWmy_ z1QZnBQE*kew+ke?{ z+Y;#*C7454aEslt5adR4Vy?}MK}!(l zz4p*g>b3n{(>ctK*R0vRt7rCps^qkfJOVJARXyf?(jmRMO-yo*(CVMxA-&6MMETzA zP%nsq5$5M$fQN8ZFbyHF(mUK~s)pN)=u};po%ARJi3o?zop;>X-E`wk7WD7G|2~!Y zz0ZCZB~!1x_Bv(!-)#35zQDtp2*5p=`q7V#A&ZZ|lj>3i^PkTQo0&ik z58x85PJlKaee|)O{K(-)yMY6gB!dKr!;yn)mm&s!es|M?Jqgp1`Z(@6uOEpy157N% z0Wj7>AL)hHHgZklzX`EdxP`T`qAH0{MhcIs98yc84W-5PV*lPM8Fv}+pjJntOZ^C> z(#fJg*~tai5us zrUrP<9rg)l5ZVCzF<~vBe%&Jke_9Fb+qd*QKqKSxm`sP)>B1-@J@k*1_d^ip^PvME z4YR9sE3K`2#y*KS$R)`;ZMeA+EH{R!iW{kXnK**=6&* zL+>O?Huk}|VWbidCW6CyV2zughe(yk7Y_&?#|AQGC*8RGP!-r%P|WfR2sp9<*vz~i zy*40x2tS{G=7U|HV^u6L^%|qk0TnHxAW_KXz4zX?>OG2#EP{NwR<4~HS`vV#H0Z(r zjxxjjYe<`b!)wIe`W<)NVO2T`fOp+>myH`y9q>9FI{c1#WRQwq{NfkgODf&Vb7?y; zii0}f#TQ>>2N!$+rULxETC;-(Xp;nhYJpj7^cQxiu+R1|&rDLn=b-|M7ONx+J>y@0 zn8%K-9;zxcbO|NB$&Yq!1vA=~aJ4&N3U@UTp{-#3TNxjmf+r^*Gh%(q!nr(Is&%l8 zJ+RbU2GUKVIxQ2>Wbe69H1$NTxi12mqD|qfAlS#Vg5=cCqHhewKOxa(OqB$fbL8`o z{POx@gzSJg>fWbP2KA$VPpcquiei%D$%3cb>!NMKe&cz@c-O~q9nnP-kARm{jdL?3 zGn7lT%}&)ZHwcAH9MA^9ufxcK$+@s*W44>N$7u^ zWXo)pLqY;T?+&7xxBZ+23PGx};BRwmAD9L>8^*{myRFiVW)@wI?A5#5`DBIXW9`Yj z?(>=eyrZPHe#W?)4&D(qWYbd-gCuZW9|f6Vk_r-mMa|zRw!@~qLJ34a{_&4(vH(TK zyYBj>{gdl~34O|`r*ya8cAJhY9cS)iPw2cc{dya9;($EDqY)VV(0}}`whBJeO5Hn* z`F`k`s=kFE0rw5<0Fn(x4;{UPI$_3-G5}WsfERs6)CyNe&RMfuu~E0O7ISz2?C{6z1$P5woC<=hZ&L zsI$uhcNzrX3^0Ym#1Nm2#K@2KR(vBCK=QIA$ELQvv(e8fcMn^4 zLC#LJ{hUlp%BA8ABiyiX1MK5D_h@s&%@*ZL1v255Q1%@8i>Ee(Qj7}8gacSt%rC?q z_-3%6jUzB4HhjXa=l#f#S%3upVFYJZiWFo_ZJS@+=E;NnJDDuc^)uwNF^XDzLD9*N zLxF4k^2=Sep)&Sog+R!wQ0X42{X$R3^J9djG^Q0XaCm<(P1v$UKLdjOdFP*}Z2nuU z`i9Am^e#pn84^%=jR@gW0f0u31mGITXZ!u{e{XJoNTI?vI9jv(TU8r?N}tz#NZSC6 z^?e?ocjauP_Q_}J1`Cwf47GGv!2IF2`}bjwQEF7ntOZIvww{Ok`QNlLf2 zim!?6R|zH6;J;ek-_;F!re?3Dt%U9>?wn1FWtS*_&b)pGdyI89%<^{`GrkITgtH=g z!rPfMm&{$x)Z#PbK>n*+PTzKczV)e%NDL(l6W*^AV6B(q!mue^Gh8679CEm^y?1)UK=0m8*#GGZ^Umx zAhS5mXYAB{d&3Z+8xsbD%r5`UTsuLPO};BN*~vzn>e*jZ9*pzl2Fb0>io+$XB!U@F za4zJ}zM$&I;SwSE+#G1A7WkoxMuVJQHl!iQLA)>XyKJneX^{4|Gg4CL6C8SY+$YBM z{^oCfXs=J;rmx3m%Bs3NEzkP8-lBM)RoB;Q0zl87%Gy)_ANiA!6k+UB!QXc4ZI&cp z^ZS#Z{7-KyY$KG~D$oOmkv~++m_dF($NrdCj=fS24AcQ5cY*|g!^?Xwc#;x*QZ)Tp z2N0Pc`!RyW-Q;n{C~5-HiOT8Whaa{B(mrcqsa*CGyJCziKawVqv82y0RoA+@Ku#gV zw1WQsGCY8~WWOw&0Fa4x&`5-;rM9Sgz%ySe;!%YzMjjA54?DR(J_Wd(-+ukc>%*-R6RrG~pVRe@^X|CB|5n zAFfF`CL;>Mc!@PFwWt2BjR=fwu=_pK2>#jGBDwTls>hMp*yr|v13}P=a2dg!vzpdU zmyJov$b3U`_MwZALZh-MUr%Zi2V-JM&fK-JnKf^(X^?!7>U0TAR}zfk7Xp#*kRUD- z`}kAGrwdvD1ameBn}Fw#0PM4#+}hYWwdgBaK-0hj^haj=q4u?-dtrop%9A?IzaxE# zjBo6*pQFSb=KJZp10ND>y%Els1T|=S```0C?iu581WghGfA98YHo;Q9LZL5UOkw@p zh>3jX_S=5q{?Tsh&BYGD4+nI2XK%j5Qv8$oBEmZ zFZ>#D?F}jw@=R3*V2WT=6cP(grfgiU0_SfjArPq&filgCkp@JAbXe)n76Uw0<+eqy z(KUMXBLOt2CkfM@L^UE|TUI!oH3yaQqq2LtINlcnZf=3>8GCsmjA37 zK;oh_^BdFYKH0u4M&HQBVc`35n}nws_LP}_==HL>Aq~B&qBy3Or&`_wPdve?j0twp z&wH{J0KCblox+KGzS+~AuhliP{V%`#k_z)YZqMT&7$rpI=W$+}oA(1gq?iA|;%baF0`FG^3)wN-3E4Bb&yzhvK zwwb9f)+v#2Zsh2#PZR9?()VFcSUXY!`yQL)cfCufXmSfu$sS_AQ!rRkvUguL2FW>w zSr@^Zpv*Y|LTT;FF6emZ4_pAw61X-WYE^C(eDLBRAX8|Zty0{)F!~-O66SdV0-)#v zBt+cwNEA#e9DamzjqcV!95zEUy(Z2UR6NZfZDV687~d_i{Yqh-Ew$H{CZ%rO(l<}& zr_Apv`m%1y5m01Fu7QlzeI!W&JObcS-{BlV$fp*nB815XpJ#4A89bVi1*Oy#7>j<7 zAUzwm3HF!-3eN%9*Ofv~gSP#U7m)z4vEuUxq7Y-ExKNEaH4j?e1RMHtY(n5>v*1iy z0Md$=xOsJn-u8clO;xph~$M#s}5B$zdK0)_4zxj>5Hjtu+A5*s%=}HTS=p@K{AFqcB zW6p-v^){U*VNGQ zFU^7)f_<5GM<*kt#k@ivL#7#u5hP+s7zvt8pRity*sA9REh%eHZO*47MSAW@J{gzt zd+c?!pa78qnfmI>uY&sKtiN9Nhsg_TFlQZ`9QZeW*549^P2Wa1QIp>bl*q>~VEfav zk1m4*de%D5bd2Be8hB{X;xLKZpgRJRhRY!Pna}-#PVT&+sF^AFQ6yn;tLH6xANhTd ztZn9{xYrC>gq+K3!C`{^&t8pOqnfH^+K#KOiW&-6-4E40DpjMcmeZmk~!bZyQ4L1$tYlbA@F$Km$_Y%21GYVJfb?E%w=T!5?cU3e4q|-GqZ_~(V)oTK^wwXv>TzK zm4e4I)}fB_$0ulF0kjTG_nsyfa0)*A^s}I&Kg~T8;@fqMdf{#D~0F3&a-g`rHOpUBg z23*ucmjf~uv~@DkRK+qq%;7p&^}3r?yR1BEC0l;{>hCr6z?zM0R0L%`67)c@A}JN8 zJQPGrtMMkR91hn(dNyyrSPNk0dS^;_F{b3#4WrGj8$@_`9MC!FU2ifp^8{$l1X=}W zDVoRLrC-l=#~*?`e}9T5(2UKp-rrXnZT?``<;((Htc7;Mi6{D&gT6D!Ajvk+Kl_U| z-p(*q+p1xEsn37_82X59SmtJOjlR{_;u3u?wNId~vWj%;2ze=TBE%-0@8J0uv z>bx{rw0~Xezeh<);N{I4y2jA~up3$kU1q9K!uc-2+=oE}Kko1Og_(MglRRd-non|? zu>hgL0f@7<1XIXgQ8dS^Q{||E52$k!^uZ6C=d#hGLRB{l*>{n-`Tn`Ljg62TL6pEt z?|c@ZEr2)86CkxP&JGHfC>QF%bF|LQ68v~Pj(m`VF3{dM z1U{;1RKk2RG|2x-SIMjcJC`>A^&q4F_P4)dy}idZXu_mtbSPS8W_M7!(ex+>3-3); z0W|o-AO29C0dKL-i^|`9_ugmkgWWIvzdP>yzE$eKR}|o0{T(O);vfk7Ty@ozMi@Ly z@A0EbCcIy#26pZGvWs@UH>x-g?F5pFht)>_1SYNpyZ}zt1mNbIZ?Vt$unG))_`yek zAStFNK=s3QQQ2|WO0%C>{3)x(1?pbQk%N{SA8Kl0Oii7Ps;bnxzV^C>6J|||y;-eE zg+$qn9XY$Pc#qV)(39(tiaqd%+jqftz$ zpeRiP$$o2#7Z(c%0Q(j=-9zg6^_WnO?pM|?kKMKFa|^7|oz%t^b^UWL0k6}h_#zYq z>>Yov{Ch!&WnzHhhxQ}HTu=wIBM#gH(1sIFJjv$#Km6cBLkFMNmKyJUzD^GvjME`| zXGBcPwNv=v{=M?bOST6uQwO*}z*?Ucm&Wz@TA?3EljlZA$IdGFrkvkZ<2x%PBuM*E`k4D1) zK>On6W(h_=CLBL{4P7lDjPh45>#fagtOX$qf)Fs1+0V{qS!blC@JdY7K;IEHWFoR5c9xC;kpJUYKMht{1g5XE6Gbg*xKexa&59FVg zMC&~C0%7tZ4@{6o;N#*;F794_^<{(H;S2B>GWoek!yrg9f)bzoAl>j@-~Tkz_cx1P z#CWVq*_$H+J@7l4sKEUH&42vHX81{9zO7Y0L7W%7_ny1E zU;O;%21SB!(AEGYpq;>V{Pd?kH7ir@G2iP+dN2X?Q|a*gU#S2QbIouocz+JbYp%W4 z2#)WoP!f-cQyyy}S%gc$dig}9QE4Ywf|vLKj01TtC*7JUFDvtJXUbA~hqRsVSUtOd$&pDXUt#>SRpDUrH4Pl7a@k z4CdgmlkP$VPLSSafwIeri~cp|`8|y2S#-}EIVjHk;Sh<1dlZQ@@~a#A=aK2pS~^By zc`UF$(4hRGB4d_(hXi0Ad_!nWaxT!Gq!k7dV6D=%^*+G@=Ep;T_XeJ492sPR)+duP zpbAXYFcQdfKmp(yMQQDwiV|>dBwORdz2&_)uh18HU&(~LKga>eH~s^?da84i>rA@# zJe!QNs}`AeoIFeg_-Z?@K@4o#_^tr!jdStQY^h816kM%PaX~qtpLVb8-?nq7i{d;i zNDKb6Qqw;fO=#NwO34q*nT#&A984?G7Sz$=0;ZqD+_N;`5KQA(9{qgN$u3rsR0w_W z2YkSs8$ksWAbL%ALEG9D(AHc79V3Lm?=w9ln4d?TF81JE=NhVG;-xJeEYg3`T{L0H-&?lPvxY{i1cwt)mLa&L+05vfx z1K@d>^vouc?D&4&=4AY`x#x91I>r6FR)g^0C=!5a5N(D3@jw3G?%w}UUG#6d6hE8XR)EpX@ezi&_^ZIa(9iU9jR>XLJid|!W$m=WSbkNoM8o)2Q8 zKYDLzGw_*S(!+~3Lkc;f89=hZp~ijl#!%ZwQph!WP*ZiTTyHB7kEE03O-s7{3;vU1 zNh?rzrKY!EOa_ffefhh%Neg7D)NkElo@g`|Q%2ItY&U+=d4>stWBOMBPOnI*Th3mK`Sy7$6ufjbmli*<}} z5G_66ea+BD0X zW|iHl3Ru`RAz)Kwfkt`oy_+VLyRTUSZ%XI7uJ^=d%Jg zA_w(Y$LJ)jz>5A~dWAi6mhzg^u*$zOZ}?!`kqmiCB1=HE%ySt*V?)3J01}G?oOz)E zA2&U&V23D?4^ke`Iq<*tn*X{#C`xcplB|?bs zTrbewyK4MFGJ(Jcu-&sN;YqMH0?wL0RGMN};a8q*ylHSa4B_i^o}Uq+|EX0w#v-6A zVu`|LQ7xs|xa;S6qxS%Z#MxS3B+^HKd1Nb z^?r^HRk`7uYR4m(#__(>aA6J72Qv;s`WWD7iXKC{2pqF`h0c{x$Kcd&o;MTkGY4mKo{ z_u8{(Z(&&61EDw*9~TmU&umkDF_^^^KIATt4nY^Fz)|Kp4isQEv*$)Pui4tHX^L#*yUh)|HY7j^ z=4=##HUT)Qd)2B!pgA_+8Cs(4p&93#%5bmt9=4zeVt#c#%TJ)E$0h2iZfCY!5Uxmn|1ULP5{vTvl?uF@{^yK?mxl! zs;jT+{{A2S-t0)9c>Ia(zyJ6D$HxCaMc_Bwq1phn3wU1yhA<66OB^X<+9)Ish!D|Q zz?PWVWquG!V$|{?O%V8DTHuY>-w5N9+l+{GjEXApJ;0w}5@EN^OuNiVRSuNq_!8B> zbO-Krw<2NYrVK{+kEOL7p;Gqet>l0a1KER&y&0 z1nNLy{k1-xvjlDtoE|TDv{6-RUFLgpE813f`w{$C-utVJv5)%ceG`b zpXebEln>9&CZ0YM%p-gX zVJzo&4Yiof++c0GfaJFnYq&Y0SCH6bTFYN5*0bYLyD`i+NoFeEchu_~xfbRU*|Ci~ zw3~`2G>RlC&4y*-o~Bh$MWQ_=`bkTZL`wI7Qu-}UKzFug>2JTJYLu_N`r2TGqic+H zM9a~S^)^~75{+**jJEbh){TgskxL(FPQ zp|AC4e^ofE+<;cm91AN+kbu|+D#z}tq6oh2OeI7?ESRUPBl+}S2akJxwBCtAK-=OU$B{CvhiMvJYyy1+^vXCL?I-U z`xuuasd?z(hpfWh7X+3{jfBz(E_L;a{l2r#JX`zyx0}5$_Z-6F&z1N`f;+n0e zjO|w^Vr~ZC*-^Kv>}{ zjM^zJYwQV0;P89`{9Z!<-Zlbhe4OWr?C9s}OXIHcTsP6MeH*>;PlcS&zqTK_U8@t> zf`*9q64_xSI`$A;8;XVvgVbl4Z1~|C+&R|>)To3q3d{MD-fQ*&Gx?wj5FJs@1fAR# zbTKRe(D#G%$KKng?*|%4akY^YS8!1eH#Bo}= zqcn}qmPZ4&;@l^)7!4RD-bwRaT5P;Vu3K8?)jR>ZvDSqPlLLi?eZ2*dKq`ICkp3nS z0%~|&j7as3H|*iF-l5lb`**)<^Vfe=&HuaazQ?M4 zdYeoQ@CR8`diEp%k3asH9lQt?;VO92i6>ht;HUrgZ+6H~sZ%}Oe9O&dIP{hhj?f1{ zJAg!pB;bMzE;6rkco#69UwrW;Gb=h;69700hpOhl$H5ViYxPy_Y-!lA?9Gh~XGFT? zI%dnNm&uCor_>L%u0j`{()(yM=X&=17%&@PPFW`SCzW4|j3^Zo;7UR;`BvFUFs^AJ z!-1|*NC0`q?}0Od{S=iWr-+o1Aje+Ww!WU}3)H@2YdeOdVm_}RD3LIzL6< z{Gc3&9sm;t>&fSGVUpp`3jLFS^IjLL&g8ZeFst@Y)%PmH7ug5@+&K93@mF1 z)+Q1r$%3>tQY+bZ6&au&Et1mqS1Ls@75!YKe5ZQaA&u_7_#9%EBlo?&f0m#~#YG`U z^s^5z7!V}04kL!5bj{)3mMKFQ6sJ(80v=-wkTyUs&+C%oBDt4r7W(zan8yCmw%|by zcIl=vqZxy^T(w7Q-r)LA1bsjOkj$iCJ{R8}h;Cn!L5#`k1d}P$|EsY+uAZAn*|s@4x>(BgX9x6A|TzS4%A`9uORW+)vC{DY4FyUVSi7U_M%f1!dAEQJQNe>cob zMP7>wJ$lG>AAInhodxj+1war+*2R2tvo8cj+^EoYgZ$?O!wxoK9V5rZwqQkGZ5HnR zMo_iP-HCiD$NnLi03|#q2w%YiFmh`cGn|LS<8Vy?AQ_W9gKU~U%I@7gzbT4~5&W6R zA?TARpw;1#0ttL36(2g(%bt^@Q>fr12oC^-&fxfCuNPaAw9`OQF2CYRdmei^Plgk_ z?S}a=4i7`KVZ_GMJNR)MrmH%80?ghXXaBZx4Fp zPe1*PRl->NUaousvT)e&BqUt?5o(shXQIV{5?GiSonVak_aZoxgdiKr!T7cc7IBTd zJ_bCrJ-F{NxmayYp2_AJz^ha0x{7aFlleq=yma=Y@ z$=?mZ!H?q2Z9l%CqBkV;QEd;xC=PZc-K-_n^SnG4qFr$36+fwl5$j?Um9vQbv?o;I zabHJNkI_6d_6g|6Xy884jDI@wBAA8IS5qR?^^F@KqCBi`#_Oo!Ns9f5-G;p;sFpeY zJ@brx23l*@??2?=OoEqv2bbU#9ff6o0@oXtDkOxS@$W2wpvWLm=e%VUna7cQu%{V; zCV75Z|M2lHRly%7lSm+Ths49BDY1*7lAG8+vc)D@dmm)OYb_LKRi01FU2=)FFH>!^ zq5tLT*XydPo_S0jvn`9?TQpY`eP(jdUH6>+c{@n}M*RnueuzZL&y6_(KK+n-^ClKB zuJZbBDHdgLo7xc^-{@hzr0^~QfK7UZW}xWHvjJ?}#?3M@-w474Ob)5H-+tSIAOVfn2L9vuRKO5`a1Dg;1~M`&zbUD(MDO;;2IqbN zpA$UE>QNGdDcxHr*BGvu zdg@7Q<(z){8TS5>ztEC>O*}OY$Y{~>GySU>oz#`l>P^*!W98UL26r8@Z?xE_%F-+&s7kYkqIt0 zhN~sCZW+1nAgSwIQk(~`U%$rZ-neSk2W;tLF2vne8G8}{hFYH0XT6gp00|Xwvf9t9 zrM@T9Vm`RaopCKaXV(%)34J#HlMMv4`x>fn8knzBI2kbup8(7UR(yo8??Q3~1RqFA&feFA?`PN2WTsOG(QKjqV z-zW6y!B+Q|t@6`rIQ9t(fp+4W_4|)8+!9p4;-9)rw|%HFhe0{Jw>W8`k(}B6v~h)#bTN3p;DugLu&NZ*Iu>X zaY#a#jN?(v{L|8)=MQ<1L!A}{hcllS$dt+meF&~|@7}$YK%YC0EoK4Nc^z9nh}>$< zgpui6$T_D;fHdfW<1$4SA`dcvykq6W1U{5vv}=i@ZscjuN({1IXyxgdQP|vjgRnjy zd+br`V}GScQ+!?42!#QOHt@EQ@+2i>hNM35URXmP==!3a$T|i|!z(1V!UhDu6ZgIf z3BcDO0XRuDMmpmfg=wC!zku{fa9`Ky1jx#mgW-?CInIDx^vXj9H3|dLE;! zNLqRX$;M+5@;o4~w{$k1@tt*D@aV~N@JC`@$rLde;cb3y70FDpeqMtBPPWg6A@e2J z_2@yogwT_V`T-E}_$NIzbpF{yuCI7!V^ba%6!-`RtQ8#~&<^9r?@;>r?>B zg|P4WoYty{10<5mCYMa|D6!0U!oA~KX;a`KKw<%Eu}6Ogq)#Od+Q5CKy@O0M622UE za3BcKAE^v8%zo|#r~;#dR57%GkU*ui19LjMMe_HIdGTIAt|qJ!)&S5y70r`RJZWtt z)&PIMJJ_@4>Sf((n*D!)dE(U(h1zU@OoRNgRRbQWq$zFj9=DQ3Ya0P^mEhjK{JbyB zUPBY&L2c)eSf<|tOn6)>;R+=@>>-g4_sLMbxhLmEmrxP_SoENaw)bX90uYJ>5&!}_ zNwy&XC!X9-<;0|&q#UT9{Q&ZXff;BYNeTbtGI%XslO&fm9zAy61Tx+44zjuJ#bT4)p&>?!;@7{A5g^eWZm@b}2|BZ+Ak zI$##pvf3V8^>I}z>ZHOKSrIv_?UzIhsViC&%k~sx(5M-W%eWzZaQXB=30^u;bW{_7 z4PXLo0k{+a81k%QRzFW!k_4gT1w_fR_c65xD~<-OVA*pfUE{Iym#Nm5X6%UjiWEXflM8MAkcc? zy_kK4UCzcelc2~bFCF^AwG)Jxv1M}snXv)k5ip(;r88%-o^q)qv9D;ETDb4NdyTww z?KRhWvkeXY#~pjT8Q$Ej^YP39U#3o6JIwds8%rqMqE51;dgNk|{lR8i>a0ES>8IYt zm>|yQ03uA;%w^sy>3@ZM3|KyDIFLvf!l3t(UMNar7hQC*{hsfK9z9HDk|s!fsFd+t zLEa}^A{ht2VApyNO!sGR&?EKIUg>1(g+}sEHP}ph)jfiskfCT8~d){ zKf8w1@!S^>tSP>GPj-F60(km^k{w1ihXyg5u_dwpALszVmIV1jZR^nzz?=s{9^=7A zuuD`kdl4>e8JHNA25p}JoRoP0M1+k5fC`)h04@yZA<0EVD>s5@n{t3c&t3 zRo64<83#3cjbxk_C7J&DLAE#Aq(`KHYv7FhKt};d3=Ti+2;2WptMC{+ z9z?lFlHzqwQNk8F=asF~1l<_3(r$PHcu*2cK*mQ zYiI>XAGsqhJNgC01sJKAx(9p$AhYN6F+Ks1@-ju1;$zejfCZ$%+Ff()6v$=0JsUR* zbjA-gi+)^t!$5GC>xKmujzpG{N!s`6_20?V3FI&9;m<#>yz)we{0N*>gKM+dXv0Up zYet30-U&h(|n4$tTJ1Q$zCT2*$)jv>*oX-tmTdT}Qilpm|*)Z8c@T)y9blpP)q zJ@lX*474Dqb~)4-Q3Rexf|uSLP(Bqj)j}uID_vSZABu{eUMTm83X0D_0)a~*$-`mhao*YAk!+Ae zKunBQ&gJ*e3R`18h^;C1E!Q4!lNw-E=JTq?L|c^HmHmlTsxWa44tq2wCc5m7+) zF?^bP@4d$erjaxp?>~ZFmt1n01w}7q>qc!Iu?e6ZwqN1PeDsk|NU_zmb>&=;CwIMa zUu5KS=5U4(a6z^}z)Y+xZ~*)WpbIjN6d2dirI7T1< z$nhGtfHP&yC+ieTcpfl4gYqGqn)8&jNU;!Gb|ea%7pQQ4sEG>6v#A2=>yWe{JABEd zm)pME8@NH^btfy?<~C^_OSXspxBg7F2L|0cDO!mo@YaI^H7EA^r*_j^fs{|)X9ku4 zkTB}sJ*#>^@2ve_|MkEAbC)ahxiP{ZrKE6<&*#LYMDwZT1CoyYe?sp6?znP2y27yU=?S-YOizh*3XdRb)Gm#RPyVmLOI6Nzq8ae7PUK3`PL zc}Qzi|Fj>xpZIPs4ct1%!{`pMG{Kb$7&xCGd$Cr(z=?eVP}wky1d%*|TmTKyLx(6w zRUXMe^y&zZPbjoa(7ZsanSH@D>HlNxP1r1}itO*RZ)Oct6=M|?^E}V3 z?}xCpzeM-9>F3{*+J6-l1rcNvgchn81*AX_P-d!fxbem>_KtJ*K4+hEZvyF)R*`k{ z-ZSkT5i3@#5NrpmQaWLfo`f}d6=Ud;AF)i-f6TknxB~j<=M(4-`+=y4S zweUL?`{8%4)F}@((wXUwlC|K>IH_(`bG*92gf@{Bx?d!hoO#)vg-0FniYkF)d~uT$g^F2ZK! zh#G3oUWF|uOUnCB+1+m`so1I!(9&&*`kEf2Glo)4Gi3a{d|)Ajp)1fZrC%Vtn4ec)XrzPwL*v7`i#m|5 zLk$c@mdYZA)lUE*2{8An?b{6M3i;(rSDxP`NbouIe}UrZKhyW)=h1CzxZyaP7Ge%P zF#;Bw2-PaH^AP%+1XDw_{Ovd{>a=QK1_Z<@d?V|L}*W zj6VPZA!&4o014sE-Ske-4Gl;SBAsaj(6}#bs^E? zJV6lNO4w|G2KLgi_;2y6HMoO0( zXIp+#%IR~<27eg=LzhlHwoIm~5fQ^+2iPL1su>czngzkU|G|rbzZ3Uz@fxaF_6z#h zpmI z=av9yB1)oeYhxp-{529u%)Fk2n*dD9f-idUJm*YAMbh9bvApAlac(awAr^$qtb7xp z&)a*k6|rJOJ4MblVV%6DsDhi5vDaLB>E%{TSn@Q|&mQPajFM+Z z+Bu(tj{nvOfS;Eehm5o;hSLyZ#$YLVlx%|1qh#oH*Ie3njB}iGR-C36TL21A&4Z}9 zjJ5#&IUxWQNzafGcX9&nfCC`amVxBd=^m=lY8D5{rSnGWl}_3)%K8FrvjC{{fP?p> zph1S}nYmYJMPO4{E-K~)C6paB_?b!(XpMflvxA##hTh+Uh0X6$HKLOZ0>EqNC-XC} zmOEHn23%zpE@M1Y>%fl?;aKSK07wdUCD9LmfF+xuB+ydL(r1K7h#3%Y<272b5a8qN zzqGKRxqd0pDUSP`U=>39zI`>8Fl>4F2FR3!5gqkMuA;{^;+R1)Hd+&+&|yC#aUh}v z!73||0MG=EkIe;B5fYa_z50s5@w8>=Pp&D;{}~lJ;y!b2*ktL`f}AkNjane=WbPmL zf@grr3Nj(%gH%p%1-N#{9(SyL7N5hlCjo&Iz=@U zLxI9nQ7jUkv3>9ivjsoiMyx~ANfFa70j(fW;GE#q*u+k{Hdd^JusGf81^=y*|H2e{ zu;sO~ED3PA_mD{y&OG85dfwGJ70C(jS?tWF>vC7!mOK{*Jl@h9B!kc-8b58DX|l-& z9+)!)@cn=O{=d|X!a8SxkeAUp-7^$Z^P%7arLUnTb`LQuB6Tw@4QXRMjRndhha>#P z2pEe8p$w{VRNq*TUXkV_a6Gj9E|qvFJ*G<&uPElI1Tw zxH*(0KuO|WO|YaJ^6tI&+^tpe%dI|#-#<7r7wf1f74gwWA5-{S4CIy31<{rqihE3^ z5U6P%fojoW80;U2kZp?=z`*bLiIA9*utM(bY)z5+u%eeD=W8O{~ zaDL8xLM@YhZIA8;B+sk|P&(%fuzygfux~x32quX*#Se)K$Q##?_9>eMGTr80s(cA} zR(vjF(@fyFH_NO#_LuelLx(7YK_^>2jgjS+jk}ipx0>!ozLrCk<0K=xG1GZ;XeOE2 zp7n$mFu<54KUB^*Be)q5uVeAy49QuJ+vcd2UTyB_&C$EJ#H797ugY*4JEfjU_Q8FLT+f zZ~;0nB~B(p0vl++Ny?%>O2P_FT(qTYKy?MyX@|4#5!u9vc?KNH!f53E8j++-GRz!|Na~ zksLrqI$Dz)t``-_J8#Ff2j}YUjkE*UWT}W=eBnhKgW6}w&|Yhe0I!lDeRBASjZLx% zVqXdp0HYcMCnh0$jOtT;4SJ)plz?Wl)TmsjmAlKisw^eqGbDG4EXVVc&pxizMK{fv1hpivbOD{Xm1=)Id+O z-59qqiQvB(B?8KaNlTz=7yi`qlomS`Ca(p;<~T0US$hxwaaMA!-_Wr!t@VMc9zhNv zlZPIB$Y5(Aa~3cGi;BRZ!iOJz$O1TSgeZAA@u^7TWL!VZZekKW;ZG0+HdU@WEe0x^ zgGs7fnp#?5fcNk$15hT^t!6J5fDHJXd}F!$pR!xl7*v^ z!IHDYLkNqN_qIYXDwk6k(kjyxlds=0^S?q9l9Ij|29JU?6S)6)k{~?-;T8O1Kdfnx z$x;PQHDSS&PL66V!7u2mSm&NQE}Gjmc@w=<`Z!jG zn!8eInd-JS7af7VGssAZWDt>-1Yjh<^>x<)IMi|gs+)9WA4rnE!%;w~X(N>i`Jp_y zf~y#&mw8D}Xo)*PA+Kt+% z3lX@|xCr^hN;jWgJ_!KhY*^+#rJ_dsH-t8ze?EhaW1j{^5)}(_x-l@$!*K)o@kY*Y zUNlpZ!;u^}NC5K0Y!Zw@QXx>0Gp9@iPm6>AZta69r@fSMZl>yl52MWh$ALYzndcMtpJbcw^V${y3Ptw;UI?ufzGxpX{>4Zl_Zcm8 zMyntNS`w(oO%E7u14$t4Ya4}B^noL_Ns7W3eR*y1y7J!ezsfO+5+@7GX#8(kLA}&# zsEY@6PT}PqxqR?%wnRc4=_`jN*yEw(v6#HJKI1^>_vzRl9FMdKwggWPytrZBV9IuT zEx6SH&R7_%*w08Rte^kax9vIM*`yVhb8Yf#VhDhuPPEVXUDhY41J$~X4JHi}cc>&O z29Bo+hlocoHUt29?2QfqfCE5s!Ta;fBK6Druph9N*k>F8V9oM7po0W;NU{8k_hny* z34BZdKnqAZqWkHqmb@@o;Oi+$9hW4;&om1Axxo0JZfs9J)!1s74*q$`D?LV-bHt>O z*dN3~cjygyn3YmAP^1{zFFF;W_vZYE7DuK7pm*(n7MmRjunviI9PXUe&>A~b2)xqA z5BtivJ;)*yTU{@OFXl;GG0>K%Lf`}Th3v&cZrc7P>$;;IX9<1($RzJebq5UWs(>)X zPtqVl&?I=!lgpC_M-X^V0x1iG4TZijK_6)BRJGHjN_E({&-TnW6PFTQ(d_+*22d(j zDhX8g*ia6uu0X-SXdkq>1zC;-c#sVO@ef`MG0u!TG>HKPp}GLJJESd!`|rC?lZ+jT zG+bm0*OkQjqkDNJn{UPLRY3K^7$%>^Mt1PPK^r|}(?A&vB{6py8&E(hK`ftviAF*j zRvED&(YGW(zoa}5HgO--+h>A7e>E7B>WF0M66Fg3+ap|rx*JK)j-&_(kAUL>9(`Ja zJ)cW*K$S$I!Ry)NKuACoxVFH>j-*(xbuIC)mbe~@)`U6u0#a47`^3=ohkf2N_1kSbLzM*{g2VLWEVpuPIK8ODacjP*40|Lo2 zE8h)Lm+pezZwD4zlxiTrq)~3Vd#=rRyy+w`x|z+w2*J{K|Wsdg|#$ZZ%{MF|jn-Yon05hX1OZ8P*5^ z9}dhJrjEJMCIF`2fo(dYh>8|45x_dS;DU?noZ^H;m<+TsI>R46qrlduRM4y@3(LJ% z%idpvD9EA)ReZF%{iBUH|R{X@(MTl26fP4sN0jfBmPz)O;W$2e^a`u9{p za4CF@iFYV82rPx|v0hud&o}|WdvhI0s6aIsg`!`N856HzvqHg)Lyi-Vd3pfkYOC^En#bUu~AMhzTHBKOCaB5J@hMW58J z>qfoB@mZ=Ispy_JO+CAo+LTNq3v~n1ux-8VBNd3YnQp`NceOqzs*c{}sc@KJ`PXog z>7&cX11;ET63-gO)C$obNS}Gq=Fgl-XY1TNgj1?p)_$yR`?74E96A3$()+q5*7V9r zGTDnj2iO;+M@HnYAbbSVXOuJuQIN#k{49E+TDh(RYxo00qh7VOR=GTFiilf0nNIrJ2|KcVL*TU4MS zKcBV&_fQ}5bv_U(>z0&B0k2yNz|4RSt$hFc-~ZPM9Im~y(hS52LOK2?RkOn!#V%hT z7N~|-dJ7quk3JXuErKusfU!3oaw@wEFN!KyQJiOGQ2lcpRM{*PelO~6AGTgPL6iXG zbFTR=5V9N1@9s=;yPyax2zrPv&+FBHBv+K1T}X@D1ZuR8&BpME>h8a*%3r4TbwafD z4WYl#;teGMVqqev6YXuEvqZ$Db_vRC3c!R^5d<{8pVwdpDGvU?KHc=Y4b)Fn!{!gE3vniTsJoQmO-~oe zRtSWg5ABK7?W;xK`14Q>Bn*G(q~ zM1erD!U&>~2K5;wQO%KvR!}FcgAG#-eBbystSgAEkp#F=Bmg{+LHE8Z8Uf z0w_aF05)6nf@{OR3F?T30q7^Mm0aC3$(NH5Q{b>RKobxnVt*-AS3~&pYI$2;K4A-Q zy`=NzwA^9kLC{$`XMue2&dTEZXySkw)z(a5DrT2M~$lk#y)FUh419W2V;S+C_scmrj?VvHz3@-aY0hC2a);KKa(jp{78D33x25>(f0gSToJ|qPMR^`jv2-~bSi!Ndd(e(O1Mz8-}Ylm1r z5(d0(RP^Ru%!h9RvdJLR3)difw)XTGrL%J#kW9k(;>vXj^c+Y82+~YnJZCfZFPSDf z6&0Hp)ys;jmW|qJmKMd%;o!JIH9&PpR7f-k>I7zbsTL8~doRz`<`ynOBAy=M~L?raDTva5QlQJ*c~^ zmB`*K`F6aesNQ||-D~I|YkVLJ-p>CYWp1JuUwo-0^fDM*>psTa@(8vNQF#st{>;Zm zDenl&=UcQV02~u-E4V9s{^csC%lPY>N${4}#}i>A0cxOTe`1Y02@sTFV`IGyz=irt}TWzI=d*c(6#ATmXjF|59xfJDetxWLvw=gCc7RzZ3t9#jULNK38RVF_<^@i*m zLo+5Po1vy%OPpvQst(2Zd$B~fZR-+%IYK~&0LbE8Ub5=n15j1`y>0;@nN)83o#%h%-Rr(mPKlAoBi@ z-l$dysAS2!j-=pplLTFjP-1@^G1EPAbv!l%s(wZipM2s;(j41x`JWxf^F5vk9_UF385umj|m1Bk}_y*{CacxPiXwT46 zfg|D;&~+X3p^;UUQ9L>PCkqKk(#S%!y`_e(#`pzcLef{7;UI4q>xIf9dfygU+vDtO zPwK7pWFxu2LQt9%{Sj-UK8yz8hSEpZQ?&~3kTY61ZI#T(5UsAnOLU4 z$1{VpE=ekK!B;BRgeu*WlYDH@*B9>xYQRV=g^uX}q!xgGR!jh5Z$G~FH6=H{W6v?Y zeG+kA8&yVE+JMd4g&0fGbjImt8oI#y_)c%K<*IzjGY?Uj#Ga&sWSpd!eUtZ`kBt3p zt)iC6@w>i~-c;ouKJCOn2uq?CVavh43)HO?a24gwRCdBRWhgY2VT0s6UydEO=6sSN z0MQHxag2ld$Y=uaz3=^N*ZQcjg6e!+X)2rn8S4;+g4k2&3`kF4tdS7<`$?#qumI;1 zq}lx0$Qd1@7soBa(0+7}_6{rfFgp{xfiMxbFX99Dai81*@ipdFJ$ z+#^AaWaocW-|lCXfP=FgtpNUwpO^jb;jRxWeUBN@oMH=&zBPh7agKdQ0k!4W3g>TG zy%7-iTmKz>{14RY8x(>C>VaxKRSRVEyeHW`a3KK^LZ3U5`3T{%<}_4Wv$RohOzIkC z>2^GeGy=x@o3acuxhB*j3;OZ3x%n+Xp0Y?;uh-WOVIU7UIQUR~kFeP5M<(#V-g!oW zu*e;O01G*k${)qfBYLmnlu0&9l^rX@QS|K@!DTJdS}VCX(w?u4-RYi#MnAz5WQz$D zo`+avmkG3c0`SYMGg$Ym2@r&~Q zxKbc)B*whQlvcv$M&8R5QDy1zQ5k6$$Py!^QkOOMtWd^Rp-dzW-mdRi{uS7I5&WeS z@y*Bd`|;E@_j;L9{ys8~52CCjJ79(X4{DN`w*ac@-jXu?5Gv4x4735tGtSO06v&BG zeNBtA3RR^#$u^Z30MX}X;ZqSF(7ACkboyB^`U$M`%cA;S*(xz*J$)^UkWslfR`wUG z9M4ID8#Y(Jo*DG?@}PODJXk378~Jdb%(xsIz-v0Y%Yp{7W|79(|CmUy$dLwSV|?k4aRXRMQw+zZ zilijENzdD9n2q`dOWOg#5UwGc)L+ed`H&qyNdN*oHug7<#!KCXoLgdbIkRupUTJfl z+%GmYdihjjoGYL4;fL?r#(nB(r`mPnTG9?6slzmhO&WBDYw_aqFPMBVUJ8u)Q7th; zZLJXv>XroR_Xmjq&1P##ikT(=NCfxHWLY;i2c7|$`Nx)9p)!&bO-h#3;PwiN9%`dW zL+0T)kFk+~!exKndtpmI$f&E|P*<$JQ%f%On>JORsW%kOBlwfhFY=3H}5})) zT#n_iCY&~LP}sP~&!@0qEh5^(^UX7Oj*aVS@~fZI3h^W#5Z1RR07!O6dGzy57TZU7 zeQYqQkw&?2k|fYO)(5{6#gU}t7H}*iE;dT6?@!C{TuUrYY+DO`gk>mTwzc42Q~3*F zN;*O&)cnF?;i`V{m!4`xm`c2*JICeiG}=12HSXy#U_Dur0wI@DVz?&Z+9He*ztwvxk(5KQMIjCz00bC<1F+n_ zllDaen!d?S)(d1|6C?2Y3WHf~2!0pS$B3&=A)A(+pAgF`zJZ30gKtl&sv(ywUI!gRzj zNk_z_gLQ4qv#~*r>wQHPK?`XYu=aRg3M|%R=P$Cg>i+XK<(XpOZz=fKy*9_EP$m{Y zX&ElXe-~qb(jNn@w8Mi`E&I9&KJP8~#6#<~8*EN5om$iZ!0%?RZh&bDU{sb^B=i#e zAsa`ej1N-KxthL5Kvt!MK~i6DmsdR$Bu@ZzvCyZ*JDWLM2zWSGA$Nvx z)>YvIeu!my^*5i}x^fnm)1LR@3U*BH!m=_MhZQ4!kYRJ7(Grd0z$ihnkDyJL${u*aYA{ru??tG_a9UipM2CoLaMj4Bpm+~H_@It@zG)S)2T1q2&&j1t zn*F~5B}uJjeN&cJNv+%NSQkdv)xZf~2&9i>mgy0QsHFi@nVc}D%fG?V!S{pT=lQ3V zMbci39A@O;XvXuNJ+p>P>a$J)0E#tF2|!#g-F*MMn$Ut+@p{%5>Y^Z78*OVN*tx*$ z=XXes`9}AjF;_S?oTDLhSps0X0g6oiS-CPC-?_=H(>VZ(1N(@%+Y-|(A=GiDRqaD+`iEYgRLN8NKCvaZBE#PJ0t_&;Ag3=Dm)9j|As@?JIxKKZW9SJY z+v&l+Bqc=~PFe^o#ut>;OQ0ZdQ5CZBZ%-MqNLj_(r6!=R#QvMTmu@s@(gP0?WH1+D zB#_TALSdB<6)!T{_|~)6_hPYuM&OV~w$4H(J4X$ytx&RKanl}tLV%@ufJ*=be%9G% zMRT0>Zo^>H;haGNSo9=GkbIazpi-vN<=;&(C~Y($O!;@0CkAsThw;$ftU9vUMZN#sZ>`ZJS8If>ufW)u9XJqs|esnUG|=JPFp>xNG| zf_ZGv01^lKsEkb6G=pAek^&b!LT`c3V&pSgv923Cn|sB3?MX;LZa+KS;BFQ$iDfH; z&PxaXtxqd12>wg!^N($k@~Pw$9R0JPtby(WUGM05u=ZIy(nMI?mF)@+fa&K)Gb;M< zkn!N$n0QrBY`w|If81-F^qBwWb6AgXNOrqlL^IsxtUqt6gJP3Pf6@Yl1f6G<;>0)$ z#v|#T@p-ZpXU#xzrL_ry#hN@zlXIJf*-Z2LZ3|##3D0|~@$q`H!bYFq0`(^#P(#L@ zCvJlmO?)1SGH3>$1!_rh!J6^F?nMiGXtV==#R?UWMK}|-FE%o%cQR}l%9e*rm*(ef z`Pr8xk7Ggsa7h#|cj)X}r%P|XzQ%i<=G})v3t&)!cT@o<0aDs22@noIM*^(o7cMQ- z=Lm#S+aVOoCXCvIhNh=>)C}4OGA*qK`4LFS@*MkFJ$drPly ztDg0D>BRxr(-UL7$NIpq`L!yH*2C+q5?t+Jpv`csx!Rpz1SFCG7C4!5=k7J#%WTp} zT^xPeWyChd3t5ae-f*J@x(_s{g`NkuWW91WF%~?P>+c_b%#s<736?xrzR47)wuOm) zDCEgV4}?d89f=Ek2vidHD@%Ts*ut|k6Futs)gNI>=t>-oS~H;)=ALo#JNV2&tEM@Y z76~v1#%5{zPwSB;f}r)_^*l(PPE&Fqq9$z0W-qKr3g^VNW7C44fDjO5Ku{78kP9!q z$R-6)i=%=hVW53P(5E`$XCzO+o>XmY;Lqv%Vj{%v5fs?S&2%O-3pE1jE@okpyjN4* zlcmS~WzdocqpD~lcD!Lq?sUnP!6ZR3OelZXT>!mTPmp?ECxGs``)&(T5ZY!YLiM2~ zo9^b=XNi;eUTdSG7eyj#n#L(~JDQIEVy>7bw)2LO=a`=1cX^Hv+qBPYAgfF;-V%&+8{MAc(-AdBFAXWf)6l zKcEHRZP0ll_whxtK&z2YC-~!oz}}Igg-wz_e=j8?6btNYaBY_N_6g&`|8r3K+lq39 zjLgN#l0O^s+zLw&Y-KPY+>P^%j_-D8lH~0HX>$oL&q75Dpo{!zlw4M$6fl2R9RQUA z{-=NX-oFmSh|1P~O|+}U^>boT$K{f#(joK+!lA|nmk~3L+JmvNzL~1^ZMI<(fXQ~T z%H=^t)s0GgFuQFV$);5iEM$7ZEO4q-0u22@-s7X}j^W?4e4di3lMPyn(;@JLf}7 zgCGlBNn$|&b2898wJ->nuzLxlcq_c26$Iyp%OD#LWX60#V5}D)-6%*~rSY)w{v;wy zAn@6YByyb(Y9Qq`padq&l!S}&&1Z71R$>wZWJ#sP9KSjKoe5o(eLdgU)}WcE}gg0B}V}G)S889l+Z4ZADGkFsULzJU}6+ z(28^LXGLTrkqr}m_9GkW0_7%=@p&APB_UK9X~2gq_UM%Y@ zKFLNq4HC*!j^NCiWnIIfXI&kmgwzcaa*=^P^kUj|j=K(r(s`3rYwND}f z5T_EPZ2>R=1aieUpG%7a^**#jT7|!aN^ov(D*8pj!1My5VyF2!gmW~P;+o+f0HO%8 zcB;PaWGxVAz4%@-wm0VqeQvG0wctPUY=#jbxv9tLGY0>r8Bf~7&^f3u^~$7rn;P&P zrc`+KI@L49I^N|J;1bEhd2DJfBVwCUaRy*VK_S z$g*#|5%u#ir-B6Nu6?!j!7&)QP=EK?XAjzQi0BlFgNt~@38gHrn@7VTCWBz}!)f_2 zwGHUgli1prUKY@2DFGn);hKOnfJz}yv>$^r{6w3Hs^UN`tIx zoQX^@iT7t;;Q8ly=e$oo`P5bskYZyIdbbQKcG=)RN%&hRq_r6QpBxvIF`*n;JiQ{N zoL=Y~#chT0D4nTrX7-l{{-SzLlL0yg*a`t1oQc{2fA4$$^sj>{fYScg15+4qep-`9W9nzV4=F+d!aT_*$D)82wt|zDv z`_>z+o;LvicKoqA8U%^Pxd0n!-o5E_BBg2JUmGXV1x0XU0h5KZcr9aJDMtdOZ+#+N zWlQx*V0>N2+#Y=J0jpdu(w-jL22q<}R;}(F>N{R_?<4d0FP1>Ouhj%%Lu`Ho%fp9{ zB!ZvkiL;0V;B=@zr#kq9UI&6E*17~$VAXE!cRXVqK-+19&0va>yh{*sOHt#`F82uqmwrAm!)Y}JrV6NKESC4@&5=MRw#K|U0 zAb($viR+Jf3oQW%nFJM{FE&-sktihfhV^&(Ogt9&nNMAG#LJ0#c&UxAGPW0{#a2f7 z&|wO7m!@X&E>i{ogZW`;rq*#7AybVifF=oV)6CF&3gH!o2pQ66<~yyj6&!%gb#wq4)d8>splt!v>kJvS>kWP9jzYG$*D$YX7PmLC zHf<&oGn7)-2yk&9;HrZM4_MZV*$eQN!(?6NoRRqw(-&quKPLFUto<&65OO_l5S+4k=>IsBqDcF3=dj7JUn&DK^A`ATiwnhkaMmiGRJbRGQ;JA>? z5%767ShL&49b(9qjjQmh=QDD*vgO;u&#(`klNzeoyruTN;b=O(8>;3b(#c=$A%j!}s zn`4-6QoP5d+H<6mFm=FOEo`KMj=^cx66xR-Fy6yx8zY8nTpOF~QQy0#4dBEbj)f!w z2S7w_ki;bkAt7R+b1hsyeb$adMwT#13-k0OAb0=jSFxR;8z1a_{@(BLH4BEeyEerv z1?o+^)>IPbD?yLD2@>$QZtQF*XPp3qKzqM=R?D#Fn4E;UXy!O%V}eeS;)@NDYsWbN zrE=~*GR65sfuR#E*mKTp;MaTSp7O7n;naqa2YEktDdai>?ecq(VEC54-!aW8%0FQu zgXhu7pd|t-%R!iZy6^(aUHJ?|LYad5WjGHMT7p*v8m#CG$W|)<^lz5fE18#MmMZkF zpa0y*yVkPNiuWBA*s=L|eI4VhjF(#Py`3&;+R7LIiz^frL;-Nclswnk=4yWu*#kTQ zke1-1k34LN@(nlKU=TRZjjf*%jgM%awPM_nCv@BtMCsaVud{W<`i+6Jr#fgYx)n=) z;p2b+&nf5gUAds4okvK zu|xsj$dxBwJl}jC7|#A#n;@ zQua1Ats(H(0=B?3uA%Kn1qlb|^0lvh&4Lfd^VCyM+Xk{zAwHYg^%19@#b#8fi=@C? zZn;_E(<99FT}j&C(OfCm>*w=`qL4^=G6fF-jf5Zm=!ebqDw|4g z+}O{Mt&$kXq=ax!*W1W}>+7wN5J7e&aT}=aIM+Mx{HeM9!DYY|kZVF;KA-8H>^Wv6 zL|#YhgwJBS!zLe?+E57pMK%7Rs^ePoC)a^Zz9n#RA9VbWne2I*pdg9kK7Xu4K>`s; z0sqbOW3$_e9?>^N|KLKc!q5#c^he29Hj*Wg=oP}JQ2~-l)?9IOlx9?4AZ(%3k!AOU zPl-Xu=+IzSg5`w&fh2>?Ocxg~{Y zX4Y317b8lEM_=2(xoPv1Jj5h<#S$jg@?1KuQ$L{dcSjBk$vd*va-t^rF3Sf0v0y$b z0l3(PIA0gCtS7(%11HVtah0D1KhMqL0yA{@$;TiFN)Ak+J2qvc^HV}}q! z_RVGi3!X*c^W(KUOcKEd9;5v*7CmERY&Nj_O@3CtJ8k{h0KRvR0fHH?MYYZowHSf%`^S{Cy~Rgn|$#@WAhVvBD+95ctqJu&H09lxVO zgJ?v{f43$6NJXBrf3vyGSsj|3TA zI5?Vko~zF{=n!Lv(kchmxhy+@3$0`b@3*3jB^x?C}fpX2+Tk_+iW6D)mkhcJ@(rR<*t%r`wddZU=% zU`={~LWK15IF``!Y4^u~1r8L?hC&rW9}^o?MvkbYfdNTKKnlXx$`@(LhD~n<)}cl= zTLL}bBm)Fi{?4?(Bac38Vm+rQZ-6R_YZ4vN)_PJ?`8eH>?cP>`=5xLyiu@dVj3ra_ zKY@kuk05cQub+QWJrcNG`@hv7&~Fle!$+crf+Y<$gZ{Ch3P!@zb`l9391}h(Zkk6d zQP^w=RJCE;`s{r(GN(WF`mve%G5Eb&6$NRkCWK)cyr>_NREc*IolESxkl=E!nPkDN ziw~mx#dYMGvNm@c5pBhYuC(rq?3eCK&K+U&?Lsw%y8ikbj2XXfMmCa?u`&g6^kI9d z^q)~a5n2QAfgtsw|4Nyf15BSlHogA( z8%^!96ZA^IibEkB09pXLMo0k63<#eC-6Qty+t+#=%w}!|w37C>4~^$TVE{4#VcH6p zQUkupKz@747fOvvrWQprlzS-Cch!`_l_y zsXqVMd4zI&(S-pgJcNdG15(P;CRTPRX7;5iga*tI2s>a!uz7k9niOzK0E)Yk3-`-N zLQrk`3{lA3=P>k|h+#Tc+}d1mF6={v2WgAU9rrgja~1-Nk1CuB0br$^z`cyXnIri$N4;X3|4g+ zEV>?BAo{t^u%G8pS*Kz>N)+UJ5CjMeY&pb2w7dS&ZR$?n{4>ebnbEL#Ztdi9# ze>ZN%dc5~mmAxDF+4<7~12ArcK&(BpJ?Ypxij`|k%d*2KNW8g5ALpJq-H%kYcx|>XSsv?hnsU8f*3Y~s3}RcoCcf5P5` zFwKkSwIBkUvgwAkr$|1SxxeYAo1=HYCx;6ziRl0&1mGPF{E#94s%wAm%`m;kT z-~H})|D|Vm81lVa`ZmBK6_VuuzwQBlEkG7}DtMu)_*0Vum&Gb$t-GEsJQS@6QZK&n zqHVxjba((%5OFScXx-^wYz|TI9S!@8k=ubEO zd>+by(b{;Y1sJ|dp2kXgex$0+?^7ZE^?^D(!fzm~x0U?&lA2l3wmi<%FVC}rXDu0`OP4_rqXb4B-q<01iY$4pmRj0oa( zJ6~EEJi{QP>;ohi-6X|w+WrH7-!Ve!6rpwUXXEYbW~;$pYI-eUvaL0xH89+tO50zr zlv_UyDMsjvA~n>cgQV`-nYHhA58u{$0Vw_BwUzHG2cT+tC{-oq$%=m{q8&o;cdFC+5g$KWjd{RW%wY&RMk9n{tUE`xjqa3>XyllCBKdId8!_UnW#$@mV& z_|^msM1*UBq9A|goTzv}fj|rlJurvHdGY2>{K^&7^6}FnW+nTU2?v=%p(d!@OEXT= z<7X2l01&tg9hysT?62-lIvxBMj7}|Ss?t;mz%o*rg{8%gyhI-c5Kl=q zVeLc0ef;sqjkUhtBCIYgS`!hrxdo{kZ@kI+=-iL>)WWkCV5r&G z&aA3N;uk#(l3boG2!uS3=6X4g*1t017Y7fB&!+uHqD&Ac0RTR4TZn_vNU>nfdlSTI z0lfXrTg@4okaFx!yq#s$_B>4tk*pv${Hz*QeWYzZ#%v+bx`U;AndElBAL%m)tDm4w zl693K04Eyfr9VBvHML0?YiE6E_W(gGe$P>t&cb$UpzgxgZ`n$7xrciz!GF0ygfR_- zoVYpUFiEHEQ;z-RV}Q~*2$wP((%E}wi(Q5XRC)m*1Qcd50qD>9=NUujwRA$x?g~v$ z7cYd0b6lv~Ew*wpEk9rxC$O5L*g)sRWObu0EejWmSNdFR=JfL62Y9e(q!|wJO4Blm zBOCu?>*HYnbGpf3o?_Lazb1@$PW$f@q>&1ydLbEMu9yHrCGOdEZ(3t8lMUiP(1p3a zn&4584JvuY`XKt5OIR`qscXu|fW(5P9l@;elfQq$EL5rBseC1E$bF((NM=JYhF8rz zWPco`(hZH@$L2Iz3ZGEuTbIFqf^FWj7j_7jV_#o)=>Vmul^@Xa}WBK(y`U@j*e13 zscpLl9{8;Vqi_EGKNy;a%O7KStecx|zQvN)-`;Z=w0Kkc+LQo^_1ieL>dNlfZ#FMzZ?tt~eK9Q+;Dy_th0spn>*b;tXV9OvR5f)BV9s}OokxNK}sV50KTOkM)2c1R;6nLfft8q z*aXP9fgM?-WY8$a(W4_7A)pbsSa4pE^x?3O!HV9mB&+B5=@FuZ4oQcqp7&ZUl)1{w zm1p(5$8q}oLD~d-?wtVZng`-+{hr=}36<>FZU_mLS!BL%XrJ_tw&Ag1(zm3S&#?eo zdR;K^Qy7^b@aKJ=s%(%8D&+0v+_*DYC-1go0FD4XT8^3fg#|H)hzavR_G%&5#V}(| zOe85_!{@zVmy@{RB1jT=?s?}LBOF4SRh8>&rK4P?Ko?Pfl@^)u^Cn0|&4ITJMM0Ql zZX!avEMY|cd6pFHtj$5-k$G}q%X@U??0Qqt4d|-^OV!zTSc?T&Zb~sUg%`-=MmbMH zHUwk%FuU}+GJ^SpCUqo}B-~WJ*I$3VwYl!Q?>E+mzwn|94T7g)edXm>?0wwV!Nr0+ zf%AKTp2gKyU89Wn3oMbCr}0?l@f1eJ;{E$oyXUXyz5xL-!sA!knu%>4>9|*t*Y8at zv_?J%XbXZO{oVkwX2mq)A$qZ;JMF;ZPyEFkfoDhKe z{QELNJl{L(lDeTc-YK@T z3PaNuJ*v?l>y6hV+m#LDiYu?Qs=)8YD3G?|Mu?pvb{; zFU+Y=gLo87>PBJ)h8($-Mmv5M6UjI}_=!CiK+;rJ^groC6BI~*NCe!+z;!0*<@&Se z-}xlMN+NHP1W>^eFsNefGqtK>h8zTh*O7RVjJSHCC5)al7EBw-0@Kt;R8u9th0j6= zT<{_O8MHaJ*|l1+b41banF9x{s^@ypX2SuKqj})|`;GX@JqO0;y4?4h-)P0V-82>$ zeRIW5;B#66n;HKC^oHN#Igg2;jEL6cYb{o?K=2d}B$St_E(nQs+dc?kcxBUb#q&lo zN>WNC&+~yTH|OArrByL9p4U_mJgfR8E+R%Sx9OMAjl#AFkf9u__FP1*kZ0`pAheQ% zlcXExAZdN%3BdblPa|<)`UM1FZ$bd__9oYrYX|E1ra6YbZO26dV8q13heyV9!`8+C zm(>wy77^!U)RS=9n?si4_RGYP$P|+%vjooNiANqt$z}Q7lC_w53Vn&~&?769WAaUE z0jLzPdS@MIECKi@LjdvtfY32CWFYS?rD9+oWa;pRVSvwOwA!h{uxJ*zn`8*134!!F zah{{NNetOM8>^YT=)z>KodxB6+q#=oY6Yyus*QyS^u>EHN(aQo=5K@}^B9+bz`EZr z7CZ~|j58wy`NR`X+J?_(V=hCBz=Eoc2Cft?PkrK=omX1GK;JwU923EVK#cV+L8D?d zlrrfI8V{HN?X?YxjT1^>)X-h30+^vh@kNorXI94 z@Vwp&$XOpLLc{Y5zXGPVKaVaV60Ko3+w*enfU(SFUbH1F8EJpkYgt5H^8TFCsHJbH z{%l<1-{fnPqT}|q-`|*W;xB&wGYdq(+}Eqh9&**_GW_CaKetu@?Uyfn>5G==p%20P zVq61?2Xa7yMkPwZ4ydkwJetsXg$E-Q#|KEE1Eo(i{%O}uU ziz>MXma3sEwhl?4a1aFrJWH=32%L>08v-ydApIseTtYGgX+Rm&3L{;whf@onof=4X z>RemYO?gI-*R^E{fTD%omMHWTw$#Fx1+9Z66amMGgrka^vNZ|r%IAavS_{*5Lxr5V z?U`NWPjkNCO3pO`LXJ^~ObSji5R^nl9wfk&0L*5p1QqmwIlzm*X=V?Dm9Gb8NmimQmtN64rLR#ZxPe1*Xk={TCs0e5W5SY9tS1CUY z+U7k-80a1H9&A=HatS(on|j>g$e^!frJISsN1&w|CS!hCAxz!}B!U3u1|1;T_FjE{fnVCR5i40cs&8tmy9I?B847Ake*LhN^9rIG|Cj(NV4=- zAPtg)n4h|Cxcl~|zABLe>HM)9E!GyuJ2NgY)e-+$Hq zD}*rS%2AfH9(N*i(JCN7M9#|&W5aLD(1K;Yc|G?fG_zLLdJzFPOpN0;ZGtgRRpd%k z)6~zQYG*VSjSf6FKGJ}P);+wE68b`oT^n{K|@g4{2E`3uukz~PhY2z<>w zMc;#oAGfSVhMUn;0@{`O&Asta(@^YOp^yUS>!?(Lv5@w>Go&zA+U$S2|63dzJZS6e zf(ulEQl*2pCs)yYuHO>?k`(&?Z)yvQ^>f)}m)moD`0(Mv+-D^z5OQ3Y2fb;20LPix z(4I;Huxus2oa5heK$JAi=QPr~yfHv1t#FFqJW~6&`$?;e(t8l%-XkS-uL(sZKNQ;< zwxL1>|7J`+)*&1K2rNnhY+hRx8J7*KPC-bUdl(5o1_Z>xjm$qF^xw+=T_!BZLf!VRu!kIPCJ$9o;}+~i7B1imdcT86?vY{ieIa~0C)ZJ zE^{I~_uO-ATrA4_jz2g)SI#IA0q9%b`j&0-$OJ$5(1WV0eX7E5SJ}Urg{hAQ%Cs#i z9|?N!?QehEpzg=i|9~HQF!JC(l&g%EqecYMs*{l=s#geoY+g@2{fFkqKmM_u`!&~G zTa~-)0=JGAZkri2PtfO@U5??2!nlk;J@Uw-&CgZ4osIpLTW>K)vSuK%%%;(oS>=oC z<+iV9skso}2EXdq<tiiq-6?G{cqSLL{!H_Rg&tR>h|xx`Auu-Q29mz z;QjU*So2@};+ITJi?#9>4gRNSASGEP(EU*Z-W6)548ZGi9~kD{RWnLrtZRH!E*<^dU6hL8^8i-B?0!VqlO;gHnc* zgne#6SpEs|>xAkWi|A&pBe|neKL30`4(c8-Pyzz$SMT_0gd+9# zANcJ9W-f!IEJ1~edea3rdJKB&C+i}nk|6Q8{q`@I!XU1NKha=IwSCo9#~1Ky$(zhX zozgX+3MDXHqDDi^nUk46^@pdLAMN^K^Z$J3|202wcmlL8sP38&iXN!LksJDVswjw3 zQ9LM^_Q8Mt=YLqB`s!D|W~~A?Y%?D*o$>P0J7>ZORJxhilz}O!h2}*K#=wog`qkZP z()5_ZvR50dOBJz^VB5GscWslUHWG=7aZUi^9#H)N%ahD-ECgcb4m_Z(f%WxYTb~mH zBE)~Fd`#XsISBgrSd)T%alr>}qS5lzs$A(3Gq)p-gv{ftUy7tMEs~40dgp#{zo_s& z{OAMwZ;}DN`8j-pjg5^qf#r+f_;Io`25li(M2~{;K{zRU6!|$uc^JEFnqbwgJ;4yP z!&u_T+P0j5GAZENgV4|-fq9P{Y>W2DlFVo66!fCptX_kp>_|%r=$L-`D;ih{aI7Ur zl!$9xuKjg5F^{O*-mmZZwIKqa0k__In;i$q0{!$GlotTk#nYi4K^(K&MzOiAu+7HV>__PlVNy~5}I=Q7BH ziIilN#edt#GXiH}Ry=e4WPqn<)>irRh!kbhC}BZcJ$Fr%3fn$shA-Pgs6+YVEnNxS zXHB&qN^ibOCETAB2qWfJ1FOi25pE09ODGk+Iw*hOs1=&B9uKWfPbVmqCFK2cm`Am% zW_3|kHj>xnHB&wVSVu|9&WQAIlTmjOsZ|xrriXUz;szdF{DM`0IC&k=>VbYY6|+Og z1m_J6-Ya2sAW*}b8HB705lHcNHTq#gB6#DJ$KnAUIJ|kdgJJT8mV1(G#mE@uMHgSx zeEsWRH>EHVl5;g6;aG@S4Wu4})iR=o^vPO@5X4Vn5^#xbhJWAu;0Hfw?z#J(=0x4d zU8Z*>L4J~rF8TYgS+JNfu3@91_isc${n>Bsy;u9QKer0zDrHhfenOO5?Ux}gzT&L^ zXr8j&1YEFReM;2_@4D+3I<@d(tG5qHX-}$Ld2LIWlf^Wj{3&z@)QN)p7JdI~RXl~N z2H9eI>Wq@TpjG3sT8&3b)lCa{eD?MPfg22-I32Nq&NsS{RbZWVgFk0}T?@06*tfA~ zgyauMc_pDQhqZ0=(MJ*Pr#1E3q1Wy2A6SC$fdw;DM@;YI(!(S(5I^}Y8RXkdEQj$$ zP%m0v2vMP$2w%ZR9rMcHP%cee={FMqV!wQsJqrWmZNaFatN3|RL1F{9gmd9JM+bvx zhx1i<>NZV)3D8{7*AKmBa?i+I)8@GP>T7jve8b-7u3!Az`r3@ClIZ?L+akDh^4}!5 zDCLo)a;}hldG1)x7hfC+YWA##X%}r{5@nSmEbUYGmHBCZ|m=}{*JaukfU18PrErv7g!vnbhXbop|iuDg_>M>jt2OI z4Q;?3JBJCwbkAP0Vx=q#`ol!ui_uHclm|I{!?cvFoBMz5eoG?)HlZp>QrcNZS-9Q+#VwTeO zr~KJqqF}s-Bq6(lVX^*0K83=fWKkeqfpQ+f`k*EO^yq*d&(KYI-TQzu8MQ0snleg+ zsu+FBUup3F&2R3tik`*3vXUV8j3m`EJtg(;H)B8{@bEr2YajF%zxag(Z8q!geCOL* zJ@2w0x>|AA>vUlv;fVic6cOe=V^gq>fA+JV8DxrX_?N%(6@!aE)?gw{r*BZ+>CkRc z{E1l7SHJpIJD=aHPQbtY+rJqp3RgnlLaIeK^9dDPEo9?^bAuRnSW95fp8wA#1t1>% z>}Pk{294|D)z@5Yz4K^Zq!R?|>s?QAf-ERGRNW)f=DG?bZl)ox*Y}2ZZ?uGvYWv}b z9=0IMGzX+k=9+mO@G%LYeXq|Gi+Y|bS9C&VQjyCEQphs5dIN=(H^-&Z39HB1Ccjso z2bAsLl0cRu@be5n0%TOvk}7=e^>{x_34$3Zt?{}~tYReq(6{!UJ9FeDS`l{6bp`x; zmSFAJ!Z|_7i%dB1SyY{!yPgcE4Lkt2n`9G%Kcq8=bf859Zdw>r=p;W>?5sB`e_9tL zCP4l7sM;UC_XOQL?)aLu>Np?R{s;omz9aZQtDFE3JVyJtUp}ACpKvU=X1jK68b<(B zfpa*V#G?lKZBoMSu`RR_WxMtJB;~Szh?#H?qrOs;Tb5kO%_bY7X8xa=H#G@xlq2#F z)(bs{VvH;i?)c$5y3R-jxt<_L+l+zl1isEz*7xf;g1T-z5!@+RYld3r_2$^)=Z5Nd zohOuh#?I|K5}{WL!df=2HtO5B6qCObs~LgOWz^rIZdonM1?wIyFI4phWYmyr+~T(e zP1i2VaFghd+lD5TieZ@f^4JyLj(ar0YTW+gmFZX2=i1nYmYaS^m(L!YXG zKG&&X37cUQ!dYvBZQWj1OdFq#fQA31FMY}0`$uZs`@2F=KlNc(@N|Y_WM-T5dHXd5|4R;zl#bfRp#zv!105$Pz{j?Jfdbq zC>auHAY1)S+XIX+V)k>wz7ved$UE2^|N44WTDY`*?Ol_!xw%y_6+KWe$x=wLDYkR`B1SkO(kgz`VO%<24JIpj4*&j+hWV_oPj*>C*vi za+anVX0i1ZNr2nTSAxJy(vZW61(cXJ7H@UL<6ku{1PX_Y+QM}KQKIcb0>kTQAzh|D zecbT)9YkwLz@Jd2Jfo}xaP;nPyZv?}a{l;#{>K>qB-W_lvHlPOA{gKHg)i7~qZG*V ziR>+-pM0Hh##xrgK_1*pUJY)+U3W{2z3gbUyKcdk=n)?06D`(#ISAGLM4J{njZ0dPBNGupXvL4rs&5bYGD6}k?C;qJxGPw_+AJ<$HN48GY zWMJFsY8T`ye&Y*Cx;?96Ham0p|gR zN8NKkl7B!kS1wXD;^_1RO7^*pX0?+I8&SN*i-DGSHM2I!ZeyW$8W){;kYrqh?q~DV z;&AjL*WAjjoM#CIuttU^1}m#kw8-{?xfHX{c@H@z8Jp2VA*uM`EJs_7H9>U`41bHR zHK248aspYQeSx*KCV}^B;tYIG0#4!zq<_Tb{O{Fv%>#DbzxtgT*@`KTT z!9`IUpjFXpN)l8^L^rOzYwCZ&7|QQyE8v$ZOM1UC-M{dKFPd5$ucgAYpjy$Y+DJ0Q zY{2Jfp!tV?{70+qP&|t^yC>OPKXbO`ZN53T&AHk~OX!4s>!wc?_xJzskG8?15kRYg z9x&BANC%n-EPVGn?_ao$WTnbE0tw%)6&Kg#F&#~!j}9b#tp;m?C{qLOf){#0x4)5W znpN_uRpTHf$0RjI5)&##WMJ=A-U07?2K*_^3@L10XLN3RWS4<|lSQA3nG`IP;ZFrIMS3C#{gDpnFi zyO|7PHa0g0BYu51uaZaziLuF00V&`^V_u_8zY-yP3lwP*p@o#wb$7XD-%Hx}?2S}~ z&}3?4jLSGGwPCaJoIlTzi}rBO;<(_<#`suEe^mnGpI%d|Y}KD9vF4pYB#@*faUw`V zu*KC8Ee75Xk}53!M|Cpfx#ym-WPoJwW|j731P!ttt)a&rf6NkU1bj$*9#BmQ66CA( z`6Vem^y(q&v)e=oLSIUnBLSwxaK)8Z*g7^XjL;0ty0TuY>FDeE?byD}#%R&)-=R=A zBvsxc`a_h8e|^C(N#sA0ClX1J#f>B&#_^f>PAkw<3nm%duJ@1k_}ky!G4UaIJpBZq zgm@#$$iMS@pjl`mz~KlPN;a7EN(h}z8z^$(x(s~>#TsW|o=lW#dJ2PjxFEnug%c;a zU~F|i51Ex*A+{5S%=s{>FgnH|kO}O0h-|bh_7Pp0G0Y)rtK`j)5XK1a6Baq4r|_1e z3X;3}_WHA-qVly`l~tOQMa(XU3#r6!d|Lb(>UlORF9L(k$zfnrU>4 zZBmS?y`me;5B~eVO-}cQr*5#h?Hws|23l<;H$B@1Wjr4GT{;*V~j>Nc8}8j~pr}1HtKBh0JGE!)yZ5)W>d850U%* ze(KM&vD(}@Nr1lJop=7kHh3l>T-ww7pZ&S!)l_|doQ!JNSV>R_e2@Ym2Lki5fe@tG zq*p5yMAOSWW6xF$u4Y7vpTPluoXKb$$q!QuXb~_rM*_ubsfJvc&`D+v-Hxh#$q=(# z2`lZU!+S^5llVI)Yqd$TLy!XndR+q>a5m#*Fz?|F7#sX=XBynf=Dcf9^dPXQ2t#&e zZ6Jma)>KETI_7xDZ9GF=wl_Y$v;)ldaL|h!t8^A>saN3D92xLwn0cCjnIDb`0{>3S zfe9AcC5Xs;pwApU2BsV^?tw!>;>xI{Ap?5-u#tv$L*>sj0N3qyO_o8JNOs?R^UoIO z5&nUbafeQxum%uyB1z}(a05sbA>X3P2WNs=cAiB9Y_JO^K|tGsfM^MG%?~0)p?N}B z2!O~jAAI^i92Zu?E0Zk#j24J$MRhciV`pZ@L!z>M+YTeRqEXNnf*K1KaY`tfYe8Bi zDMmxWryFXX6ZXt8>ZcC}+z$!}#zYZC+DL*yL*tojI!f0sXdlnDO)AXFDS-Z0OmsI> zVmd3XOQ;BV^@Y5oKXT`<2{JH{%DGN``d3K`mu6s&Q~Y8!=-e$~C(O&{yc(3%6-9K#;rXrkjkE_Om;G z*8EaQd<4wD1BzEjvlR9Tg?63v9yW)(K{~$ut#4c3l-X(sW*`ByF8Dk)8)M|Jt`BF{?J)~y{WEItlaUb>@No}Vg0t6i@W0Dbi z+XN)4SdPOtYEYLb1hmi2Wml4+=00tvjnlawju1HCM0#`gtR3GN!^o_%RE!Vi>$^!Dk7$7LxjoSW#6YzWW2KfE&AGd2u@P}h? z;6PO3L{$@r9Qj-BABumB6uRQ0=d_{1ZIaBSJm#~^!Z{ely-sfd5$^Ocqp5R2Q&t+`>a2c10)W# zKX#Zg*0@=bwTtGPcHTS>6g{~}Rb7%cYF@a2m+O$UfxO=Wt5)4oHwemz(xX*%b-uqp zQX$iUWMbxD7TIWrN^7rDDECdV>RiC!i(GHhTyiWw2IpC56W28RpPnI~=Z`IWM*^gC z>lsi0lGQ-t)+&-?84||;>(zoRt-U`rEz(AQgaVdj3N9jY$Pzk*6(uPjo#P=tW_KKU zgCNI-1~iG_2UQ@wM~BDVR<`f<#k*oe!|SeB(jL&?Pk!aCX9C>q)P$ z@cx7ftgc1^Ac8&^LXj}ik9O?EELmQyG zK5-YMa!9}M0m`CW=soK>z!+L`W+DVhCm{@Uzi#S=Ympea9*L1a%A6mSF!zaPihE)Z z_p!ST5rWmvvq{x$bNKr2tOf2B{e2{_4{BmekDg=~4+W}zl18{Ch6-qZ{||q7(%J@~ z0;lMB;NzNLKBOX9oHHaq81+mAfXYB5#i|)$AQCgge(TJ8)A8-*F%mZwsAf#1pfAT+vGEuP z@@9rt`MANVSF=)QL4>|UPcu2QS!5%U!}obBK!)Z@RajpdSt^@smgRqDVl5H++TGlt zBqKI(L~p1z+3>%hGsEnfGt0LeRHE`22zW3f=TOSklF9F0daT!;d zav4(oo$8zl=!;5-BG|Ao-uvrcH>Yb=?0q`V(pOeylh?s}hkSca^S}P{Kh;L}u{dt0 zn+2ph0=6^t`{qF{%$Sl2H?(2X=lTH8LfHDR|N1ZEQ+)f|-!aFwoQ&2~OhR%fbETbe z4sooFK0gXM+hPcZNIp=hR4GW77lOv2@}p zxq=))#S=tFlK7LLNA<`3CqbYM;~ORS4M-ba2niJ5+>6-O*lyn>1R;cS;kJ)#&*Neu zNuNt8n6<@=U9obkcrH&UjwnN^q@W zVzb?np|zDYODG`H!Rn{lhiFSOYnIshT+`>LMF2}51OZJ3kSUU8l3zGA5C+kVcv#T@ z*3LEB_lJ=8k|wXP^GWNwu#+>ja zz>UKQdtp#@h0xcVX!QW|eJqsRft(B|U7#IK2c%af&VWP?KqVCmJ!*uEH5qd`L&<3U zp?_JWRNQJ+0W-hj1m9O}O%usMI(A8=;Fr-G9kSr>7t%>^1e)t~17X7<$S~JWz_s8# z@OE#K+EFN62X!m7P3@b1|Mw;$f+N>2RW2Aflpgo)WUuQow!Q7SHE01T;FzvR%4i%T zRr^nW`V*_hZdPa%;+oqGyLWjrA{Fdoi|`USzHj{9-`V>dcIhS5q|+qw;LMJJAheR^>j@Sh zLGB8O#WK||_YNpJx)MftS72JIW83r%35bF%mS9FC&;(gX2;ddih@Do}+grpSn!d#A zI4ia?hoiaXZi&lvg3Dsi?Ru>GaNqMUS{m!g<01Y|4Eo$#5DO~P9SIWWSw^k$Ser55 zWdWS$hx22MlhHSH2hhN$Wq?qYYZ9DoYwUVMk8r62;eiYA;6o2+g7BIl3187f95Wy) ze|P|(4mW9=fRR{ae`y8q`4D5_ZXg59IfCL{sYwOTOE)qahK&8*{^i6=?j0!8?j+I# zvH%l5Y9!Qf-)w3o-AC58RquLzNl1>n(9z<9^wDLK z-ATAejP)*?#sUeR11v~1`KgGg45Gow6)DKgO;NBf37TJgx8kKj;4_flHOOCKNyexc z6J(>p2OQ?Tdm6OkX_bn@9RjIXAaDKSKYq*f-0!>ZUak6`)#~?s+gyNcIZg{&X%b>u zFT5#8mq${MpD!i`-~iBO02z7F^cSAeq~kfOda3@n#soZqGeNZ_v)P|3RljkW7}?4E zwTowoAnEMzJZ(NM>GX%z6Ck!$sEB#4;1R?b@3%~Kk6;{`_1i3vdXluKB^Jikz;+v- z+Z`o+)7*U|=!I^cHzmOBBuGMd2%-drIPbk_RU%cNRqv*Gzhyp z1400t|Bb))b+$YJ;&sw_gdmRH*=uH z207eM*3>)tT)1G8Cp1NMExB$$LcS>K`M39epB=K4qfY^;}t~xmbt9fCd;LO?L#rJFMS4=lyeBqO6(I9 z%GttX1Dt16((7CF;DpHwT(b=p8FQpgm3-9ahv|OONviR$DtA^Du}ueEgTjoY|Mhb~S6 z3Zy`ej|%H44S0#eX(YZF520v0Sk3S0u*!gPb<3cfB*0QXixWl&BYBv7iRn&-k0QPSlZ!M7QS@J z&8qd$HIdMBHtl{T15#|CofuGS>5*=@=HI;SV7x5v{rTqebg!kauv95B` zZ@K>PEK&DR4aOu(=nnAnqf?6sK{F7AkZ}My&e4h`CnQu6m5fT6u?-cWZci}ttLNfc zjg1HIv|=O_Ny)^V!`P%sWm;=|tghA~UZy1=6&d1*ro%vMM5Z|bOGwk_(+HX7;1Ce4 zG70;ql)>&5bR)6^!$5hsT7S)(5ifvI0mWuGuP;4=g1iZ`RnCU=M1aE50)x$R4wO=m zq$JJsQ>id+$Gkh63uAaB0#rgk+BOG0lQe_gX~23`W|Z)~f^}N^m5}mx3w)o08?rE2tZ4qyKAmo2~={J6eRoe9o+>xUDZyfb+ufSRzm{`%MVC>n6k zHZU0d2)w}j-kq$4H&buD<}z68-$#>|kJ=e_&YMk^ertqZ|7-!|t#{tGo-P&9Cm(-g z755uzUrOKrVL+b0pqCxZg5sYmJ64Nv!j_fqB0D*f7Y4$eC7BV`K2zIU@8ogQ!%RY8 zpLK~oR>SmMJ__D*kXl0w@Ph3?@Wk7dSC7%>UgV8Kt$$M z&mD0aCntLU*%2^r8%QB?G;cqfWQ62}fK1>(sgA%;_57Aj2k?Ah{scDw7@zw_Kqa_S zo%7ti@Z$3Z`xE?+B>u^lbX=AM8 zmF}e2%<9h-;U~k)?NdN(dU(9j6^C_I5hmak%#r?@HgIl_r0jEp})H$ zc>vh?R`lh~+d$X$2dQn~#zCvC@K#wwNgj9(E-tnI8*}E$p3IEz&{sv2Esg|^%%P99G$i(QJ!`^2>AJppju3y}x z)#DxwZdcfbWTrx4mhllX5BXFLn9jcjCz$t$Y}|j}{Z{pWGGKrLK`0X4fFftr=hKWy zfLk^2^LqNDY~Ca#j8nnTCaB>=nD_O@_yGBYfZL72RK7#?{`o}6hCjRWr?zQfRZOB{ z!6(6i^4TIeO=l)By$8kq;^3#fM`J~j@}V95-b_9$8+8a zopf`uxd}y>AcVyy!Ye1q&?q1O^7qzLZ`Rv$mZ9;M5$|>svedqCTVhvEvTRBvq~Lx7 zx#C}{_)W%`(XUoT(3#}(LK%<*A|!WAf)KDi{Fp;p@sm_>Jx|nLH*Sg~eJ?4Le@F>> z^yne_#cO5WY9ho4;LW$(VpAVX0U%Tbf8YW&3Bo1w7?og#@JX8| z8dOEgZFC1f<1W`^fEE*?H)f)wKOFr)r08NZ%_jzhdV{za36PoW?3if>U$h zTq6P~Qwg*ZK$=chbjK9|$4zv)J&N|rhaVocX&Uw%kgL7>_72AJO7D(PtV!+%sGYfB zYGQ29bcHvNyTq}7u$OJ5F;dzuu@Cld_ z@~ybU*U{f4V!e!$1!Zw@+Ds41$-?(&(K<)~df_nX3A3SnZyXKGErVq+I^7qkD<^}- zbqqXi`@H^YRSZ!&fr5b}SfT$@541I;U@$$q@vLj0|iwQ)fXEf3zN(qR`k!c!h!5_ z%Wb#n-1Hv%yJgfqQq#-QS;TDCz{eV-@sCBIo4qS0fH_ zo@;BH2IH}TZ`<}wtEeAQBH!=ye1V#;x#n7fWobkBB*bQ7{)!BB0yZ~e|CRraWU=E7 zN+uxywLmoH$3OnR&6!$h(SiV)KUQbRdG<|tF7-Ndvz#$OC;r!3<)(9GBi*xSpLq%J zZ;*suh(kAq+CxK{QuC`zI5K(Q;ybDQnO$tUMeuF~R@C0ac9H;kluW|_x<3R_5suC3J6-@-u zO&}4Zvb|1qHsYNBZcEPYRkR3$AA&rvKa)9+KKzIg0a4Ne4d8h)(x(PnJO!}v<{7f* zJsIVUyNPrOavZ~4ZJyQRdQv?IST`64@eGjYH0c>!)MPc|AAK)=hqe8-CY%?lC>YZ* z^vI7?p!;F45=$aD2a+G2Q^p@55!S|Xhcer7vfzCa4tn37cL;!zY`JTdptmfHxcb2d z_BkXDtb{n_@9FQ!$O0gMHT{K;-Iz^{0Ahz zDc9_w?!PoLHWk{`RB;Q_pSI90MG)L5s!GipQ3{KEb`=5|6GTeh0HNQPkSvYLD*xK~ zhKiv`kL2HVd_$4G%e4E`fX9)kKZTzEap;Lr4+H|GfCquV*@*t40wxfZ5WKlmO@4qZ zfl@ug&s8up=igLG1S8~-1bqGLf3u_j(;zktcmhlvMEL%2b`<$eD? zNhnWXsKEJ*vsF=zD)3F6)kgE3alt1P5#XBO^^P4f*MN;Iat)Rc>lhi?36hLbsIXwFGfMXQ>(3gAk?Tv+MYNcjs%Kz(gm@U&^I-eRpI)&5538RZK9e=KXw?r{6Y1*5AA8)M2?T}+{>=Zg z_8!od)V=rKWADR+3aAbwSdcFgN>uvJKmS4t-jE#UY2C_#%FrhrK2#zktqYQuGb|xJ zH<{+FrRQ)shg7Sg-1ZvgJ_!=SKcFE@MQjZ0gJo2roEF}n|NNTCDZ`Z@0q}%vR9ipo z8EZ*A2u=x(q$FpyKe+{R69xqu=pvOq$N{YY5-$4xB^o6y%So~}!eP^hShM#r-GJ=b zoz(1?@hJhBY@GMefT_X2VbePFRS6du3iba@#(p;HoL5_)1AIVS5;*kL4~xJW3e>zC z_49aJ1jaC+56B>PO3_TR3$$WVz~$6qu@^~R?1#)csYnzCynrschl)Z^uH2mIRDe2^ zVW6rt&=;H<^lielTa=8oYof#{LE$om)`5}fNxrD#M)W7CMy}P$*rCyt)s+H)%_UYz z0+ew|NM{t zuwYG6!3Mm+rjm5|^B~!WgmMc~h>8#kc|9g9z`Z~C!GBwC{dOfavbpej5RG|qPHRRm zka^qR#FS*WB__xmBjEG=bI+R|{15fLAv&?aLj*&04|K!Gan^d6cQfk?$9vN%nbTU5 zw!*bu__nCzNaDtEu-TF9!1th+&BOxS4XR!?a?^Y;GnJ$4TJzcywBlrjA2fkv$Qa}) zK?50ZG{Sr4_V>(;Cl~e^b2pQL?sY}Oy&1}v8X%Pv3`45sBhMf@3bs{#Vr)bHdNrI(oK)tNfC4qt{=3_+2; z{clt)5{qAs6Cx-HG+YqA@RNY>N1{O+3pL4o$_02xl^=hj(tmpUj-jmD_u<4n*_l1N?zzM^VIKDwf2)^8N zbB;8g$xaKBaf+tL@5$ZR64`D|$(~Fzu+Dgn7-NkH%le4mP&P57`wP!7_mN|;it=zcq??so9?)2U4rd>!XG;*Hl9sd)L_ zX#q(3>PYyZloD>+C<*m_vu=bm*nGaGlL3(L=#d-4Ru?*%J{hxH!p>&1UXN~OpNFnD8vikILdW~;^L@^Fs!p9c zRrfY>^Xbpf>D~&b&Xa!OpLK5kYkj>9<9kQJA70yOSn=Tu6$h^FbqVSw#j64C0o`1w z!kEcYb)n79`v63wns>ebSe?occ$kuoNKaZfdtENNwf*&z(FzO;gje8+bFHAqu@NKd z%4WnhTT2M_7*Kac!BoA5`>fkoxHoKaa&F$_J_KyAoCjbvFa*%IPD2IL;Li*jAlLcx zpI9JdT)-yzi6#dh8pr>jbz#=l_Oz@4Gsn?3?rbG809IF5TAChTb0215R(URD64xT- zKrfUJX%b+WM=;<{+MHXNVZeI{z(>1xK6mQZmIXnA4y|JZj6O2-`3JGZulJsjmpNN% zHEJUuO?E-2zx>+o?fwyvxyQ`lk@6)uWaPyC`t0| z06)NeBDrJ~1-kxB8d;5kW6iyA-L4N~o4C(hViMHzbpNMZ5TOY2-0?f?C#=&rKAabO zS&T#MExg~R(*~2;V3QnDmsQpaiz@J{`ri;uC(9`O$wE)z=~2MsfTG4)svNF_-dJut zOi3S5-?Zw=q2C!SA$m?8i?h*I=BJ-X1u2^-i69zM0?^U-Ldho&q1Bxq22+`KG4g7@ zc7fV?ZXWPM^+tx0ep4HvRE&a}&@7ZWE-(b{u0|yYYW*)%N1s6P|EXcnqmMjlneYGj ztG}{Cm&E8ww5AGJ9l!a?w$J+MwC0xPNVq{5=of}#k$ z>xVylxOquutcbdx(nYlm8kq+=cl;Z}-|=?0UUX|Dy{*-LOkeUO|S@<0K1?KUa!~VKw=?mWlDTq z`#nrPrXj~C%w~o3@H|QeC+r0Y9UJcPOOIQt+0gd-$u<61gQU&BHF0A`&5V}KmgDdw zfNMdQ2fYVc;RH;l)RD~C<_f)vW%~sQzioDi!Z)l?{(tD)z)p| zJ|5I-M?&Oli}(JSa`pBXc%SCWQTY`2ymcO`c!m#0)avFwIHiUumW@`P@HN%&HWP))I_ndR!dUf-9 znJh9|%WVDsmtTI_#uB&Reus59_Urv$U*9M&GR?rS$q@WWzyBTWyfBs`@j9u+0HDTZ z4DeNSAQ-j1m%3I6H!-^6I`e&37_4=W2;$7~S>x;<05@GacmVPZQBs+`js$N#F&p;o zj{;Vq^+g$K6@=_*49z%Iuqt>{@wEuDJ6eE94W2Lk7J0J-&2Opkz<5`$>|#r(&>5xG z_KyI;V)m59;~@m7DX;d240O7uA-9PP((S^FqAnQ&v;-DD^eLW?U1Y60r~j(+ypuz;O@`<{Lk&d z|Iv@sK3HFGuhD7t+YFxvi8icMcYq+%&31~TJ>&Ic0P z4TNmIX^wRxR>=~DcUYDbBx767b3RW>o2p|S zXtz=^^2z*_4UcvyN^F3s046~RqooXA-jw|`AerLa#zvyU%?rf)_#G|X({^?`nj6PT zB}NeEI7pOY#U6R=A0(H#D3SZIjcy30+sT!Yf4Qz7XwZtzbE`?IZ)O!S!4y@$n1Idf zb0yILbM`|Bp@L7%T)z`T!H<8Qx*`#zxMP8tiq4$fG-zArV;=2`jVaLt+u#A5Au@CH z$YEoUP%R$UdGi%%?hMciJ~`un-~A5l{?{!y5r{cgs$~FvbjC37;XKj3N6C+5?gdQ} zpnh`gsM7COHV9QV*A4v3PZbC9xN7_Ja~vsArQ|+8sqOto^m#aKK)RdkZ%9PJ-n^h) z1nv)=Ewm;$50VQXaZEq^lEjEEu!MsS1EUGX8u8iKo2qOn0$+WO8xj*Gc2Uk)=ZJG5 z>BGABd{S9tO@#@iNq<(jrtc)Y1&P>6RWxM%ZDsBM^1}-R#%loocy35&k8481h;pjv zalkG?oI-(7+l8%ez@E+qJ#SBk&Oc=%Q}krdBC##SiI(ZcHebLGX3FY)HzGpQfW2jc ze={BYmwAn;^Qd&cN8LUL9mNs4X{en>NMb-c9SyLYUyQ1zNi%T%>)?iAvfWsrRt!x z4s&Ods~9q-Bmq1~BomMS{BaZh;34ESJur*{1NFzgNl=9|n?-ntP4?l3|4H4`o;Du> z9@y{bFJngJ5B}7C$Jp;-{7T^6y`^V+ZMtpbEjC>Jbnktz$QuktRQnX^Ym{IhM|ju_F+>0w^z>&3+$s zw0-IXNU-yMTuemPEm5OFrrKgu;4X;#KV1Un&vTDR0;OdutQjt0B?XCA5NS5+#Jf8Y zuC^+Rx)GASW(@K+c1Xx}=X2AZJ)%L5?gMNSDt)W^4<0f=Uq|qrwQiIIeeMfrbk;b^ zdsNE&EdV{{^r&Te+n4q|i3~tG+Wm~k2xN|V!J~mL0pkV|17`Si2B=K$)1O0YHO+qo z65=dL5_SFcr;O>sxsoWL>%ci-@&f>oL_o?pzl_8-uV{+v!nRJS( zQKyD#b$tp?=fPfs2v$r!cu2S7Je42|%J~DO^YPF^XChecHAM0qwkevUS~3j^m0Xhz zLNuXJ4uEw6{`@cg!V&@ichKXgG)Xe9SA{zdz1P{70j5KKBvo52rz+U5R1A_Ae zgrAX^06s%!9~oaLdnB{`jC)N$zQ`C7m)R#&JQKXqjt^>m%=-zdZ@w1ocv1a3rGc7J z3Nvx;A$Gnv`mq)fxcRnX2GE3HU4aAncMbj=16Tk=xGvTACD9}qBHDUTLJ%{ zdp@7{U#libZi!wpA_I5vu_n~KHsC!z!3~j&hJ3`4Q=Fw_7yXJ`s~wo{?C2E`EolY9+@Ug>mbRqhH#JzF2xSOK6|Gs24Et1hSHLGG-spO_xbJ`edp5J=fg>n@Zl(e=2eAxXk-w9vZ0Ubw-n7_JNpn6fraV9R!9PY* zm;_YgwVl&nBEP+}&?F_kSsGV-MK@Qfb{=YIcZd(6ipQpMryir5&Nw)*H?%2?lAP*D zmBI1eb@yG`Hoex)n{EIcYTB3HmIr}sEq+rul%9fMDPrQAnDf%3W8Ruo`NJC`UyJKyerd67MksRs6(G>a-JjVs(vcqNmPj?K4u zYYpqH1Id$V{pY-?_K;tWw-wdTIE@2l9PmSB0nkP#Y53!Z zzGFbp=bqJW!V`}hSdZB{-7PROuJ~wt^jVl-^7KWoxJJ+s5gWQh1LY}APE0xQ{Q5#* zsr1+uGyB>Cw*nc_nE>NJ@=F36k8L&yyz}Rcj?JekiTv8DuiD=+gNMCiYy_SM5{aC> zQ#kptF1e3%VF=75X(Tl~dtNx?Te0t{B(Vni1t;?P8S5BOJg|4rq2s>Q_Ed8_BmqbU zS0tQVv2tBN1DM zPBvk=`AX5Q3%gN`oM`bB0303+W{xMcEe-nqz0>bDkNo%%t4_fJ0KT~jguHIhJ1GFQ zUgSm8i4SlThJl6z%Ydp8TGap54Urb!pO`VqwXH@x)xOj2N>0Wsf!qjG%;ccAQC%^U z{DCGG&;)6Jf@Wv)rHbQgPXb!!zZ#t}Qx0_Y0aYGJ#*@n7CICGA@WZyb;_VI%5o#ZQ zZ*6eeg)6f{>2GLeJ)32MIr_iMY~hL{DszXqJJD#Kva0dVNmm=1Xh%3S%d% zS%9-SC8HbDEcZa&&4EG&q7xZ+8_U&wx>ULXbM;wTN*jaBJ70lNxNvy@psN6TKsV6t zk&-Iisc%6Y$4Pw8fRRn7Fc>$8d7>W9dqz<1B1p@cfXf{GSzQ;j`?rn4cF;ye%;T@P z@+xc1n{R*~BX|SDM(p2X$uBd0uq!XT@H^`?FdF!>CT+CBk-+7BPJjG~uIHaxcZ(4L zcz_3$1%sqE=SElH5e34YPU?BE07wE}Ra8CZR4_7Fr&#=6uNoEHJ9`FaVKl+7J;($@ z0ydf!bQwr6(59F>Ho~6K(2VhwB^xtv4Pn~6_1+uS{XlT%xQ>Rn2Bp^jnA)8OaT0N) zd%?WWg}Ltf6UH!VyMDPXckx;zLd@{Fb|fKuxOyPho{>h}D>Bovw9xHY*8l&+z^!Mj z3xar4WHm$u(-qg_kd9IvQU|~~CVIR)pM37!yJiAlZJ|`vv{)2Y$8CG;C7e?6p1&{V|q;e*QdXMgtRhW3TZL_lJ` z%1jf~yd&So90^xutM;I8=w}4m-PruEzy50r+OPwDq*A|Z;?Sn3E)E{JAX(Z*KDlp# zC6$lLEX<;G&zl4}^?c%_?wdp%5Q=xm;LQa50p zW|eG+%r$RN_5{E~#xBn)8l6@$!GMH-+372a&gb_yUwGKepbsBCY*i_5WE41e$jdq( zMdz?f_-eGg^hD*NPd&Tfyc(GI1A47Z7(!@j>KMHZrXp=EvYiF^PG&eHk)M^-)wbEVJHg10wQS}=dL120)bGj-`Trb0 z&#d29TJeYNS>Ky{9L9HDIww32To-~lodXh?e2(Agvq%n{vGrhH9C{xZ5g7wuHXByM zQuKVo7trrtN`_Y^BU0VcSC;e>*saa*R1qp8fYPBxU05#Mg+_YXOk-5yxSNHFm}S5P96sGwp$)ilE4-5kyVMTSn-5gDR|I^*W^i(1$Hd`&G|U zJMOGz-uul+=dM;c4Q+D}{RCb%D>JCc)c8Uuu?Gnls1=1HYVS}~H8Wa*;7>HEAZSy~ z;z@8y+p*9Gn@l-jc$lF*MmLjT6a5oKS%cR9ryu;2b}JrNcf!AEUe{_GyJBYQ2I$I6 zQfi`0{WVuy8DG%<}?s?0C*bTeDcJ^C0U!+s~{srCQ~22gQ^^f6LWehO_Q3{ zE)eK{lph~>;6Ym(Bu>$iIIKU+Ch8;)^Q0vaa;Mac77ezB?h!Otx=jF%SG?OfNw=1+ zyj|AgM@p@jdhnNO4Ji2|I{enq zAo{tE8rXoK4}xic{K>%cfbgd0~Xn=y$}sRtJK z185p4+O?VSM?3e80XjPStyw2(mJDC$%n-D}YA~CJN=Ic#0%;)01Wu#tbA8e*fYoyK z)z{cH;aY4bkfN_rfQ@vG7!@$T=XIG)Lji=VP9k=_cA4nX5ENkyAn}Y|06~{E1#p!4 zJyyV`51_!&#~ypcupGSqHFJ?RV{$J=E%3u^A3u994T9o=YotkgcFT!SS2P{>|-7tucvS>ac zg@8ks1dOzB!q9x8?Fda`Qb9T6DE!$XBbHclnaX8EX!`!EN^ zqO|p1Z{&6p&>|zt1E&=4-FLrn!In{^os6 zXut^&h!7TfS)J)NQWt~`mewzUkHq2V(YWbxt|3IZU^B)^a=E5&-pl0sf;}P~M}OE+ z3WI{gH<2<4o;9-)I$K2)w-T(n?vO0zUrBU80HCaok9D*IBAlSWteb1jE0OSU-9f&S z0Knrn)ky>O#ZHlV#wRyN)-eHsmOsKjumf0|BwAGccieTS>G>N6J%P~ZEd25p(e;t8 z!wp)|Kd99^s(s9uY3Y-6!s+L=@n*m+5{3baf=FzWII{jwq5PV<1QPt|R@gmRT^%vw zsqBc!AO|C(XLXH}xbRFd-tdI7#k@@Tx=NPtt|q}A>o{pQh3iOi!}IE*I8#-B{;aa@ zdFI$F_?Hm~uNQ%!;{ss0K@ra!z+Uty_UXNZE=%{z6--MlgNXHtZF~|3Vm34-={qLz+$@lw z?@Jp`_|g<7CL{8Ot&{Gk;X@eXzKm{)qei>fB*Le zxI+-;um0*24T#@0y4q$2UJ4asj+h9{TA&^CSb{0l-j8*(0Dl_9E^XHP?!V6-_N}eB z`TM+g%rewB4T2g#xd-*31+9-78yhks0XBNn2=M{fx9@;$Fs^Oi_YtUZ>bq_MK>;~e zv=Lwn&<_6XZ`A4SUmw!|@H1=Aqx=RNfwrZg&^3rq#qMOFwOK{6u*E1Qiq$Mp;do!h zF3_hUb?p+{@Zp|wA3(K#lDN{$g4y^uClZ4%DZr3Q^|C82Gvqs8b8T<~G@teDowhYP zPog^Js+TN%JAS?lD~APg)2?bb%NPTp)$Q-DC&7&SPT({VCT-m!%tMt=5MY+SzLBsQ zns_qyfL#IwkXAjLySW=4JY-cHLCZo-+PK-o0giGGxHUfa+%pEyUfu`(ipq*gkWgeO)(;5a-2@Y@}u__qQzJ z!C;ED%xi2Uzl~6Lin`|-kVz_?qVR7wBS<+x1xa5M{aj4IOHBW#aRwbE=+ha&pN`_(hYadp7fa4)lT z9wi_d4$N)=UAfrI*v4U%T>{rpeVE%_s)VVy9)JAjmbCCX zY;GX$`5n3tHiu6Z$sPAP3@mFMfar5$T9VMV;Ox`lMC1j~6g+b(2wJ|Vut66)tbv&8 z6w%4i`k25gO*tBup5hZNIYl^Ez}$uD^cG0w)O)f$~)i22`P(zi)mZusLRU z&F_DBT9pP5M?SP(d+-)iIZ<3^kMZkWU?&FUU{xu?DJv^c-7m~SRm`hv<>#LCQM+L) z+3CgvBZ447nOl{w*Bz{dTQKuAtO|g8+$RiRytU7Z6Jk3^FI`i9Q@!2&$3Iskm&CxD*BRT``jnc~`X%oJG&DCU~( zU5z*y5?Ycy?j2YVI(2y^K6q9t&$(*|kW_Mw=!D>5K;lZ0%UW|Wt1?NJiWrf5W0175 zKd=VPeNz(?*4&w-$v+1E^N}8FfoJIrr8V+3oj3L$8&eoiQU+?IE&#+=yw68^9(<4l zM_-GzijHfiJF%0jjk))X#~AxutE36_{ya9Mp|QiU&=a)^D5FVPod!Y&N)|u~C4IhEq*IV?%oAyWcSmvR9&R#artJ zSXIrYhHy@*v_WqZEZK}vHGJ;b=d5*pS2AsZ+J#2GyV}_jn1!a(sp*@AM4_B`3!$p=1z6guyoT|klSC?smTUY+N5QpLSr79U z8yl>wW;DYtbMZN{AOAd!R|xdz(i^W{1OAS+2wf`;HF=dJ=rxrD~68 z{4-{7FkP!FFEY;em%bX@JmE{I{z+oc#JEGT3M4bSSAB>}{_ zlC^8swwUivjR4jX;1?Ig7tB29kR`$HqcC1c#!pW)2*z*fS_RyBktSUvBQtX*oiP4M z2&yB1Q5mC<>#xiWCEXtdwEVpU_{$xF z`$|fsYGVje$(Fo#;L_}E2YMD#0x;D<30WAgLL7#+eu(FH@I5a(rie=OSLgg(SSxc}3n8keio4U!7jsM->{coE?W7A7O2P;73&Bi~s z)}||l_0!47=ig}sb*QG8^F@vNHKSHN^2j3=82Zn{zL!wJ)$AW*{&aJ5C| z)h}sfO#=6_cB2ULB&ggk+WK?}7(39Zdiv?7j2XjNfa?2xMc+dM1c8s}(4&w3xcSB7 zPuTgB1cLVG{pjLsq{OiqQTpp|3h`Ot90^K*^nR+jGdURNsN*|Yc8Qp5x{E! zY~>!9D&W@k03Q(w1nYdh6_ZNsesOPk7LApVlmq*qIY~~J@wj1aa4mSvgNF{aUAwAh zL0C+u3lqgwE9?_0OI9jf5|9RL27D7A-4c-vzZwB+w7p`mKbDw zRv#+j3bF_dSX`h1l|qK^RdymJ1Cm4+zvR%T6&W*50>j_@&EFVs=zHJ$p8XEirZ}+i z|8-zsSFyVp5x&n;#t1xss;sT672rI~#}&u`RHd&8f*dc^ zEv;9o5s>qbDth0&-apWXo}hT6DW_f7dCK?9bqZcZuNxrNa`brrYjQh1pwVPHoCt_tyU^} zf+zs7d=AfsP0%12V8(7DN_wr)I^ddbC+!3}2K?T7LfgA#@l@|{C7BI@RJWOYQhp>M zqs>mTb>yNW#&ED1zOI*<2{G;T^$Sv9Icx5ZXPZt|ApQ?YhFkx#fs+(a@gs=CwWkV& zI!VRP^}h3tyVT9@Ry!unm-9iUmcSVu0sqrRIdlxb5is5XGs4J-Ysh+H%t8|R^Pm6B zDpd3Y{z$t?52!OGNg!PTD6UUxT+uv_(ZBDWX>=}@m(H9-y>BRxp)sus!8FLMsSyzp&5(zK&iUa^mhvN!L#m-K1 zAa*WJ>v4mn;eA<`dt1$cLhmFjfwfwQ{+H!-fEG}^mlgu7(8s5Zfa<>+GC+jXy3&Ow zL5A87HB5nwWFgX{5Y7`fVHVtHG{#mLDcD=-VcS%9z=#a9o_@QcG*S?$@H+;q0qNQM zhRPs9dZ9zAhR!lUa~R`xme)8`tYVvibg)d$rKChcmYKS>;^qd9`MN6Z@e<$r-apvJ z`2FvH--5;i_kZ1hR5{XqHk%C|+Hz46sH0`;A*+!8=?6bB?mbqo-}=_Kt;0Yoy-8A~ zJhZE;tCOhSCh`8MJTFx+5nwO66xf?G^903DwRe+l@?ZPf{l+8!Xy(+iIH!#UDXsqb zhafVivRB&1WBM&Mg>tKI5Jg8VBUT!UROpi2+!=K+4i+c z<-1fF0RTgtR=H-vE-O||GmZc+0R`|m9T5Opqf5elq1tDxa;dhT!EvBG_u`AsTVhIL zLDGQe&n;iR#mt=O{4idE8cJt?Waf6g?~DZq{@sTl}>|#*=wx6Va)FK?AMQ)JbJN8sMua{4iC?k88sWg>=F~cYtPzw*Nww z!5ET%j53%p9L8iudc*L(QIYsqpuHDT;}~Znf~-{%5|22t`_pQ-$ksJ#QRqGzCy_mKNIFHZD&*I0xFlx2pis+gjCldztn$RSwk>vsX5}OSM`eF@bk| z$b9Pe+brIJRW7}3o3nH(dHMV*`1qg|M49(}7DRd$!G8T2yMW)|eOD9rzLgiAg{1%A z$+mW)G+}f-`7xS&@4S0&Pb8uy!H&`U4OI86b;d<6Xwrrx zHr@>!7r`BAUub?@3+^jy7HD~3D=@7>J&+{CRqafKCd}$0s6|-tgvY6_CT*<8Ns@+5 zF^L+Z5Wj~ZEPk}zZPD=&jD<*A2$}?2x6-Yn>)!@ zt+lhHpV>}5wq8lH84;*xfnq63CxH1q4>9voXns^ro?+KI0Zmq^`t76Ip+f%CKm9XJ zB95zi+W$3ipC2eg0JpPmtIg@1TCE(>-`U)pk8^u7Thb=AQMZ%n%`x5Z?$d4nQompV z=rqu(e@R>QZ2sTS?gkreY(LK0VCtZu(BxL6-MD3zToN6R!0efyGk?BSUk}Z9HYkEV z!b-2d@tTRbP~jXoa*-t;0L|!hKqF%&Zq@PTyyDf)Qt_5xFSri50zR~I?7^C<{ z+ZwzA*NtlgV}zDC0Urt%0l+#12+!c8INLIu3FiRC5O5x20s=pWLKo(aJMXZ7&K!SD zI}>zbaFfH{_uoys{~f!J5!iXRN#ycA&*|$tl7c@U_vyHa?ik=j5||E|KO22=OS*5Y ztoPXE<#<8;lUM_YgYpM{qI2d8(@e9Td!X1c@jR0F{_0mxTjGapFdev_y{=BD1^F(>0Hn`$;Sw_(TC$}NE)%>h6W1b<7%b;?Kd~a?cQ3K%ARlFO%32M zJSY|@PDtYEs>RXOyx4oX*Q3M57>@#j*9L!reXw`mdT{SW@i-SIm=s|Pb)EkKMqwCX zhqb9J77`ju2xNh|OvQ3Ilk}-;Ahcuc&_lL|CffE!@%~ofGvt3Hc>_SkwF=^Xl0bz$ z^Niri>*C{EO8{q*bO2;>z7mRF`l4byEbs>iLnuQNma6h)N?LL}j||OX|K_1v)3Rho z$ygT=z{Vy^ux9qZxwUB(*7I5={ZK1+HU*?fspv0I@Kn^=-)s|zT1M{`qS1fU4G+FO zASWJZ+VyPOJmBxV{f=$KZ2W9sWb2z-TUo0y3tZ{FB0_%0vV2<6wCBxJUaJh|pNPDe zDx6kD%)Vb#FLWwN+L;K$Knn^uNK66ZSc3EzOA7KcN3cbC&HyvuoSt^S;1QN!)W3j(67P z^FEbI3Pm=NzD0E%XVU#`=UO&GSIs&P-ff5yz0o)bu;DqI<9=!ZpHC`OV>4)Q;Adt@ zte+UeuvtSB;~t$jagAvtu<3Wqkrlg7s6s-~d_$QlBoD7CK+yQ!)-5<`Nf+&JoB_E% zU?G10>z7P|8D-q?R}u4HqR*6lhGIdqA7u}(JX;Srx5#j>!07_ zIl_y95zHH!%mT!u^T)`=q;)kxTPYm>nxYFBfp(j5obz_HYQFjAFB`y-*N2`rR>-t` zas3bXKQn&PfKI264qokCy1)>B2Dq4l1rZ>?q>OX+?Y|&PuEJ8mKkpWI;I^>1x?U(5 zAfqU4S=2U`Wt8{F%lE2BP8e!|NVRu7fcP~%JJL`iz=eMDbk>{24QFzpb>?#*5EiUd zK5W!bvuNAWZe*_h4Rv2*R?DW(h9fh{TGhjREI#z1XVDALh(zK}m1dGE>n#V!j8Vx?CnH3bFAy}x@rw*MbZwN9>_+=W1G;Cd0R2!x44d569 z-)Q-lN(}lU_xA%8gQ4Azz|qGaeQehgfGEt4+x5DEo8U86uAL7;Y~>p7$gl$FATWMG z$?rJ@_z_?*2jbdu&k?-gHNZj8MuxgbB8adP^g%vQ#lvM1fG5c*qaHjO{$14|fA_m* zt-C<*Ct+nhIq1zRe;M1<+buJ=1)3hSu2Vg3&iSR6Vy6R8;&snZ|rjMs51o$KnTlPL)ni3ThNKDn1}@yxSTr!tBqhc<@N^+F;S0_{^IkPtL&}#``v4m-GIo`Tj6h^c5?GcK_Gt>4&MDpAfxNfW1>Huy>FSx0gi4YS z3i0vLbIJB1D1W(nZW0O#X>$=C{+QebNk8pT(9s`hj0Tt%UXXH}P;xb8AcNW)5A>yg zGli%i9mGDAzW{hZhriJS3Chs$rUU|c?lt8MHcHrvis2R7>5wZ!}b zph&R^5{yA7BaMnk3MljMD5oF6734K(3+9SmH9%ngFF>&?^nqT}&6N%RXFvO?&F&w2 z>`~*kL%-s+ue$0=6H1BTy{$IOuYrH0@E&I)Be*bHK(*{YU;0lA{-F2qdPneuiiGjN zEn1zhL9xNmQ83V65}|Rxp6QZ@jt?xk_I;9aW%drJ4YWK76y6G#Y4vpLmp`*E0P{=| z0}=to1OyzeH^Bx`8bFO~lC+w+u18Gs{9sF|^f3hO^H>q5@j;r+mr8#_KJgF~RMGU! zTE?>$cVYSkdXC1*m_<;a1%2l58fej#AYicZGcsc1W|nUvMjC*(tqT-Eibr)ca?Od8 zTIFA9&Wwk2lV<$Gzn1tVke4~>B|B*t5%0YBwg%9bEU+M<{E0aOu3F&7zu`*Ny62wL zVWHIw?exVLUNrScH2XOwk}X&OwEXF0k$7=GNN~~ir-Q>rtkZfI(FinW^476NQH;TjsT*>Lv?;DlSM7%V)=A1TD zGQ6nrFV!Bop9D{?1NR?bB*8at+xPhJl~E-31n&L|CdvGfSv50yv(CWwR`#sc4a_(< z*dONQpuZ1p;P_=R0obXH%B1gK005Hr<=S#hz~a!cT(@pWaXm-4_tSvDA?r$}fV5K% zdol`QFHm|@XareM!Iy@16@>m2hD|~#lV5P{^S~Hd9e;^C=wzF@C|! zhW7-;&zj{}*7ev(C}?$)*b?->01*7|SABoFGn}IZe<@JVOL%@*S51$T=j0s)!ZC{C zdvu_ftDC?RT_MFp#Dq$wE6HpVr<2CtbIdO*SeN&}hM1(5H9+;hI_4ix=>)iU0M{1N zAJ+5bnw&HGIC$_vvq;W01Zstf|BQP^@TPqq*&El|TE-Aeve~^9Ne#~wER%CsIqS%V z`^amO5Cdi;sivwg!HlhUfF+Bu>%&^)=2)p5s3)}4L~KovMurm#aWTKGW>soB{W3I9 zXq%}i%Jl-6CJC(uWej5E~;fJIyx2B!Zq6BEOME(qj1bv1s7T}hML|XjEeRm1H9M1 zHlBVG(QyWWSMs6BY%5c`J#Vd12=eSLI)XU8ufM+B@$;OHI?d zD>7CgvAaPeiy+cQxu#>Q6lbdG>w`YuoCbX!40H7$rAUGIWV@? z*PpuHx<^!p-rn`&e?yZyDq`C0Ama%Dx7~J!iQ=I5kB$JZMWFkY3IL%KfUGc`B7mg0 z`q6=DpA#u{<0~5c#VkA@7rdjv3_Sv5wE+|YOteHt=54Z;3yC!o*dGVTa?XrZexjZg z;AePEg1?RNGUd8ZIi{*70m_4V&!5#!3D=EI%1t-jV)t@Z%=l{>2bM(ed}?5d zaj`7XUeeOdJV>;;La0>NQxRMiS!>q-XknDAKZXOT@$l1=Ua5PB6>eV>S4Qpo^twyh zL&9iMtvy z3(`~zno-l9P%)ZlcJ(2<{(TwD>90nqU%{~E2BJUG{r=5 zq*dC^yE93MBs6H2nJ64a?r~gTD>f$BW5>b2UUjiiBJXQebDDdyiSae+e^k_43E9kh z64ZD&kkI9R(3ZCVn$-TNqGAG&=yxj-d2gMAlx;_+zMWRpFIqCk0J@bl6YScik-!$9 zOlWH9Z>gsEIqyf1Km>^@4`oT#z>y>nga*UG3l3P&H!I`qWM#a)V>C<~3uqUB^>X%X z6n%L|@AoTO?K2KSeGyR~t`}4}*5D~+UeL_}+{O3#TL5PO>$sPu{P+84T*KeM{y25& zMhpHIY7#{!<{9k@fFVFX!VPXf`rm4xyu&TPHME_cB_!3dS-2-9B$~Vz_lLyth8u5C zmB&MdV{pMJk!3K%(pKsrIpmS(aGoSMB$PZGe#~u^T}c9lqB{}!kJuICmG?FALIRnt z7;DNByfE%X;KydBO}Z{fNa(QfaolXJRi0T#6(qDPoxs5;1<^1xO5Z)J*XVKu>yqS6 zix)}HJAk!7Vht{Y`M={+nhNBu8j(Xk+Dn+?*e$92(=5W?6iL7W-TtYujx;i?hpTFS z{SHHDDuuhq($v5q;xG&>mqRmxCtjqn6f!{~6Ad#OV8z-{`jjX~G#B?e56@0Cl*}Pe zir*{L76;tGFmBjWqddsyLq2w+LltBw3+Ip1R+lmg4D=)baS^^E$g}y-?nfroXVX+r z$fOd$@Bl!^oFL$iaZ|_SEo;}hCt$I`Gk2v;icK+dd+-6Ye^LL7*nq1np!Kg!F7exK zY(BH*r^ttXiVUd!5`mM=o}}g7cki){4@EXMRNUgII+@Y3X;V2uD5wqOx#5PJnw#|=pcipbs^~lja^Bom zx={r5Un>m`p(&2N9V2r z)$*c%V<8FVdN3}6PFoxM<=2jHN2ks+mN>JvFV+zb6j0Y*D9Lw)C}&#;hDHXIH5tWq zO|<(f2wYj__iNmI8IaNA^(bNLQbL6nfyVAt>o>p zr8xZ+KlZWU9Ts#7CM+u@W3`DaP8K}JX=mI?Kc1GQtKrd(W{&%lpFC#vzo49{N&q-9 zn{|oSu}(nVf{aM~XkFYQB++qk~r!A@XC($%Ud-S|*Zqbtg$>ZUcuIiK}s zRP)x=t^+C&B&gI($6l)px)7+)QN6sS|FV&Q6QD}um}uX7=K!l&lA#tjs6ivr_hLZx z+tq387s-%xZdy_Hb!PK?`!|8@coEv?NHtq4em#OM}P$ zB{6F;41Ax&uQh3mUon5ho=FU2BX($@Z8OZ z0|~jFj6*nH)&Zk9I&m_0QzmIDWB1}Z1xrA^E5ZG->#Cy(rz(bl5qx?KlKLPYAL$-q z08a%GlH4Ha%{s^vfP5#NdlIAX?_D6U1DqGS8y73}xM>rB5pDZY?Qa*={XpZdsFGXi zpvZ_!@Q5UEXe#;x^*|qFs`DUIu!6#>tY!ohiYZ0t(S^Ijq&8B{k$`!ty%Ps$$^9V@ zo?V9`WcI?Y_24Pb>M~{tGPFaKc0gOYD)d)E58GI|cwt#KrYv)y31L*SG{Z!32~g zbZ#4cZ2R9}bUo}@!3QvQ07S=T4yv9+1F+V9mHKoES~o88d1+pmt%Pnie~wKdZ7LUQ zkf5`0_uZnw;X?~7FQoARTyw?{j0mC$(A72~fUC|t8Ni+g%N6ZRtj+eguti%n&GYI> z-2AAks%)gNwLPfVTtUwh6|YRa!{YPQuI(G5*^dGDA~h1_L=uF^9!U3-&dBOY%h`v) zu-3}_hSa%>G(0ygicEt}mta1Z)+Nw751CryEF}>%NPJv_76VBN2=RASD*=VLR}~Bi zN1s5+`LSs;-)G!&Qh|Lw?(uOEls*Rr(k&o4Getf9dqzm``bhu?{v;~cU%!*s7wGvj zTB5T+mkECV-FM%s3C3}|X4Zwu93tyT_hvv5m9&QUrs^CuNH7Q_HXIMgd|sENXI8xa zGU8rd`I8u;Yze3l6QN_rE{;~%`V7XZcY9n)yArgy-@J`!2q^EL*91^WCxmVf$@`Sr zo@=_3bTzVK2^d`!MhJ8dZm?s!+^DiO`)|p(|E`dUmj_7%zmFco73w{}*oNTl^Z$Nw z1b#s~LYiQHs`N#`wRFp1m3+yR0}t7nV^3(iQ@cg*EokjajXIF&+y#+EdNs?-1dn~W zo8Z4hhqzCEl&mLQ`pw8FrD(({QZfVGO!mB%02ew%S&hCUxe{Qq;~Syn<}VyvysS3O z=o=o`=G+M!%eaA3J0+?M91VaywV*Cb%KA>veioQg$V5x1w)qS@JgMa^E!vkZz<0j; zU3=g$<$30rXH7zt+4@7@d8m2lq3_tnOkj5ee+cz%fPc?71M`j7YLKx2RX2L>KT+%f z9A(_w*a(^He*M7*tz*FZpxeJO-%RuQ?Uc06dUm(H_iKY_NPxRwE$$n2R(tv7mzkjv z05L$1(DvAX(T0EO$)_y9kbD3fL~@p61SCnz+|?3IBG&+f^UVFPKi26r2|9nwl#}-T z)-51Mgh5KiLwjz$2PnN475$#QYlauF*m00lzt%bH4^x&abEtsV?@k5CBsPJ zN+Lj}_7|;&x$n$lk@iJd4x=f6mjrMsW_}mBWD+@${ICQ_s7QF|8hBOfYIo*yuR#Ve z9}{@e5dfW{+q7!MqW2mN`~)vo4dnfQr@}jcJn0m$7Dz7cF;k$MO?=3(B6^=@xxmdt z@2;G}S)!zg+8!N~kZe4v&X=^#Nib;7hXhU=qsaE%Ht zpjqN6mz$z@0wP^7im%W?h+v}Q7F=n=GbfIm@D~iN&fM|j$?Hv6g({u-duXdzxmCI4Pp~vrS0YG`6PudAV3ad>S zezqGykHQ*3m95iZ>Ne0p@SsBg-7qY|8FPR;r}qRTzXKY*GXy%DnLqde?k6)^syweVQDdQQmuqAvlygYLzbRbdcW<8!9PKVJElRg*stUh!gd z#PtNbfGsr%D*SwsU#<~JkgG6`;e7Jfb;@r<+rOdXgr^PoN#}$$?$1P6qO0ftQMmWC z-w}GED|myp?n&M^GbO@470f#)={2^Z5fsnX851lOk-BZh8}(!0{?ROu*+VWqmEwRKrhQqfZug4`iv)fpkVq;+=X){ve@{ zyzj{@FB$a0Ao$zNy`y7DAD9{ChjnaJWYE&42~8imC&TeZs9%HH1qqpml5`-Eq;)+$ z3#b6}ngv3`QtAfO0fbXXf01RaBPSFELf`UW%qfote4bjTY@P&qP}j`u9?>cYS=ImZ zfBw#>McAt%%0pJ)`2>vTOxEIF>1LHY*!(`5Jew3PUFcgR8z8NJ`0x)k0RO@^9`Fgb z=mtq5Kn1dsS<-gtZ0OTw3PiR^^6IAn*z}v=9x#=;=bpPQz(DN+ct+v@MU7TA=jk1U zW10-0D?m_lDb>SjVuS22Gwuk)+v!FF($U7MHk1_wvmQS-4FWyLkJ_DPQ?ORFBjl)I z>CudGKwle}q7X;?`Brp0WDUSzV75+69}pP9;6n|>(PIMZ;A4@=VHk?bQ$lypm;fXf zTN_)3VaNj@?c7(iszg-g_c~XP^WD8V+rQQE zxrRO&rW`7V)wXQ==oi9wN`pJ^jn>6FTEK(m9MacqxQ`Pa$Q8FXgw2ks9fRDcSD-atYoZIA2SE4C5%?d$*$-6HKsrqIkrB~X54$4 z7w`&}3WQ46s$HGmL*=6qeq1+2OiBm@ufO)XDGT0o^G((b;P2UpHnUB_L5GqdbLYt{ zhmFo@hy)HQbT;;Du6@pu0l-5f1Z+kqyAfDlk7+@A! z{RemOqqWMbZzM(nf&Q#%0i3aopMZzx3&9=`sDVE9*w>OZF~JtF9bFsR`$shi0LdRa zBl``1z~Rhyq{V{J(P4g0KtV#8iXU`3_W z406ABY2wDboO{Rjz(o)^T3m1lRYboGs+{h343MbF!wYIFj1wi@3V?3_SFICa2>PA0 zzGNx3PB)6#vDe`D=q!-TfPWytg|z@1gYH_$=zcXoVNRdhNzajaZRo#@wD<&Pah-x7 zxi37&uGOE9sHCu_-bv3R&mo;KUWeY>otgK~y>Iu6 z*D~(Ez7EOAmaaQMuI+h*&};XyP65Ia_!(zNYX1XbWPH$e02Yi#>oIdEx!oIOOff=( zj5YzJ7(0j260+;&E#(N?EXg*gY88Yb?QghNGxxjH`gbp&Zl7r-p=41XekfEI3Pzu_ zu7L#uWmb*=vLs;eRw6ano|JMyA>+Zfm^~<@^T7?gdB{3W02T2+2K%)DR#IV5~%j|;r zm*fDnc$0k1XH7pa=WdH$p*5X#cD+)NT7Y%^kGq%WQcW!aZywO+?nuske)l9 zcWoj*BjPGprvP2)j=&;77TM?UvqC{|KoOR%NZ_?t=Oo=6*Uh)wYPuO+;9aisffyFp z5WYueh&2QSS>dHc(Cvb_u~+nW&YsyI0hIC6?w96<>dr^y9CL;Ej z(_f#QCY4|oh;~uP4O`WlB+?Oxtov z0o@7pztud=$wB^un^8&GdX9kw>QTl3d!yX@d?X(-fAvvKukpn=CgL;FhLj0Df^X`f z1|BK(1(sz9EYQBr+xrB8haP&!+MxvIUp(;(qc1U+Wn%`2b=z&XT4e~Y-!=c+wq|8~ zl2@!=y_0a9(yHs2QUakc85PM|lCN1J@U$ulq9(@Z0HE2mC$BZ0^FiHYsH`K`InCk| z5X<%Nn}YHhn(qAC7Ab92J$`~Y715O%_y7iiKtGST4LXbF?>xe6lJe(%~oyLW&KpHfgLRVbYo zDoV_SZr8PNwT`4n1ULpqf`<}6(@YA#x3;@hu^Lq~>ea&#dHm;(o9NPg_dQ^^0#8)O z-TbBjz9d4i)3j#Hli&R6*Vdw^)5Y3G#LDpzp2YOH+k@(|ODwns96NZ3C!ToR0wz+w z$PyED*IV(YFd`1tss5RgBWjATMkWRVPT(-UeCwAjS&Pqc7_9|k0H2u-#QF8lENP~D zM%RN54+)<8f7CWOKes6HyB=k=wM%q?J~Nl7gX9tIta*Ymg}qpa#Lww=p-ZNA2d}Rr z24MnfT0`T9N--z3{eF10q-sti6GGv+AyondrkFO5T6!V3VGM1V?zL%b3%G=&bo1BH zssZMefBeYobwg_`oW&_uB8cl#_mkCTDMQ_5X&i(93t2}bj3sw|GM)tVgMYK@;J*xR zLY74`%BXsAGY)F}Vz9-Q@1Cj%9!fR|=6Fc#UXc|1@Dc#XgbN^ZP1N2H_;_-h5+4wci(x}z=P2Dz!ES=J%(|NwxV4x zJW>QBh0Z~GHE}D`MPQsT-)xOdsGKmNL6Y~<)ywSsskjKHY)**xe5$s%bOQ)_AEuc( ztxM+CaQkUx&h+Pdb*629zY@WAj0k)K=Qxe_xOY#~4w$XT#CMecRDM4gO($BhmYydg*(DUtnWqYyrsXsHy$MaR~Pg03@a>4lpzDWg1mD zplz0viCxNd2(*Y%RPoF33w6T4Zx*$X=fn{V6DdS6`cYq^^xG8SN@*R@^iX2 zuxL;xq&q|P&%aFq5cEQuXm7pSr%^sEi2mvsDCD=r~ky9`k+HW)~6b8EvA zKB|At3k*@DaPCX^fjTpyjiWqY`s~or06*gU2(FR7e|+KKKk~h>dtuk5UUxTR+(lfK z@P{hQO1}%+NU8cirOX)rB4qv1rEW`29i7JGRo5i|xgBsQXLat|Cw*=IVRF`! zUkJIzb?@G1{U|AZSgZUO0`Eko^Ao8ngUKTB$TE|LN;W8?u7NVKm}H#SpOopS!oK-O z-?ZwRkpO&bDs=N{*H$Fe4ee7lZ8i{VaZbKxNa7?bEl_ah{CPVPn-%LOg5546EL4^N z^w_9qFQYPto&XhF)KI@JnGPLI72LcP6)HO=k^=`V6YZ;!StU*Nlae5p&ZVt?2_i?j zIq_Z=ByrI*1-~<`%HJoSejGamwAqsu|M{eN2guHksTCdCnNN0es;ZJ)yOmk14(Q1P z_=W=(9en5jgl{_<9TC!AjZ1il-uq5b4xE_H?#_9(A>E@@ca8&M*VDwRmh;D*6 z2^s2(%5hqP9`Cs{TD(;xCch4Gw zg<)P6rnxUs5wZ|knF8PSp9v80E3MNU2_+ZI1bMqrL z?^#V&U$o=KQvp2;t|RN9)qKc=t;U}1{B=oA07n9*`YyT-gma>^9z?eot+wd)B@6vhO&8gMdeiS`+46{3;||jt(v)%%rpCH8P)NZ!a6o@t4ejG8#;ZLDG;D68R(ZV_K^w9K`(MuI|_?g zEj6Ft+^9(gYW@ArdjBm#;mwm(?=8bQ^el=yv%kZ2DK zkvT4txUN665b9)fU$fdH`K?Azvl8DwmZ|&z;?Ym8FA3*B!>P~VHq06qFkwW7S~t()zAsNUFcUe>D96wsc2(l#ChRR}taB>1G70bS0Rz`YHk z9q&%7Ej|lj&>rYq1D%#PYhC(Gmp@4$)5fMPZInk1j2ny$bVH*pkDDQFcT@N~AHTnz z)CT!HpOgWqhUYDdAb|v>aL5YHpo>DHL(qhtNC4nETW3KxQ3AXLc>tY!y*CDYzFu8f zZ37Swq8(3i^5a})$u`*A^i+u-Yry|dgFM&#-S^&UK2WnGf_@C@GppJWePK;7HliY> z;%D@+mgsG)0nCBG1kf#@{SHu&Yt3i})(1NK<`tpApS5S=LA3p6q3ckI^vrjBQ>m?e z?hDt6&J~I5E4E%&DQlX0Ryy6)x_*)|2yzun7hwWBfQH7eRMMEWcKhwo z>Ch4NtUDL`S!SH=m;&zOo2gquvPs8+=j@;+cU#Hj(X5w4Wy4G#J2pm$Vp|`+|A8f6 zj44bv#w__g*xHVt!gY+1VdjOzwN^}=RErNEpVyWd~` z`q#!3faA>O1g{_MeFS*;mq6fBxwF}7t3W43d)to1=};&ZZfvw@W|9E_WNg+1Q8?r; zyckVmsE9}cm^~BZXdlu>jHWZE>}xjX*-EFt$LGqPmA?Ok2mRnwygq`s1=VTQzafR| zTgs+r>2s{s?oOghwb6$I68UuefG0t7uL?7CagOrua+R3{*EKT^T5SW@JXbHwz>#@d zznA>C%z|06@YQ5VwhR%^NW<-AnbR}!VHCo>;XDy;0x{2hL0u920LSF#LC{}M=SISS zV9jqeY2>wN7ZaEn_n@>#Cx;+MLS(@>RsGRsw-010JLY3+e=Q|O=hKXtE)t0h$sxi+ zS7=Kf{yA)j2+Eu-_V=jZ1I*f4&zryuo8?(ON5(Q#)i@IpVEx%yHu!rtaxJ+7(misy zWCA4;!d!nh4>FUUTC2MViX^Zg^Koh9IJs9OV2meF?Bky02|&NY*s1>UEHDOoLz5Dc z2$JIy`Z_j_NNSeVYBvRfRmFd7CfC5%bv^I_fR<;6wPoEH^KuBwd9j5t;P!AYoUHt1 z+-oJ|ZS%04Rq)cIgQk$rn_>!#BnDEEG!~hVJ;>Yc6Mh4k9&o677IZql8Ms}l`Gv3R z*+2@oEtw^ZiXc|p5fV~06plNC9=p_0j5$9dEClWK%r9!0R9H9zsb^DPyt=Q0L?*nR zM3$5jmI`?xa?e+iVjR^r+iE1uL2HL?1i*#}iuvt#-m?FPzfM~dlOsfGF1!3P>kdFU1KrFf zb3~1A4r!GUtnKZ3{M&|B&&LK$XleqQU8Wp{iiFlGcWK{bYaGuZv=Y6X%bui=mRD4r z8CF*Agd%Uvp4C}qnAC3e^N?^4Vm+|ANKew(#s?Ny{aUU@^+gQ|k@x?hQTE=|Yf8{$ z#KYHAuLh7rs7WbW==)Pa1Du2zLDjdC)B_3RvE6;fs#YpZxn~rouowAz1((6icR8oWyKOx z^D5ByB6xj8O|R&NF@BM;LPCZr?7opoI*$W~4p{5|amCzVlytyUE^o5nzb|zg;%0!m=sx_ zhSDtqFj^9jH;E)(62G^b;J@gZ$!f!+Q|d}0A*LE4FK^Vx&Uu#d-ndqdVUI)~U`b@pd$kEAF+&ntiY5W|=GU`8~=J zzWsK7S&{VZK>M%N`(CDU-70*UK0s(IT9T%jRh=Pq1$JzMKBK{zpk<$0=|hL3!UxIy zsRkTqc&@DvgMkW>DxcXtt$u7;8PD)xV9s=n&g&S&tdNx|3Wbtu55SMtgY7}3PiKY% zfH4v4z(ykH&$oelmg6;+4E{bVcSCUZcT(M0cheeuzT~T%JGC4_7;-K$j@2jMX!Rp=@=AT-8b{?fOdYFeu|f z?gm&f2`N=M78K0QlsADi73<2}uW?kicXvqBWk7yY8UC1duw=*q4=yev$_JD;IL_By zcT%vLK$T`juKzy(U@c;}%*|p^k(TtNql8aNyTDo9TaKZClh3sqnc@e9uQN zRPYF~5Uj5{akX6^*cM<8%(>0RDJw?x^ZT=wD1o0T`kes744mM5K6%I^{DVo&_1E8E zuLY=*HRQ>|%52ui#Mo@n+Rs@E{0<4t6ADyizI;+ea_&SRr+M?^nKR)j!FkxBYi(_3gr31n!q!`kkEzN_(jD!MFi)0oAhx)~kW# z99P2kxZkw?Ni42UqZ_DujCHsdHOgI!d&saABsQ3Akw}94$6O15C-@q-+AcxQo;Pf*pW`!f)>QYh-}NP;&w-0pqz8{?Li0YV$i~E>x;7~KFsUtu(N48k2*Ezq6A+_TJ* za|C~O4?=2{j)2ZXUAUnJ>Z@?$M5g(hp)xBP0VVa6Y!@i~jlIThh=3!bon3VZhLu<7 zZB$-`P-zlaF%bEZR@flUsnSsqq=KOuLsgHK@Jl+sN8Js;7gZ87P5kIXswM%{+3RMl z?$(=ElJ-CI__S&aB&F5N9<5Z_g!mb4Nx)ugPFT5uG`GrBK}(-&8~QU<`-7@QcY;a? z)H$62HX&$oj0iX%kj)_Ky+g1kf$mn*NW$mt^PT?eeV)|+a*h7cnR3qvsPAkI0CARP z;W-B$309wv+w4Bs`1SwK%PTk z+%cEArlmm2QY*3#9(DOK^3h89Co_zRTZ3zaA;G9lzBb>oh4e}Ls=ex)+2pW@SwOOuZH8OVrY zTBrg;E6RlN{&W?CBm{g!vm-F`^{+o@6$^-Oz+b<6?spa>01dK%kO0sQ=65c?{L17Y zx03wrR|ekRU1kbku%BhRJaB$K1n?VfxXE1OtmUYi^l5ENzoW^3%^o#aFc1A8@%f=` zL0=-6T&PS30w12{#zX+1q{wj+0;p}C5VXQceYK%J)iBhgJEyC*9yVaR>5m+;Y+4M- zzoo%`J*q4c@HNw>D^tfrMNN|g^lk(U1fy?idqEtt2*Ol%AAM?A-eUZb};BvVSohXk89|i1zF&A;4OEjCUTEpK~V97 zPXG(S8upg`Y!*hU(9o&}a?jp=`%N45ozt$wN$vQZ(CYuNJ{xq9qIB}CsmbvA|L{BD zO;CR1oG(`3AZw6giEIq%2@thFBo>wYnS)lee_Ka5C_wqfk0w9>wu#u5S z9!9&yG6CSL?INMW4`nF)j0sxCm5>F+Fib}ZE&li%^~sl6r{2;2?duLeprXyc90Sm+ z(+3W|y+k4w`jRXBhAfPSW`r~%J;8x6<{VM+?vVu(sFAjh_fHm-;j&1=cksGX$JP&$ z{&hJDka2?V!bK`FF0<*zIPNxwq8ppn+S-?+*SpHVf(f7l@RD`~K=Hru{0pWqNFcgO zMSw6OV&lG8I|DOo5pT6>d(ejRjW`TMb0n*%W7WKt6zledeHNGq?)b;U5-@(gZej@9 z&<%)=cONBb+A{`DT2E-?3sTUhI@^2)9FB<3T{8i8Pz0ea$rt)|B!(LzX;&gJ6+so z&XH@4NfC2-)FSVH;OmxX5d6npYf3iNSs$}R^}U zg4f;9dqzj(x4-_4B}w2fZq&pW42)@C(3%f2$y+BWXWYjcWA2abFNwsp=4)`c((o-` zBR-R?RXQ_oYbWqyJ(gpda3T%O8u5g4AlxG|sz;C;Xf4C(FB5>!goVFfSK=#?@=aAg zV)&XJuy+6ft&IXoRQ(jbAPkd~&`&Ny7QDlN8`;y#bJjy&#x-;_*dYCaRVL*8+s8d_ zfo2o8wpx?Nt8wp(8&@OjFV@wMM;rRXn)b?6^(uEiYoEp?PI*PkE_?O$Ic4bCl8)g3 z!qmKf3VpiZ*K^*ktJLvs`%^_o#Q}Bj_gXnU`Q%duoO=7Mw+*z%=kwYXKvd-B*Kg6P z;)GS-=Dvp&FI@J-O?Nq5*QA$$2~tcVx3?nTt<(RHmbkD0j+xf@S~Bk0vW*-SJ6AP> z^A3m*&O2y!&WAv}t(%t1xH@2v1cME%Rr=FF3FCo)a6tkVn%dtim$5D8q*($mcWdEf zr%Oc-u3>Xyv*lk?(Hff~=^1P%)xNbotEM_=GQUS7;1X~Z?R)~`@#C?j&sbw-a?SDe zqJ{4~=_SM#e>C0E>i=BSFn}?EXv>Q)y z7vKQDR-$LC?$)rR{8arN>1vuKZ319P{8rMT`{i z(205MtvcIiTeU`06ulbDP-Dx#)Ax&+$V?bDvEW-nsFqQ*f@y{0^1YWPtGR zbIn&;fuA+=6WG%nzWj()_x^nnt!Pqo)aI(@cDE9>0{EnYkF>Rj!j`SAc8uT;JAgs( z4SV-RDY}$sZf6D?cufw>&gj@8wQ5Az9V5SUJZ9R2uLZWesjBHi1Ku043 zVMdT&ur3AH9RnNG1V2+Xx+`>q^xyyTuME_Afier|5^QhJCrojc1Khf69@qJ_(%zoZ zsvn78X5lzv-li0~$OJgojw8x8?v#;@x4Zcn2^N9zr~m$MmfU>xzWdFB+rYA|)$~BB zr8?Jhg+UTG67A;|i^IBs#lbzJ>qBDOwcE$3!X_Q8$MAu!cM=~GKi1j_6aKl}j)wPf z>0l{AjaUyiGsA?CL@+9%gTQ?Rqrp8h?GFPXhVgFXND!CG%(@TsYcki!ktAR#MZjr^ zzoi{mBur)%QrTDAfx}?jBb6jcYBaoIq3jN$A!+;gPu{J<+b)7Z}+2n#MNvC=uDbN*S(-9Uhe@fB8#GdPtnMlPe&J#Y$2h^dPsC zK~@{^W8&k93jva@%rn1z#?b5}VBDk2FS|mk+FOht&(|bi(Pu*HZhj+;x*T}x$swa1 zJPMwF{yB3EeB+Hbt@VvC)h&u$!4Z<=$5jWHc}7C7LpQg3$lvq&NNj_nxL&&`$Y4{c zo89(KeM{=PxqjR`cFkE6XglycQT;=?yY<%F)phd@TSp}1y-{TeZA$^cyBQ?C2JqCR zi;t5huQv=2SP^$N9JaJ`6P!3ABynSe#0P8!lExLgx8A9nO@a}JduP$j4l-V;E&={q zikH7UPIbsTYot;*hC;eu8d=aYXco~SdvJ)p9$~ik#mYB~7Kvd1wG*LW^BL2Q<(LkR_a(Md|~NN=7~eeam3884Lb3 zoau5cr3qZAj2Qv--?I_fRjW%Xy&}nw>?;{)kIHgOHxxh4OSNr#g$DS0?!Ctl#>flP z1pq5xs()I+@JVF}rofc`bZ!WLnU=57(^gjs6gL7n!**iGpq^4u{-QE=lz2%MB0<2! zhYfUGYPkSxCgnrxCL|eP_bG_TYw3v{xm%rsA$`W#&g2@I&jx~C;eGq9H+1g*YaQEsU?9)i@4Q24ohPhIMTe%QeCTr*NtOG)J$r3! z(E2BlAmIhi6O*4~Hac@LDv>OdWrr3!BXb*(khp>++vCD3=4jJn_RGbE7yxL72lOR>_WGL`^ zq3ql_D6i)mkiMAd&vd67@G{GsG(rvf6ue=}qjGYgZWiE^io}wX3fcJ!}9a^9S^>U>!eVB-qu;}+V@D75o&d!JchWh|YdpLY-( zBu;EDfwkH79OwoiU<0*}Dw2+kCv8$qa6=U1SWx-y(Q5~5;Xi)5$OEal@7(`0gwP@K$*XZU_c-;uy1FTag1um7+5TVJNJ?#eTCuHBP^Hr|sLXpO8awfyVD0?XB*!arH~QI%>;S_uoU9 zQY;B9yw|G*QaWEcGd#Z}@8Bse*9730Chi=s&Hh(r)k^xmM3ry1;b94I-6F}|X;0rp zxU30%K&c&!4C(55?8v)snFq)~E?@e(5>|Yt%D(MIRQ9zM z6eJ<|@fNcbJ>?#8x5?_`p3QITbt5@laLD27`4zW3>t$BRNc%^cUD5t(o7Pl^KxB6T zzWD=`1t--6MHVCpS>jSu*?Ftn-ML!cyhJ(F?KB6bngA~;Kir_Uh{jp ziGHB!gN}A~KpX*zO$J0bBL)%#s#+*>NW+1+be6 zg>L#7O5|_uni)Edf3D(0%AOBAxuzo%! z1&!$?9JH#AmO8&r)kuYgJ_CsqjDcw2vSt;aVIJImb`~pp@C0-Vm|H^;eC)ADo4fS? z2|Ub+X*tIZ#XR#y=r9ivKsGb%0l|jgMG)pbQf=P*m3z&>j}Fm+q_1D9{|&18qRTM$ zY`LPIB`f+`Bp_(|<0rxU6Zo0=vzA<_rVRYG=Q4z*3T9-j?c1xMI_+rv+fV-07z~Ut zFb=xrnv*TvvPoVUeRtAsNMFiBOQQVoY(OK#p$~gqIu`gZEW4Mv=noyFclEUp^CAhb z#9Y_O<(h;=70|6#uc2C*ru9$oN7M+W4~ZSP5t4Kgfi}631<3rt%q~rrY9)Y6RLhossDuxwO{MFPOAU5IuV^vZ|HT?dmFYZ_z%a@VYx!lV|+vw zYO$jGRpG3eLtDdyxN+@*X8`npxX@CPda@gO+z4k=mQ*j>t@ijIrsKE7uZ_7u?J~J& z78z2M_?u9uIi?>*Fx@;uen%9xwe@wAHk(>DH{IBn%aQ;PEC^myid1LD@Bf6E<9pUZ z_8B$_0Q5pe1%~=}g+FUM@3UF9x3+1~^1guXJjmn#KHqd@-j@l^ZAANg?M(VvLQn9< zlOLa_=Cg2bokv<-RO4GucEaW?h!7AWKc@b7K57JL%bMz$x$EbjpD_?uM19X~BZs4Z zNZsF#=nNlF<+aMRKw};;B*c=qUUXHUpKY1Q(uM_j>}TK6WCD!AP8JCH`2~uPphD!F zvBbrFkC`zE1YH=g0bJ*h(DnQc-T@r%R&uq2_DGV#2loh!0W1&%XSjEO#eCMEtNGQQ zsZe`HGmR0yA1>%b;HT=R-HrMl*b>eMjz0+{z3;ZI`)Wrc41`tMkqK>jOu3-1GWWmx?t3+0Utu_pFmj#h zBa^7xK+^bomC|NSB1H@{=Yk|2#Ce&_bY+W(NyHwez1FS!y|R2r(4q4}9R%DM@haO( z`Jq$;d8jCCR+}Tc+*^YzWhx{S<^HnFK#@LJa@P&*P~T*Gxku=$+=t`pm-fvIvPvf=}4n036%g8hU!Ee>ZwP?k$m^9;|?dOJF+}~ zf0>HA+KT@J41y-GGm}%1W~8$qV?dk*Z%|dTDpSZ%I#4vx2DW&hcG9M38&7(nFR5%V zC~TD$h-z>eNJs=Qp#eJpUVtPZf{)&@7G{h1*Ugmx$i~j7fNlZQIr~}zA7ce5foKLC zu)wtzEprQDjxwC(#9oxX%xJV6OHlN+J_w!0v z5?F3cTgF@~Ite5hBmqBDfgTbsx&w3*qGWHd<0B!7^28rU<01VC_XLpH*OUV1;JzIb zfNFGn@p-SUyzu#`jrDqfz|f9(K=Pwcf#V(tCCn7Sj$9{WAZ&3AJB7A?UrjLdKx|Sl znL^Uh-?Vj!a1osrt_h&Ro0P>sa^)b#2%M{c-9pb=FH!@W*rh_i3QvL;lvd0AzWbhg znj1B#j(R1$LgN$$d1kGW&FLy!GE$C1Xu;Y$`@iA z^pjU6e`@K4x~8y9;)U)%NxYEnvH5mCaZOs+`fnUUNVA)}4RKwWX6gs1Ed9lGmrf-DNG^>dXF2J(^vs=xGG zNiymoKAuU0O0!AwhbXNk$!CN``@04tl!1I>45qePQf19%W&$)Q{h?8=0i6JTp?*?b z&;dZ54)Gqk1#Dsj8!A@<4uKM(B3hG1OVT$%I0Z2us#IJ8sj8@6{cH5_TRg#9w9y3s zN0m$)-8lN-3|3cS^)?FwRI0atZM2Ype5US~f-V7lef?Y%5XPX9gZ( z=f`;hpxjEyRLxKanjo!bs$JUAoCCq&tZ68GX6NN?e>yBs?cD2t>wA%cIGME{zA!4x ztsAY6^V@E#)8*YV`)kXb=9+Y`_r+=)YTGl4Z2)M>ngaWPl{I(+&Vg%A65_VHeccwR zJUhd21Z#$`Ur_G=jIrok;em0N^8B&OCUM#}Q0LrOAGw_FAZDebA3i0H{* z{z9w&U)lTJrdcrpLfm6Nb|$bf0q6@YgJvfP^Oj z!jYgTHAS`2ACUB*?ltq)Yu3{I2QB@Hevlre#Wf=?wPqxBOD#$wL5dFEds78h01BoI z%3OE$>~Cj8W<=zU$jDQrxzc5!Zk>Bh9+SEE{`P0euBfTHj*EqKv|^ZQ7P4sY;`N?f zCbr9tg$rpkTx))4q!buOlXJnVfF|ACd|eo$`iTcJWu)~+UyUR*AkB1^&$mwc>lnmF z^0Ntc-QWZ`^CDonBy!}ORkp$WH!uDs3wQNE;Evf9x|eu}TX*rEP)0Ozor`^>HguL;_8}v`O5u$ln`p41dwc9-|o^#lN z1Dai2++7SqL~fK(*u&l^ICzdqFvKx>nV)hGbUm`8zXgW)y}}_tar+E;1h7{|2$R6< z`Z}uLLWjC2H$&`+(F6pw2EK!{-VmLN0w0_z(Gi&E6D< zdg5FNB?4g)4GkI?c>SXRgh^Fs4B6bP4@A4r%)wz2YY@aZKy#%3VohOewQ8TWxX!%QyjB$KQ?^z==+4c!zq^qUCm!SO!cCjoi zM)H=pYm6Hjid&w%r=l_$#_Cr|mj&GWRg9S6*v)s2GAf4ygb6J_@&GoyzI=1D`xDc& zLh5qi`cn4hxOkp9+Zp!iXx8!jDm%zTiE;l$KU;3O)Pt7y9Xs@o5VD}7}9CrP9zF!wdKbTVCbD`F@ z5yv30E}m%oRd%Yr56ixwLRN|Q3vxn8SrJc+SnRKU?W>w%xgBJ1i$w+Q9j_h1Q9)(} z)ENl+!Dkpej)%md5ZsP!AC6^0{n0BJR519g;XYvX3ppn~i!l3n;DLwKg>!p*)8|8w zd9{iqktp&g{6G=V1i&g8Tn?;)@VW$f340@5d(j!NbgeE7 zIhi({D}yafMYB_CX4JYe;8>oKI$k+S{Uwt1dD`rz#(3U2l!J6Ri1%3?_b8M8B{Bsb zh_Lb(xfHI;hX6+1I7{{{sBFt<1J4&g9x|~J;#Tro-)tG=5#lOqJT-P9cRn0|71b`2 z?9-ZiTbb-a6%?l&i)@kw&A(90PEE*1>~;6CUN;muOzoHgF~xU=JDu^4^F{qfv^a2753zhKmIHIJD&c{qVc{;eiz3Ry!W@{g1vig*_p9>uYx4E zp)a(9AxxOwppL-v!}G$_Ps=WK5QQ5}2ksvdLL3_u1Nc3>MjdOtD}X^#s8B!5nl4)O zfj6-;q7oUsEpc4@sp3&T#jy$Bor_{e>csnbSm1srN5F1}yWw7GJ^*dsci%S^lE?3a z_Z+`VDV2YcD2wNf=eT!o*Vuq70W%&*{Z1S|p_0l+gj)b_1soSvKjEi5F&r63u@i=|3Et7o08IWdO1&f#G3mBr_ z<~p>3%c^Uz3Sb^zc@4xpUc;33`e}FD>dO>StnG>eX?WLbWHq&ed-!uT0#dOB*iqSNw4R9nE4PElmHy&Zy z@6ye>pQE4yss;S}>Z@IrI;KJ0<4U1`#0*m*AYs7vIFB@RC>Rj=r>a_Y`M&@DE+7Wh zvH$d=AL*q1$Rm$*E{jsA2WcRho3%3OD0;1RGGAZowb>|GAgN)+f#fHx+f2@j7$#QtL$x5@HZMMF9-^K{pyeVm7Rxwyy+IOCizl%se-5dE z7mG9AH{Cb9)G-vT@D)}CRW1(Ky)^*)!xI6LLNrVF%6j2j-}<%|R1LoY4DRKdmSbQE z1;yl#N&SfUU_9q1Yy|T>jQf^G(DnHd3;VhfeFX5?g!?3p7wa%MZbGk*&&?g8O2E{P ze-CSfVn6O*qcwS6I5sf;p-4c}dr&+ZAftq$BdBP4E(dV~>9`FINU!%?z7HN>!K=5o zf{D0}EyR|}MWmp3A2joNUzQV&T=MMCEJsO!-tWg(_feJx1;k(!DQuW;QPddp%0m1) zz<>Ev@wPyeJ6sAYsIoZf(Szfl;m8%9QuAfY$t&@VE@1?{k2BYyb&k-0EHb!->6w1m zseEHHY$g-y#7?dEVv5o^isY!@ya^__SU@%JYle_SuZ@U@5D z`fAyct4WfqA21yTTUgxRSH$`7-i@zoI^DC+JfpevKsBJT#+0?Wd2ZO7TilQse)U)I zrT6Z~8sXHbZeQO8v@H5KUpx<}2N33hW5v(!x##Ojs^g$=;KD?n?8nn4k@E9m!X0x^ zNAM^xNF$s5@xWU8$dT7HWCs&Jk zT!09j^%2&wEwY@C7m@njWB7TZ$4bM%wHAdOLhpX*^Jsu9ajDHM!tud>QAA;cu||xqdb5 zn~y}*L_N{Dd{wIO-=^68h4pc=?$2|hD6_+3<}^aSZ_Ai1=3g5+Z9_59Pj(dnSf~^* z@Ij|x@bt4seerD|%kxCOWVm=F7ph7VVES-R?PJ(Z7o3zDw%QF=oc)-2`X0F8l+^IS zqf#)rd3!dQyXjlQmSg7lU9BA`r>t9wbE*AjCu&Ul_f?5;ylWRS(~sOUbfXi#RI}8a zNJwue%!o$C9{&y54?{B_+}nT>JS@{Z_PHRV#d>eOSW7Y3>q&GlsR0=hVKvB)#|q(Q zx!B@DTl$2Spmf!4%U&Ki@uxKL@N{p}8rCbpsuw$5=+_Zd{FYqk;oNwcNcaGO@A%Zm zak2=FJniG&u9O$h5=zm+W|1DT;qE6lx(l zSw9WUA>@sa&tdls*dHhkB#nrJ=0AXApi9SP_8-vKG8Fg7PRM>-A?Tx5e~EfZdn`=*&!_Vkpye;$I^-i&HZp zuH6Tv34i4%7oN9>;BlipOz-x5ms-_#uVnA%Kar@JuYb50=-@wlDqAI5PlD`udNm&$ zm$PEV6$qQgBQ!^uFywMaz-n4wCBD~^ve;oPoDiEz(0y%noKfSRHwM}FSU z_;A;3h0Ozv1Q%p5{^rOXNAEa|LGGdqm;1d z^dcBce?atu=lfM+cyp`B=3bY=4@NrTQq`vS=tu6if9LzUb4QUv;a~S5K_If>PZWFX ztP$P=qS@Pbys6s$4OJ;Dbh0xdK+Mk+cC(n|FO@{V|0rrnSRw$ZKJs_&~MG0*J zq>dCqAOn~_l{X;wlb$OvG3)#sD%r0qyw14a^P`|aYIyX6qiU^-DHO*8Wd!7QAoziV zhQ>t;){;hS{U+{>KX;eQ;d+5}45XUxy!E!S@$nwwHG&imhz^QBinux<8l!9pLBx9w zwm)7^GiyI;Yy1>`FRaXsE8Gr&$a0e=7qX*C(mPho)mQdXLZm;CSGV4{00Ba{%{VE3o>NUJi-IAz19vXl*mciaJG+ zf>F&H90iHI8sm(vLXdNC;^c8%O~B0%WKI;uuZe>lUXKGZjiMm}IgvgunxxSie?4whcJEWBBZyqDM(X3 z?V$+o115hMD&Y&}L0^;H0OUv@QaRXGPhF6jWz;iS&)n$0GIb&2l?w^as}#)VaUbMt ziTxl6G$Xpph%{GG^X#AVhB}QuXT{sr)!1dBx=}9e^Yg%-2rwben1Ka5!i+(f?8#&M zU~2K@KVNVCDL26e6g~Oj(t=HS+)z+MEHKwrB(5OW;~Gjv6RQ9VrKqGXrnJupLgczw zlnW*xiAP__mCv|H*LIR6cQG0kS8LIpT>aa<`x{_=_`zQzk}D7p5s3^ZJ&?XY@B`!z z(;x2W`&t-KprH!Dgo|kxlP_d?xRB%bRJOG?j%l^teH1cGgjge>umXj^L>+tq24}lr z*N&#;a|xe;w*ruW2ZYE76Qbb#AIN<~jalK=1ta1R&~hNS?RuXC@F=iGfo&}&RzTP= zocWF*Cs#oBDGd~wA;|H1nOdS_PZRwTG|M>7P73G4QUpqe9P(Lt?$12+lon!y@7#C) zHx>HsTm-kgO*3m%POMe8xEB*2Q*JhO2~T!3~sT#5~lFUs%W)#tEdknDcWM%Em$51)f)2=vm`(wvh2#)RaN9iIyo1M8WsVk)y zPF?_9W^_Z28cZ5%0VU~_kh6=q0A=q)liQ`D=bv490KBxYd0 zBiS$zCE@P?IVDsIAjH8<5NeL#*`VP^{`hU@&~Y+!9uNk!024hXa2y9F=T~HR{=RTI z&=B2mt0eDz<32IYxmAxFQ!!pE`!UB>BnqYOc2U8QW8w>q*i%nDp~gH|?ZD&*>leKC zAvKVBnaDXUMmyfGb#G7=nhM+}8W_A*N{*DhKN`z3@_NBtvv#>vVxk}v`}Xda*WwH1 zT0mxr_w3MZx4K(y?Op>MAGyK`VT$uj5g&^!^Eb{p-J_ar5|eq{Owh#2pIfljLOcME zu8mxEylGj8a#6ZX*~3tF)>9JT+rs}gia)hrCb1s?4S!ep)jADY5b7;QEj^z#+okK} z5?n!%`H!oHN|Q@sSy$5GS9a-kSV&s~(VWOv@Sdg`%vdO7v2zqxS1F~XK`v{mO9=bo z%~sGxgE^D4rZ$@J(Q>AOBew*liW6CFk7+ekqnSac(M8Wez=V&2ih>T`0ql~&_&##@ zh&BLw_N=S<5iY*YD+4xsN+0BSkb?oDe@&!@(1l-q`K7(qei0kmSVT-f&QcsuG(qTt z8UW!!!M!gI-syI_OD%?QiGxuROld%1#I72s1jvpdFQ7NnF&y)1WTFri-8nNZ6}|`B z0JEC6W#5kH3hn_|<~WWnr~V?nJ-iyWy>U}Q@-xrz!J3wh70IGMCa19Y@#9}A-2LQ} zk864%j^PoJ;lXr2v-z$(aaVLeqKo-S?_oZ&wvEyL|waxd05rMCw*3f%tgXv;MKt zf~gXaB%mMq+4J533Ac$$4vt*d@Z}dQo=Uw-Q|n5M`DEf|ll)ucW0}__DqWB(&Zp%) zebwm7lml{gB^vGVO$p!Bzs;$OX)W%Y{ACn4C!erW>mi*W*Y@%_Y+z&r(96mE)tb%y z|75WqamddX?w@L1nCCSWWxxG4_5sx*daEWtWO_%B3oAD!Rh5fBHN|vh=!q-rBq!k8 z8u^Nur#Ew4a^p&}eTyUiRA;YybB_d|sC`v-sPCvP>&tRMy+ZZWA*oGmW4(b3iiHLv z$q(crwz$*8_Y>;2x(o)X z84vGJnBmSySw#9exxiLVuoS5gZC3Uf57u-F!0*lExUPm z{G<5e^AEG8WVOQ9I*j~ziVEB z>l`dMrTF7@!1@O$0N#VUWR(DKg?M$-s!sq#6hB?bYNTM0}J=Px_U0d*`@ z;3<5P&F-aNS8!9FEf@1Ls3Pr}s(mHII&UcQf8&0az<~lF(W1*QWd%Sx9d^|ddJ8)E z9Mj3)_zS^V!`1M2Qrnppv1NQaDkDt&c07S#j#OV%UNtepwz%=Pr z&AGo-EpL?!=pFUCm|8Bn3APvt1)LLu=L3S>!pwXW?ZbpTh763KBFE zK(Fvw>5PsBe~nd&|LP&=)=6|+HD*N6Bg-(cHPJRS z)#vkKp&^htWLJ)C8x6ph@^>{t?>Yrn$iE(U82w&h@MVcm9L`sfAF`WL?@SeaGM5%Lz8^Pn zPuNZ5dzY>XAvShrM~mf+vd?~9CVxchej*b&cG)0sQ5K^N$ob++B&cW~6b0Ao1^(#K z55%qSu-*rzwU7zmO39g?RgWToAf@132pIjF-+ZtyhTT4v`(PsSam_e}eV)jORRkt? z_1u5qdAGf}t&=Tu>zYO=2nUV{zpwVoQn>Nl0P#b9c+Q*Igk-~;Z@F2Uh%2tR(&rrP z>8y0UratybXwfZm0e$Kn1bu@AbOXl*9|1@--+t>2Jx&n(kRJd`--|E4R7rTjdmU>O zlQ?E8u)T4V>qC~%I6e1lm&6CDC|=L|Wb(gSny~J(vE2s&)yQP=bq4P}j{C?vhus@O z6h-le^)VU?*hsSvNK&nT4z@>#+tROzG=5l&mEZ>X?t4e{-(W%o#0L@D>mfBT&c!gV z6$O}`r+W8VO{YhZw!qSTWkCzv#z1eMoong z3PcrmP3Fx1H#=`EzCc6)h~=bvR0fC;@Qafi=@_ajT&#*(?dXSGRdG7=0MDblDOhgC zJM`Gdj>~)l%LQ$6cx>#2S1A5Pt{-z5A~Ogu)ao(FX%c1gdta3Jaiu%py6+TQ;TjfH zM@1@Pz4%Aw2$U{Yuvf+8exqERk)e)o7Qo>^1FjKxwcq*nazygl?lrv#fBcP)-BF2% zeNHCvt7J+>;RM-B3mZ|<$Rq6b!8f>Cr$W-lgjQTliTv(23Bdwn_&J&bT@}a|QXMhj<2bNFxI!8s#4`tOK=|ewv9Dpx z#QOL{A|57L^%Rh11h%-?zhmDI^aHO8$a2UJxJ}kGh&~1ZQH$LoMjd$CBh1@IL(h-L z1KNSdLi6^q=;`5G0IB6&cip4PkWl=ChQF5fIdEG@pAQ-`#sGWsm?_uLqP@kGy-xmpdQ;I>i}et+cK--ur36Z`~UfJ@KQMPy|8 ze9*c?0-ci&#P;B=5fSnuD^;>>Kq;|_O!FPAi#AVWoh_#?BjTahSb;+=U{cI(mu_#R%?3Xbiq!iwm3JdnHf? z&j$tnj3zCf>b3YCdz};=04jh604TzqJ^K`5kJJ3H^VStabnH#;eOCBMhI}ldNfpP5 zq7DS(#TTDX! zzc+S@9Vf$anTyX85PpGwMN?7pL^)VAF#wV&HRQq`f1CzbV2#<6eVtT4oLfY0?E1Jz zjewk*Rk4x*BTLn$eRAyb&!ryF@oSZ>ED4x7DXp*O4##OH-{LNFBD`b45+!$Gnv})P?zN_up6mtrXso`sb<*` za_J|otHlfm*#JjH4FJO(Ol{X(bFCDA(Y5cehk2cs=IyEx5DLh(vX4J37yfreq6Td@ znElwmZAbRLcC$j>UtmLa+;=N-@RBqDxVQu64F*Ff&@M~Mi;b)hBD3yO4e^CShN&KV ze8~B_Fs7i(@7(V5?tAKJ7i%;6jw)tMEazdOF^m#<0x1mV^@EX$R>4t(fJ01 z+Z+-@9oPaNkz_aQ^Dz~JV}QvKkJAy}7RjB2`$f?TkoBPLmuX*$Effmzl{`%P_zpzA zJRaC;*4sb7!@5PKc{r9mowt1_WBW`X_!C<6;baFT18xU(15yJ4#RC!PcnYWbW72iV zj8nRk1+MALgVz{-24Liaw5dTwBHaY35F|&a4mRP#5aiLpMUU%i1mnR1p8pc`gJ zR{HU`syJXJiGyqM@WRujYhrRhKHwXM;CnjQPXUY}b` zcG+>`6=IS&=cb7tEv9g-GN4vysIe<6^qx&Ycfy0Jy3>_w2Jm27EnSe(>63N_}t=}R@Wg%X@uRg?c|dSo&3?H9MeR_r@fKU zNiB|0Q$T@%q&*b=K4`~vAwDtngQ^X393{d;Rti9FfQT%2IStt{uB>B6&iP6sW}fy*;R+HomBqlh z7YSt~XJhSP$rE{Ayu2x6CO6BSwo-6{Qy=>k5#|IejVDy81c}J!T_OzWd9Hu}J=x)6ECS}p1$f1G2!9#xA749fmdgOY^W>WyOBai&5f%79AP?AW@7>tz3pXoA z;S-S-0#W%?WN{#~9TlGeye1&q!M`3;ErOat8Xk)OGK%jdYT*C{MZr>EXP7vt#qvj= z^e%j`&V{-Hp+OHl{IGH?fU2O_2EF|hsW3G7xZOT4zk63;|0e|YNAbsJ1h79$gz#Cw z`?a;Z9q}i)pep7hC<$^*_))woXb@I3Ae$m*0V|R48EB86bTf&|Gw+aRJMT~I*V$;6 zsm`V08n$3%g=8dRTAf^2B9+JW*g^v?ocuk_pzKhEKJtBY@2OK~U{rog{(g0r4j|nE zG6G}SNH7ZYF>Rd4_R?_qtO>Bw_sU01!;3<*D3;7qWJLu2EdmrXNV4`EP3y^t#f+?g+ojH&+4iAe%-4nl-fFXgJQQ!{BeJtW=Rs)F% z7k%6ZoB`1^VD0d(82zAdU3~FHI!$7y-0kw)G{xZ~NEJsCF2q`gL4k_~RcYhUHzdo> zWCza$#UDmEM}$lXawF2~a62e1v;YgT0TUab19)A7Qem7QV35doEEnyl%H5g(azgC; zUzQ>YEk4kO>#n;&&3(|!L1l5q6DS8#B^6}6Spn(SF1xzE_|-3eAxP1i8q9?E|9)Zo z!-rveYdgFDa`DGEp0fr+9s68YBk#T2N&fIzJS3k#En|@1m#8)pDSciqxeS-plu0$v0+hp5Gdsb%(14AIRJ5@Ur7#c?DngMd1XYr zJL3T;1ipc^(d(xU9$a7h!~gPM{-gX%ETiaFArz6Yn0c@VPI?G(BMviBt}@8klQ}7& zqQ|H64)hs)qNuueX&-qj4pLNiz0155`rdMUkK~f=36%>m_xe1ZKm+1qvw@jVd}{jO zl$GD{MX4GZon2=pAqt}*BQ7c*Nebugp>WDHUk zcKnd{VK1FGx%f{MJwUUN(LrJcMF9NpFBga#dVKs_*t-|oCQQBR&bQ{F@P!r_Z~;&x zF;RlOk0RQ!`q@nrYq{6}Fc&VmUg}89iOj9n?R$@d&hc(dpBs(~6Y<&8T~H0=bx2sm zzWtL=KGx&EUib`m+;Nw_mT0IH@|V{JkI*?5atM8#SPjxh-iKqvV_;Q-0{My%3x6q) zyqfR`?*MC^2c_w_8}H+lSN5SkIRiY0rQ=|nr6(2c6I2GD5!mtm`d7cyVvWiFTaSK6 zvk5v0C+W(AvMVWD5<*XsL<4}JAb1DB+zBK_kRl&>Q&Q6T&!l(=se8dwl8f41o zls&Rv^6~F=cT^U1_eN&+zb*w$TN9)RG|_+D*!bcF5CEZXS*Ix2Aq|F^1Q=ol7S&Di z%7D-+&VW2AS70;2i7!;~&nMc(<7G2EXiQ?WgO#~{8Wv@R6-LkuE+q5hX*O-8JsIx7 zGgcbSi-Ibvsb=@Aq(0Arhsz#{6rf#b^AY;<74HcUH2g7Siv|FYKcZ>D2f*}+Lin?v z{nY)=@BDkcub5eH9bbr|?H>PtT=>7GlPp4TUX~cyT}{8cT_-_x`ST_*A^)8WtM8a> z$lSo(k5>rsvGs8LAdSNW421!vYbYWxQGfWshiXv_!W|%geBB@%3RvqRMi~kTAS`$- zU_q?ueLg05ZBettwt(Dj6@vT2%{KK|PIS2e!k>6oR2g_r;F<_>AxMUJ-8OxC;$pEj ztt1Fdfc!tgfmFIEjUoK~fBmapN%4O}AOC>|9+EiX2b4o{)*CAY*wqR(`8&3v7$x%0*>@i!wf1 zF6ItUvv;~BWn~x3=Zn{(nw)$>j1e3EP(^Xm!fGTMEc*p1+H&G)1}DIUdP79e32_gW zP&RiNMlV?^(fA;zoc?;^V5|q^71RUu*j_4xH(2*ip@_zq1S`!fGqeCTXfr}<+yS_kU zqC*kBMJ6ve)4`<=3IRye!UXOz#yJY~=DDR7n(zMRzjJ^4xBsug&-fjXxDYM7P;23( ztDWTJT>|03{~O=^P5s@cpMKi?^rw%hHvwW~?-X4*$YRiyho&J85Gpp`6}#SKXBi_c zzUXLTpcE@458~gj*9Tb0J+DC5 z5(xK^h6Pru~f~EQWvW}G$~D5s}<~t{eAJ=j?*L?O?kMDRBgiUTuKb&BI>{m&Z_Y*^=eWs zrBPPAC2ZqA7m@mc=07_pc~tCU>3_JLmk1^(Lj1E5AuwJ)D9KOU!GHL}AMUR6&hms@ zd(Jpd5TLI7lSTJw^FMa8eg6j&gw?5b+}L_f`P~mQ1WjrhTimE79i*!6S@kY?60zX2 z4bUo_?Y z#2wh~i!Lx`b7xZ~x69N^;C?9z@R)z@x#tun2e}VL9lHK0G9F-TK*C^sqtIiW@aoI2 zC>nw37Kj8wgHT{WScHsi$IAc}Y2a?9kQ0%@RgIE9p8CCeDE1vQe$lNruGN zFV$%flQz8RPYO(}mck;}LqS*GgXGhr_yV@Z-!b80zYm+^u;&ktS>D#MDZ6t~ZUB@M zxIgdc_+HBUfJo>2#Znnw`4>xJ?wky_6}pdQnMFO3$9_XvAl_RL^ML9=#Q=#Y zyan$6<^%eT)eqjIkOb*U0a}B;TVG!k|4yQK)IBTB%X`w`;khAv3CIPSPBeKqo?P*)WCw4xtdlt&$+KiM$8s) z&Bi{?vfbp$Nt;i=wtxRr>R0JPVOavVcm(8cb^iRh^}J**Hb>@bAIIV5d%D`b+&>D6 z)gIaPB6$vQ_Rs(IXTk?~Lng9&q`+OP1z#Hg-9Nj2QeKOE*&h@s81DXOKmCa|0!Wra zVGQfLjvY5hifAe}BxD9n$F`Twg*8KouxD{0 zLemo_^bjYE|3>%`?1?`Sb;Cy=brb)wW5@JKfERItkQVP0?LLkNsf3z4;HR13R2bgv zAnTiR(avv=DO;2DLR{SP#a@zqyfRullQ@z+IFfXNxi1AZ3`+<;`PnV*T2up4|x z0XA@;l21k{1ATii(d$`&`AiPA^@U* z#|W7LRB`U3R?q&K1-lY@P`FIrDX<%}ifQC2w7 zRA<1(&^wA>GIFS0)h#gouySTuOL>!!8ce$pO2$UYJ?Fu;l*IjV??hFz>C7C!T3P#1 zie+r~n;-_(9+atVaxknl^DcGNe9(kjL#g10W33PwtBXlXI^een2nfPdjfo!yEP!M` zk|K!74{-JsGMNERW*=e82_bM>?+fmL_lS|u`x4*+d)UW*@?-a#zxi7Vjf3>Ixp_t} z_&p40Frq?H?udl@^uO^txIYlao_+2aK__05N%0DuLJu6cMpp)xUUHdg(ARwA??CJA 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

    *OAB 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 Hu0mjfb6k4_ literal 0 HcmV?d00001 diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs new file mode 100644 index 0000000..e244023 --- /dev/null +++ b/desktop/src-tauri/src/lib.rs @@ -0,0 +1,810 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + io::Write, + net::{SocketAddr, TcpListener, TcpStream}, + path::{Path, PathBuf}, + process::{Child, Command, Output, Stdio}, + sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use atomic_write_file::AtomicWriteFile; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tauri::{AppHandle, Manager, RunEvent}; +use tauri_plugin_shell::{ + ShellExt, + process::{Command as ShellCommand, CommandChild, CommandEvent}, +}; +use wait_timeout::ChildExt; + +const RUNTIME_MANIFEST_BYTES: &[u8] = include_bytes!("../../runtime-manifest.json"); + +#[derive(Clone, Deserialize, Serialize)] +struct CapabilitySpec { + extra: String, + modality: String, + label: String, +} + +#[derive(Clone, Deserialize, Serialize)] +struct MediaRuntimeSpec { + strategy: String, + executables: Vec, + reason: String, +} + +#[derive(Clone, Deserialize, Serialize)] +struct RuntimeManifest { + schema_version: u32, + desktop_version: String, + package_name: String, + package_version: String, + package_index: String, + python_version: String, + uv_version: String, + always_install_extras: Vec, + capabilities: BTreeMap, + media_runtime: MediaRuntimeSpec, +} + +#[derive(Deserialize)] +struct InstallRequest { + capabilities: Vec, + prepare_models: bool, +} + +#[derive(Serialize)] +struct InstallResult { + package_version: String, + capabilities: Vec, + prepared: bool, +} + +#[derive(Serialize)] +struct RuntimeStatus { + ready: bool, + package_version: String, + capabilities: Vec, + detail: String, +} + +#[derive(Clone, Deserialize, Serialize)] +struct ActiveRuntime { + schema_version: u32, + manifest_sha256: String, + profile: String, + package_version: String, + capabilities: Vec, +} + +struct DesktopPaths { + data: PathBuf, + cache: PathBuf, + repository: PathBuf, + runtimes: PathBuf, + python: PathBuf, + models: PathBuf, + active_runtime: PathBuf, +} + +struct DesktopState { + ui_process: Mutex>, + operation_process: Mutex>, + operation_worker_runtime: Mutex>, + operation_active: AtomicBool, + shutdown_started: AtomicBool, +} + +impl Default for DesktopState { + fn default() -> Self { + Self { + ui_process: Mutex::new(None), + operation_process: Mutex::new(None), + operation_worker_runtime: Mutex::new(None), + operation_active: AtomicBool::new(false), + shutdown_started: AtomicBool::new(false), + } + } +} + +struct OperationGuard<'a> { + active: &'a AtomicBool, +} + +impl Drop for OperationGuard<'_> { + fn drop(&mut self) { + self.active.store(false, Ordering::Release); + } +} + +fn manifest() -> Result { + serde_json::from_slice(RUNTIME_MANIFEST_BYTES) + .map_err(|error| format!("The embedded runtime manifest is invalid: {error}")) +} + +fn manifest_digest() -> String { + hex::encode(Sha256::digest(RUNTIME_MANIFEST_BYTES)) +} + +fn desktop_paths(app: &AppHandle) -> Result { + let data = app + .path() + .app_local_data_dir() + .map_err(|error| format!("Could not resolve the application data directory: {error}"))?; + let cache = app + .path() + .app_cache_dir() + .map_err(|error| format!("Could not resolve the application cache directory: {error}"))?; + Ok(DesktopPaths { + repository: data.join("repositories").join("default"), + runtimes: data.join("runtimes"), + python: data.join("python"), + active_runtime: data.join("active-runtime.json"), + models: cache.join("models"), + data, + cache, + }) +} + +fn selected_capabilities( + manifest: &RuntimeManifest, + requested: &[String], +) -> Result, String> { + let selected: BTreeSet<_> = requested.iter().cloned().collect(); + if selected.is_empty() { + return Err("Select at least one capability.".into()); + } + if let Some(unknown) = selected + .iter() + .find(|name| !manifest.capabilities.contains_key(*name)) + { + return Err(format!("Unknown desktop capability: {unknown}")); + } + Ok(selected.into_iter().collect()) +} + +fn package_specification(manifest: &RuntimeManifest, capabilities: &[String]) -> String { + let extras: BTreeSet<_> = manifest + .always_install_extras + .iter() + .cloned() + .chain( + capabilities + .iter() + .map(|name| manifest.capabilities[name].extra.clone()), + ) + .collect(); + format!( + "{}[{}]=={}", + manifest.package_name, + extras.into_iter().collect::>().join(","), + manifest.package_version + ) +} + +fn capability_command_arguments( + manifest: &RuntimeManifest, + operation: &str, + capabilities: &[String], +) -> Vec { + let modalities = capabilities + .iter() + .map(|name| manifest.capabilities[name].modality.as_str()) + .collect::>() + .join(","); + vec![ + operation.into(), + "--json".into(), + "--modalities".into(), + modalities, + ] +} + +fn executable(runtime: &Path, name: &str) -> PathBuf { + if cfg!(windows) { + runtime.join("Scripts").join(format!("{name}.exe")) + } else { + runtime.join("bin").join(name) + } +} + +fn clean_environment(paths: &DesktopPaths) -> Vec<(String, String)> { + let mut environment: Vec<_> = std::env::vars() + .filter(|(key, _)| { + let upper = key.to_ascii_uppercase(); + !upper.starts_with("VIDXP_") + && !upper.starts_with("DBOS_") + && !upper.starts_with("UV_") + && !upper.starts_with("STREAMLIT_") + && !upper.starts_with("PYTHON") + && !upper.starts_with("PYENV_") + && !upper.starts_with("PIP_") + && !upper.starts_with("CONDA") + && upper != "VIRTUAL_ENV" + && upper != "VIRTUAL_ENV_PROMPT" + && upper != "_OLD_VIRTUAL_PATH" + }) + .collect(); + environment.extend([ + ( + "VIDXP_MODEL_CACHE".into(), + paths.models.to_string_lossy().into_owned(), + ), + ("VIDXP_ALLOW_MODEL_DOWNLOADS".into(), "true".into()), + ("STREAMLIT_SERVER_HEADLESS".into(), "true".into()), + ( + "STREAMLIT_BROWSER_GATHER_USAGE_STATS".into(), + "false".into(), + ), + ]); + environment +} + +fn configured_command(executable_path: &Path, paths: &DesktopPaths) -> Command { + let mut command = Command::new(executable_path); + command.env_clear(); + command.envs(clean_environment(paths)); + command +} + +fn checked_output(mut command: Command, operation: &str) -> Result { + let output = command + .output() + .map_err(|error| format!("{operation} could not start: {error}"))?; + if output.status.success() { + return Ok(output); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + let detail = if stderr.is_empty() { stdout } else { stderr }; + Err(format!("{operation} failed: {detail}")) +} + +fn active_runtime(paths: &DesktopPaths) -> Result { + let contents = fs::read(&paths.active_runtime) + .map_err(|_| "VidXP has not been installed by this desktop app.".to_string())?; + let active: ActiveRuntime = serde_json::from_slice(&contents) + .map_err(|error| format!("The active runtime pointer is invalid: {error}"))?; + if active.schema_version != 1 || active.manifest_sha256 != manifest_digest() { + return Err("The desktop runtime needs to be installed for this app version.".into()); + } + if !active + .profile + .chars() + .all(|character| character.is_ascii_hexdigit() || character == '-') + { + return Err("The active runtime profile identity is invalid.".into()); + } + Ok(active) +} + +fn runtime_directory(paths: &DesktopPaths, active: &ActiveRuntime) -> PathBuf { + paths.runtimes.join(&active.profile) +} + +fn write_active_runtime(paths: &DesktopPaths, active: &ActiveRuntime) -> Result<(), String> { + let mut destination = AtomicWriteFile::options() + .open(&paths.active_runtime) + .map_err(|error| format!("Could not stage the active runtime pointer: {error}"))?; + serde_json::to_writer(&mut destination, active) + .map_err(|error| format!("Could not serialize the active runtime pointer: {error}"))?; + destination + .flush() + .and_then(|_| destination.commit()) + .map_err(|error| format!("Could not activate the validated runtime: {error}")) +} + +async fn supervised_output( + state: &DesktopState, + command: ShellCommand, + operation: &str, +) -> Result<(Vec, Vec), String> { + if state.shutdown_started.load(Ordering::Acquire) { + return Err(format!( + "{operation} was cancelled because VidXP is closing." + )); + } + let (mut events, child) = command + .spawn() + .map_err(|error| format!("{operation} could not start: {error}"))?; + { + let mut active_child = state + .operation_process + .lock() + .map_err(|_| "The setup process supervisor is unavailable.".to_string())?; + if active_child.is_some() { + drop(active_child); + let _ = child.kill(); + return Err("Another desktop setup process is already running.".into()); + } + *active_child = Some(child); + } + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut exit_code = None; + while let Some(event) = events.recv().await { + match event { + CommandEvent::Stdout(bytes) => stdout.extend(bytes), + CommandEvent::Stderr(bytes) => stderr.extend(bytes), + CommandEvent::Error(error) => stderr.extend(error.as_bytes()), + CommandEvent::Terminated(status) => { + exit_code = status.code; + break; + } + _ => {} + } + } + if let Ok(mut active_child) = state.operation_process.lock() { + active_child.take(); + } + if exit_code == Some(0) { + return Ok((stdout, stderr)); + } + let detail = String::from_utf8_lossy(&stderr).trim().to_owned(); + Err(format!( + "{operation} failed{}: {detail}", + exit_code.map_or_else(String::new, |code| format!(" with exit code {code}")) + )) +} + +async fn uv_output( + app: &AppHandle, + state: &DesktopState, + paths: &DesktopPaths, + arguments: Vec, + operation: &str, +) -> Result<(), String> { + let mut command = app + .shell() + .sidecar("uv") + .map_err(|error| format!("The bundled uv sidecar is unavailable: {error}"))? + .args(arguments) + .env_clear(); + for (key, value) in clean_environment(paths) { + command = command.env(key, value); + } + command = command + .env("UV_CACHE_DIR", paths.cache.join("uv")) + .env("UV_PYTHON_INSTALL_DIR", &paths.python) + .env("UV_NO_CONFIG", "1") + .env("UV_MANAGED_PYTHON", "1"); + supervised_output(state, command, operation).await?; + Ok(()) +} + +fn run_vidxp( + runtime: &Path, + paths: &DesktopPaths, + arguments: &[String], + operation: &str, +) -> Result { + let mut command = configured_command(&executable(runtime, "vidxp"), paths); + command + .arg("--index-dir") + .arg(&paths.repository) + .args(arguments); + checked_output(command, operation) +} + +async fn run_vidxp_supervised( + app: &AppHandle, + state: &DesktopState, + runtime: &Path, + paths: &DesktopPaths, + arguments: &[String], + operation: &str, +) -> Result<(), String> { + let mut command = app + .shell() + .command(executable(runtime, "vidxp")) + .env_clear(); + for (key, value) in clean_environment(paths) { + command = command.env(key, value); + } + command = command + .arg("--index-dir") + .arg(&paths.repository) + .args(arguments); + supervised_output(state, command, operation).await?; + Ok(()) +} + +#[tauri::command] +fn runtime_manifest() -> Result { + manifest() +} + +#[tauri::command] +fn runtime_status(app: AppHandle) -> Result { + let manifest = manifest()?; + let paths = desktop_paths(&app)?; + let active = match active_runtime(&paths) { + Ok(active) => active, + Err(detail) => { + return Ok(RuntimeStatus { + ready: false, + package_version: manifest.package_version, + capabilities: Vec::new(), + detail, + }); + } + }; + let runtime = runtime_directory(&paths, &active); + let version = run_vidxp( + &runtime, + &paths, + &["--version".into()], + "VidXP runtime validation", + ) + .and_then(|output| { + let actual = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + let expected = format!("VidXP {}", manifest.package_version); + if actual == expected { + Ok(output) + } else { + Err(format!( + "The active runtime reported {actual:?}; expected {expected:?}." + )) + } + }); + Ok(RuntimeStatus { + ready: version.is_ok(), + package_version: active.package_version, + capabilities: active.capabilities, + detail: version + .err() + .unwrap_or_else(|| "The desktop runtime is ready.".into()), + }) +} + +#[tauri::command] +async fn install_runtime( + app: AppHandle, + state: tauri::State<'_, DesktopState>, + request: InstallRequest, +) -> Result { + if state + .operation_active + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err("Another install or model-preparation operation is active.".into()); + } + let _operation_guard = OperationGuard { + active: &state.operation_active, + }; + let manifest = manifest()?; + let capabilities = selected_capabilities(&manifest, &request.capabilities)?; + let paths = desktop_paths(&app)?; + for directory in [ + &paths.data, + &paths.cache, + &paths.repository, + &paths.runtimes, + &paths.python, + &paths.models, + ] { + fs::create_dir_all(directory) + .map_err(|error| format!("Could not create {}: {error}", directory.display()))?; + } + + let profile_seed = format!( + "{}:{}:{}:{}", + manifest_digest(), + std::env::consts::OS, + std::env::consts::ARCH, + capabilities.join(",") + ); + let profile_hash = hex::encode(Sha256::digest(profile_seed.as_bytes())); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("The system clock is invalid: {error}"))? + .as_secs(); + let staging_name = format!(".staging-{profile_hash}-{timestamp}-{}", std::process::id()); + let staging = paths.runtimes.join(&staging_name); + fs::create_dir(&staging) + .map_err(|error| format!("Could not create the staged runtime: {error}"))?; + + let install_result = async { + uv_output( + &app, + &state, + &paths, + vec![ + "venv".into(), + staging.to_string_lossy().into_owned(), + "--python".into(), + manifest.python_version.clone(), + "--managed-python".into(), + "--no-config".into(), + ], + "Managed Python setup", + ) + .await?; + + let mut install_arguments = vec![ + "pip".into(), + "install".into(), + "--python".into(), + executable(&staging, "python") + .to_string_lossy() + .into_owned(), + "--no-config".into(), + "--default-index".into(), + manifest.package_index.clone(), + "--index-strategy".into(), + "first-index".into(), + ]; + if !cfg!(target_os = "macos") { + install_arguments.extend(["--torch-backend".into(), "cpu".into()]); + } + install_arguments.push(package_specification(&manifest, &capabilities)); + uv_output( + &app, + &state, + &paths, + install_arguments, + "VidXP package installation", + ) + .await?; + + let doctor_arguments = capability_command_arguments(&manifest, "doctor", &capabilities); + run_vidxp_supervised( + &app, + &state, + &staging, + &paths, + &doctor_arguments, + "VidXP dependency validation", + ) + .await?; + + if request.prepare_models { + let prepare_arguments = + capability_command_arguments(&manifest, "prepare", &capabilities); + *state + .operation_worker_runtime + .lock() + .map_err(|_| "The preparation worker supervisor is unavailable.")? = + Some(staging.clone()); + let preparation = run_vidxp_supervised( + &app, + &state, + &staging, + &paths, + &prepare_arguments, + "VidXP model preparation", + ) + .await; + let _ = run_vidxp( + &staging, + &paths, + &["jobs".into(), "stop-worker".into()], + "VidXP preparation worker shutdown", + ); + if let Ok(mut worker_runtime) = state.operation_worker_runtime.lock() { + worker_runtime.take(); + } + preparation?; + } + + Ok::<(), String>(()) + } + .await; + if let Err(error) = install_result { + return Err(format!( + "{error}. The previous active runtime was not changed; staged files remain at {} for diagnosis.", + staging.display() + )); + } + + let profile = format!("{profile_hash}-{timestamp}"); + let runtime = paths.runtimes.join(&profile); + fs::rename(&staging, &runtime) + .map_err(|error| format!("Could not finalize the validated runtime: {error}"))?; + let active = ActiveRuntime { + schema_version: 1, + manifest_sha256: manifest_digest(), + profile, + package_version: manifest.package_version.clone(), + capabilities: capabilities.clone(), + }; + write_active_runtime(&paths, &active)?; + + Ok(InstallResult { + package_version: manifest.package_version, + capabilities, + prepared: request.prepare_models, + }) +} + +#[tauri::command] +fn launch_ui(app: AppHandle, state: tauri::State<'_, DesktopState>) -> Result { + let paths = desktop_paths(&app)?; + let active = active_runtime(&paths)?; + let runtime = runtime_directory(&paths, &active); + let mut active_process = state + .ui_process + .lock() + .map_err(|_| "The desktop process supervisor is unavailable.".to_string())?; + if let Some(process) = active_process.as_mut() { + if process + .try_wait() + .map_err(|error| format!("Could not inspect the interface process: {error}"))? + .is_none() + { + return Err("The VidXP interface is already running.".into()); + } + *active_process = None; + } + + let listener = TcpListener::bind(("127.0.0.1", 0)) + .map_err(|error| format!("Could not reserve a local interface port: {error}"))?; + let port = listener + .local_addr() + .map_err(|error| format!("Could not identify the local interface port: {error}"))? + .port(); + drop(listener); + + let mut command = configured_command(&executable(&runtime, "vidxp"), &paths); + command + .arg("--index-dir") + .arg(&paths.repository) + .args(["ui", "--host", "127.0.0.1", "--port", &port.to_string()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut process = command + .spawn() + .map_err(|error| format!("Could not start the VidXP interface: {error}"))?; + + let address = SocketAddr::from(([127, 0, 0, 1], port)); + let deadline = Instant::now() + Duration::from_secs(30); + while Instant::now() < deadline { + if let Some(status) = process + .try_wait() + .map_err(|error| format!("Could not inspect the interface process: {error}"))? + { + return Err(format!( + "The VidXP interface exited during startup ({status})." + )); + } + if TcpStream::connect_timeout(&address, Duration::from_millis(100)).is_ok() { + *active_process = Some(process); + return Ok(format!("http://127.0.0.1:{port}")); + } + thread::sleep(Duration::from_millis(100)); + } + let _ = process.kill(); + let _ = process.wait(); + Err("The VidXP interface did not become ready in 30 seconds.".into()) +} + +fn shutdown(app: &AppHandle) { + let state = app.state::(); + if let Ok(mut active_operation) = state.operation_process.lock() + && let Some(process) = active_operation.take() + { + let _ = process.kill(); + } + if let Ok(mut active_process) = state.ui_process.lock() + && let Some(mut process) = active_process.take() + { + let _ = process.kill(); + match process.wait_timeout(Duration::from_secs(5)) { + Ok(Some(_)) => {} + _ => { + let _ = process.kill(); + let _ = process.wait_timeout(Duration::from_secs(1)); + } + } + } + let Ok(paths) = desktop_paths(app) else { + return; + }; + if let Ok(mut operation_worker) = state.operation_worker_runtime.lock() + && let Some(runtime) = operation_worker.take() + { + let _ = run_vidxp( + &runtime, + &paths, + &["jobs".into(), "stop-worker".into()], + "VidXP preparation worker shutdown", + ); + } + let Ok(active) = active_runtime(&paths) else { + return; + }; + let runtime = runtime_directory(&paths, &active); + let _ = run_vidxp( + &runtime, + &paths, + &["jobs".into(), "stop-worker".into()], + "VidXP worker shutdown", + ); +} + +pub fn run() { + let builder = tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, _, _| { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + })) + .plugin(tauri_plugin_log::Builder::new().build()) + .plugin(tauri_plugin_shell::init()) + .manage(DesktopState::default()) + .invoke_handler(tauri::generate_handler![ + runtime_manifest, + runtime_status, + install_runtime, + launch_ui + ]); + let app = builder + .build(tauri::generate_context!()) + .expect("could not initialize VidXP desktop"); + app.run(|app_handle, event| { + if let RunEvent::ExitRequested { api, .. } = event + && app_handle + .state::() + .shutdown_started + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + api.prevent_exit(); + let cleanup_handle = app_handle.clone(); + std::thread::spawn(move || { + shutdown(&cleanup_handle); + cleanup_handle.exit(0); + }); + } + }); +} + +#[cfg(test)] +mod tests { + use super::{ + capability_command_arguments, manifest, package_specification, selected_capabilities, + }; + + #[test] + fn capability_selection_is_sorted_and_rejects_unknown_values() { + let manifest = manifest().expect("manifest"); + let selected = selected_capabilities( + &manifest, + &["scene".into(), "dialogue".into(), "scene".into()], + ) + .expect("selection"); + + assert_eq!(selected, ["dialogue", "scene"]); + assert!(selected_capabilities(&manifest, &["other".into()]).is_err()); + } + + #[test] + fn package_specification_has_one_sorted_extra_set() { + let manifest = manifest().expect("manifest"); + + assert_eq!( + package_specification(&manifest, &["scene".into(), "dialogue".into()]), + "vidxp[dialogue,frontend,scene]==0.2.1b1" + ); + } + + #[test] + fn capability_commands_pass_one_comma_separated_option() { + let manifest = manifest().expect("manifest"); + + assert_eq!( + capability_command_arguments(&manifest, "doctor", &["dialogue".into(), "scene".into()]), + ["doctor", "--json", "--modalities", "dialogue,scene"] + ); + } +} diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs new file mode 100644 index 0000000..655f4a8 --- /dev/null +++ b/desktop/src-tauri/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + vidxp_desktop_lib::run(); +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json new file mode 100644 index 0000000..7d31112 --- /dev/null +++ b/desktop/src-tauri/tauri.conf.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "VidXP", + "version": "0.2.1-beta.1", + "identifier": "dev.grayhat.vidxp", + "build": { + "frontendDist": "../web" + }, + "app": { + "withGlobalTauri": true, + "security": { + "csp": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src ipc: http://ipc.localhost" + }, + "windows": [ + { + "label": "main", + "title": "VidXP", + "width": 920, + "height": 760, + "minWidth": 680, + "minHeight": 600, + "resizable": true + } + ] + }, + "bundle": { + "active": true, + "targets": "all", + "externalBin": [ + "binaries/uv" + ], + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "windows": { + "nsis": { + "installMode": "currentUser" + } + }, + "macOS": { + "minimumSystemVersion": "13.0" + } + } +} diff --git a/desktop/web/app.js b/desktop/web/app.js new file mode 100644 index 0000000..8a9d157 --- /dev/null +++ b/desktop/web/app.js @@ -0,0 +1,110 @@ +const invoke = window.__TAURI__.core.invoke; + +const capabilitiesNode = document.querySelector("#capabilities"); +const installButton = document.querySelector("#install"); +const launchButton = document.querySelector("#launch"); +const prepareModels = document.querySelector("#prepare-models"); +const statusNode = document.querySelector("#status"); + +let manifest; + +function setBusy(busy) { + installButton.disabled = busy; + launchButton.disabled = busy || launchButton.dataset.ready !== "true"; +} + +function selectedCapabilities() { + return [...document.querySelectorAll("[data-capability]:checked")].map( + (node) => node.dataset.capability, + ); +} + +function renderCapabilities() { + capabilitiesNode.replaceChildren(); + Object.entries(manifest.capabilities).forEach(([id, capability]) => { + const label = document.createElement("label"); + label.className = "capability"; + + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = true; + input.dataset.capability = id; + + const copy = document.createElement("span"); + const name = document.createElement("strong"); + name.textContent = capability.label; + const detail = document.createElement("small"); + detail.textContent = `Installs the ${capability.extra} capability`; + copy.append(name, detail); + label.append(input, copy); + capabilitiesNode.append(label); + }); +} + +async function refreshStatus() { + const status = await invoke("runtime_status"); + if (status.ready) { + document.querySelectorAll("[data-capability]").forEach((node) => { + node.checked = status.capabilities.includes(node.dataset.capability); + }); + } + launchButton.dataset.ready = String(status.ready); + launchButton.disabled = !status.ready; + statusNode.textContent = status.ready + ? `Ready · VidXP ${status.package_version} · ${status.capabilities.join(", ")}` + : status.detail; +} + +installButton.addEventListener("click", async () => { + const capabilities = selectedCapabilities(); + if (capabilities.length === 0) { + statusNode.textContent = "Select at least one capability."; + return; + } + + setBusy(true); + statusNode.textContent = prepareModels.checked + ? "Installing and preparing models. Large downloads can take a while…" + : "Installing an isolated VidXP runtime…"; + try { + const result = await invoke("install_runtime", { + request: { + capabilities, + prepare_models: prepareModels.checked, + }, + }); + statusNode.textContent = result.prepared + ? "Installed, validated, and models prepared." + : "Installed and validated."; + await refreshStatus(); + } catch (error) { + statusNode.textContent = `Setup failed: ${error}`; + } finally { + setBusy(false); + } +}); + +launchButton.addEventListener("click", async () => { + setBusy(true); + statusNode.textContent = "Starting the local interface…"; + try { + const url = await invoke("launch_ui"); + window.location.replace(url); + } catch (error) { + statusNode.textContent = `Launch failed: ${error}`; + setBusy(false); + } +}); + +async function start() { + try { + manifest = await invoke("runtime_manifest"); + renderCapabilities(); + await refreshStatus(); + } catch (error) { + statusNode.textContent = `Desktop initialization failed: ${error}`; + setBusy(true); + } +} + +start(); diff --git a/desktop/web/index.html b/desktop/web/index.html new file mode 100644 index 0000000..d2f7df7 --- /dev/null +++ b/desktop/web/index.html @@ -0,0 +1,53 @@ + + + + + + + VidXP Setup + + + +

    +
    +

    VIDXP DESKTOP

    +

    Set up local video search.

    +

    + Choose only the capabilities you need. VidXP installs into an isolated + application runtime and keeps your media and indexes in your user data. +

    +
    + +
    +

    Capabilities

    +
    + +
    + +
    +
    +

    STATUS

    +

    Checking the local runtime…

    +
    +
    + + +
    +
    + +

    + The first setup needs internet access. FFmpeg and ffprobe must currently + be available on this machine; setup verifies both before activation. +

    +
    + + + diff --git a/desktop/web/styles.css b/desktop/web/styles.css new file mode 100644 index 0000000..9d2336e --- /dev/null +++ b/desktop/web/styles.css @@ -0,0 +1,181 @@ +:root { + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + color: #eceef3; + background: #0d1016; + font-synthesis: none; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at 80% 0%, rgba(86, 100, 255, 0.2), transparent 36rem), + #0d1016; +} + +main { + width: min(48rem, calc(100% - 3rem)); + margin: 0 auto; + padding: 4rem 0; +} + +.hero { + margin-bottom: 2rem; +} + +.eyebrow { + margin: 0 0 0.55rem; + color: #9da8ff; + font-size: 0.72rem; + font-weight: 750; + letter-spacing: 0.14em; +} + +h1, +h2, +p { + margin-top: 0; +} + +h1 { + max-width: 13ch; + margin-bottom: 1rem; + font-size: clamp(2.4rem, 7vw, 4.5rem); + line-height: 0.98; + letter-spacing: -0.055em; +} + +h2 { + font-size: 1rem; +} + +.lede, +.footnote, +small { + color: #abb1be; +} + +.lede { + max-width: 39rem; + font-size: 1.05rem; + line-height: 1.65; +} + +.panel { + margin-top: 1rem; + padding: 1.35rem; + border: 1px solid #282e3b; + border-radius: 1rem; + background: rgba(21, 25, 34, 0.9); +} + +.capabilities { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; +} + +.capability, +.prepare-option { + display: flex; + gap: 0.7rem; + align-items: flex-start; + cursor: pointer; +} + +.capability { + min-height: 5rem; + padding: 0.9rem; + border: 1px solid #303746; + border-radius: 0.75rem; + background: #171c26; +} + +.capability:has(input:checked) { + border-color: #7d88ff; + background: #1d2340; +} + +input { + margin-top: 0.2rem; + accent-color: #7d88ff; +} + +.capability span, +.prepare-option span { + display: grid; + gap: 0.3rem; +} + +.prepare-option { + margin-top: 1.1rem; + padding-top: 1.1rem; + border-top: 1px solid #282e3b; +} + +.status-panel, +.actions { + display: flex; + gap: 1rem; + align-items: center; + justify-content: space-between; +} + +.status-panel p:last-child { + margin-bottom: 0; +} + +.actions { + flex-shrink: 0; +} + +button { + min-height: 2.7rem; + padding: 0 1rem; + border: 0; + border-radius: 0.65rem; + color: #0d1016; + background: #a9b0ff; + font: inherit; + font-weight: 720; + cursor: pointer; +} + +button.secondary { + color: #eceef3; + background: #303746; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.footnote { + margin: 1rem 0 0; + font-size: 0.78rem; + line-height: 1.5; +} + +@media (max-width: 42rem) { + main { + width: min(100% - 2rem, 48rem); + padding: 2rem 0; + } + + .capabilities { + grid-template-columns: 1fr; + } + + .status-panel, + .actions { + align-items: stretch; + flex-direction: column; + } +} diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md index c9eb938..b85a93e 100644 --- a/docs/architecture/platform.md +++ b/docs/architecture/platform.md @@ -954,7 +954,20 @@ displays model preparation progress, and detaches from durable work or requests cooperative cancellation during shutdown. Closing the UI never waits on an uncancellable model thread inside the UI process. -Packaging technology is selected only after the application boundary is stable. +The Phase 11 adapter is a small Tauri v2 shell. Its bundled setup page selects +capability extras, while the actual application is the exact published VidXP +package installed into a versioned uv-managed environment. The existing Streamlit +adapter remains the local human interface on a random loopback port; remote +loopback content receives no Tauri IPC access. Runtime activation is atomic and a +failed installation retains the prior environment. Tauri owns the Streamlit +process and asks the repository-scoped worker to drain and shut down on exit. + +The first desktop release is an online bootstrap with no tray or updater. It +targets Windows x86-64 NSIS, Apple Silicon DMG, and Linux x86-64 AppImage. +Target-specific uv binaries are release inputs verified against the pinned +upstream checksum. FFmpeg/ffprobe remain validated system dependencies until +target build provenance, codec selection, and redistribution licenses are +recorded; the application does not silently fetch an unaudited build. ### 21.1 Operating modes diff --git a/docs/desktop.md b/docs/desktop.md new file mode 100644 index 0000000..54577e0 --- /dev/null +++ b/docs/desktop.md @@ -0,0 +1,71 @@ +# Desktop application + +The desktop application is a thin Tauri v2 installer and process supervisor. It +does not contain a second VidXP implementation: + +- the selected capability extras are installed from the exact PyPI release; +- the existing Streamlit adapter remains the local human interface; +- the repository, model cache, managed Python, and package environments use + platform application-data directories; +- the existing DBOS worker remains the durable execution boundary; and +- closing the desktop process stops both Streamlit and the repository worker. + +The first release is an online bootstrap. It bundles `uv`, installs the exact +Python and VidXP versions in `desktop/runtime-manifest.json`, and activates a new +runtime only after `vidxp doctor` passes. A failed setup never replaces the +previous active runtime. + +## Build locally + +From `desktop/`: + +```powershell +npm install +npm run sidecar:windows +npm run desktop:dev +``` + +On macOS Apple Silicon or Linux x86-64: + +```bash +npm install +npm run sidecar:unix +npm run desktop:dev +``` + +The sidecar scripts download the pinned official `uv` release, verify its +checked-in SHA-256 archive digest from `desktop/sidecars.json`, and place the +target-suffixed binary where Tauri expects it. The Rust build also executes the +target sidecar and rejects a version mismatch. Generated binaries and build +outputs are ignored by Git. + +## Installation behavior + +Users select dialogue, scene, and actor capabilities independently. The +installer always adds the `frontend` extra and uses a single sorted extra set. +Windows and Linux resolve CPU-only PyTorch wheels using uv's +`--torch-backend cpu`; macOS uses native PyPI wheels. The custom PyTorch index +is therefore a resolver input and is not embedded as a package URL, avoiding +the prior package-publication failure mode. + +Optional model preparation invokes the shared `vidxp prepare` command for only +the selected modalities. Setup subprocesses are owned by the Tauri supervisor; +closing the app cancels the active process and stops a preparation worker before +exit. No model is bundled in the installer. + +FFmpeg and ffprobe are currently validated as system dependencies. Target +binaries will not be bundled until the exact build provenance, enabled codecs, +and redistribution licenses are recorded. This is an explicit packaging gate, +not a silent download from an unaudited third party. + +## Release targets + +The initial target matrix is: + +- Windows x86-64: per-user NSIS installer; +- macOS Apple Silicon: signed and notarized DMG; and +- Linux x86-64: AppImage built on the oldest supported glibc baseline. + +Updater integration, tray behavior, in-app playback, CUDA installers, and true +offline installers remain deferred until signing and binary provenance are in +place. diff --git a/src/vidxp/infrastructure/local_worker.py b/src/vidxp/infrastructure/local_worker.py index d15522d..a22bfc8 100644 --- a/src/vidxp/infrastructure/local_worker.py +++ b/src/vidxp/infrastructure/local_worker.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import signal import subprocess import sys from pathlib import Path @@ -12,12 +11,14 @@ from filelock import FileLock, Timeout from pydantic import ValidationError +from vidxp.core.manifest import write_json_atomic from vidxp.infrastructure.local_files import durable_unlink from vidxp.settings import VidXPSettings from vidxp.workflow_runtime import ( LOCAL_WORKER_BOOTSTRAP_ENV, LocalWorkerBootstrap, LocalWorkerReady, + LocalWorkerStopRequest, local_worker_bootstrap, local_executor_id, workflow_application_version, @@ -60,6 +61,9 @@ def _ensure_running(self) -> None: ready_path = ( self.layout.local_workflows / f"worker-{version}.ready" ) + stop_path = ( + self.layout.local_workflows / f"worker-{version}.stop" + ) start_lock = FileLock( self.layout.local_workflows / f"worker-{version}-start.lock" ) @@ -73,6 +77,7 @@ def _ensure_running(self) -> None: ): return durable_unlink(ready_path, missing_ok=True) + durable_unlink(stop_path, missing_ok=True) command = [ sys.executable, @@ -86,6 +91,8 @@ def _ensure_running(self) -> None: str(worker_lock_path.resolve()), "--ready-file", str(ready_path.resolve()), + "--stop-file", + str(stop_path.resolve()), "--log-file", str(self._worker_log(version).resolve()), ] @@ -192,6 +199,9 @@ def stop(self) -> bool: ready_path = ( self.layout.local_workflows / f"worker-{version}.ready" ) + stop_path = ( + self.layout.local_workflows / f"worker-{version}.stop" + ) try: worker_lock.acquire(timeout=0) except Timeout: @@ -200,8 +210,14 @@ def stop(self) -> bool: raise RuntimeError( "The running local worker has an invalid version identity." ) - os.kill(ready.pid, signal.SIGTERM) - deadline = monotonic() + 5 + write_json_atomic( + stop_path, + LocalWorkerStopRequest( + pid=ready.pid, + application_version=version, + ).model_dump(mode="json"), + ) + deadline = monotonic() + 35 while monotonic() < deadline: try: worker_lock.acquire(timeout=0) @@ -211,6 +227,7 @@ def stop(self) -> bool: else: worker_lock.release() durable_unlink(ready_path, missing_ok=True) + durable_unlink(stop_path, missing_ok=True) return True raise RuntimeError( "The local background worker did not stop in time." @@ -218,6 +235,7 @@ def stop(self) -> bool: else: worker_lock.release() durable_unlink(ready_path, missing_ok=True) + durable_unlink(stop_path, missing_ok=True) return False @staticmethod diff --git a/src/vidxp/workflow_runtime.py b/src/vidxp/workflow_runtime.py index 75f18ee..a02208d 100644 --- a/src/vidxp/workflow_runtime.py +++ b/src/vidxp/workflow_runtime.py @@ -42,6 +42,15 @@ class LocalWorkerReady(BaseModel): fingerprint: str = Field(pattern=r"^[a-f0-9]{64}$") +class LocalWorkerStopRequest(BaseModel): + """Repository-local request for the supervised worker to shut down.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + pid: int = Field(gt=0) + application_version: str = Field(min_length=1) + + def workflow_database_url(settings: VidXPSettings) -> str: if settings.database_url is not None: database_url = settings.database_url diff --git a/src/vidxp/workflow_worker.py b/src/vidxp/workflow_worker.py index f30d978..f5110bd 100644 --- a/src/vidxp/workflow_worker.py +++ b/src/vidxp/workflow_worker.py @@ -3,6 +3,7 @@ import argparse import logging import os +import signal from logging.handlers import RotatingFileHandler from pathlib import Path from threading import Event @@ -22,6 +23,7 @@ LOCAL_WORKER_BOOTSTRAP_ENV, LocalWorkerBootstrap, LocalWorkerReady, + LocalWorkerStopRequest, local_worker_bootstrap, server_executor_id, workflow_application_version, @@ -40,6 +42,7 @@ def _arguments(values: Sequence[str] | None = None) -> argparse.Namespace: parser.add_argument("--ordinal", type=int, default=0) parser.add_argument("--lock-file", type=Path) parser.add_argument("--ready-file", type=Path) + parser.add_argument("--stop-file", type=Path) parser.add_argument("--log-file", type=Path) return parser.parse_args(values) @@ -93,6 +96,8 @@ def run_worker( executor_id: str, role: str, ready: Callable[[], None] | None = None, + stop_event: Event | None = None, + stop_file: Path | None = None, ) -> None: config: DBOSConfig = { "name": APPLICATION_NAME, @@ -106,23 +111,40 @@ def run_worker( ), } DBOS(config=config) - - VidXPWorkerWorkflows(create_application(settings)) - roles = ("cpu", "gpu") if role == "all" else (role,) - queue_names = [ - QUEUE_NAMES[queue_role] - for queue_role in (JobQueue(value) for value in roles) - ] - DBOS.listen_queues(queue_names) - DBOS.launch() - for queue_name in queue_names: - DBOS.register_queue( - queue_name, - worker_concurrency=1, - ) - if ready is not None: - ready() - Event().wait() + try: + VidXPWorkerWorkflows(create_application(settings)) + roles = ("cpu", "gpu") if role == "all" else (role,) + queue_names = [ + QUEUE_NAMES[queue_role] + for queue_role in (JobQueue(value) for value in roles) + ] + DBOS.listen_queues(queue_names) + active_stop_event = stop_event or Event() + DBOS.launch() + for queue_name in queue_names: + DBOS.register_queue( + queue_name, + worker_concurrency=1, + ) + if ready is not None: + ready() + while not active_stop_event.wait(0.25): + if stop_file is None or not stop_file.is_file(): + continue + try: + request = LocalWorkerStopRequest.model_validate_json( + stop_file.read_text(encoding="utf-8") + ) + except (OSError, ValueError): + continue + if ( + request.pid == os.getpid() + and request.application_version + == workflow_application_version() + ): + break + finally: + DBOS.destroy(workflow_completion_timeout_sec=30) def main(arguments: Sequence[str] | None = None) -> None: @@ -160,12 +182,18 @@ def main(arguments: Sequence[str] | None = None) -> None: ) if options.lock_file is None: - run_worker( - settings=settings, - database_url=database_url, - executor_id=executor_id, - role=role, - ) + stop_event = Event() + previous_handlers = _install_shutdown_handlers(stop_event) + try: + run_worker( + settings=settings, + database_url=database_url, + executor_id=executor_id, + role=role, + stop_event=stop_event, + ) + finally: + _restore_shutdown_handlers(previous_handlers) return lock = FileLock(options.lock_file) @@ -188,18 +216,56 @@ def mark_ready() -> None: ).model_dump(mode="json"), ) - with lock: - try: - run_worker( - settings=settings, - database_url=database_url, - executor_id=executor_id, - role=role, - ready=mark_ready, - ) - finally: - if ready_file is not None: - durable_unlink(ready_file, missing_ok=True) + stop_event = Event() + previous_handlers = _install_shutdown_handlers(stop_event) + try: + with lock: + try: + run_worker( + settings=settings, + database_url=database_url, + executor_id=executor_id, + role=role, + ready=mark_ready, + stop_event=stop_event, + stop_file=options.stop_file, + ) + finally: + if ready_file is not None: + durable_unlink(ready_file, missing_ok=True) + if options.stop_file is not None: + durable_unlink(options.stop_file, missing_ok=True) + finally: + _restore_shutdown_handlers(previous_handlers) + + +def _install_shutdown_handlers( + stop_event: Event, +) -> dict[signal.Signals, signal.Handlers]: + previous_handlers = {} + + def request_shutdown( + _signal_number: int, + _frame: object, + ) -> None: + stop_event.set() + + for signal_name in ("SIGINT", "SIGTERM", "SIGBREAK"): + shutdown_signal = getattr(signal, signal_name, None) + if shutdown_signal is None: + continue + previous_handlers[shutdown_signal] = signal.signal( + shutdown_signal, + request_shutdown, + ) + return previous_handlers + + +def _restore_shutdown_handlers( + previous_handlers: dict[signal.Signals, signal.Handlers], +) -> None: + for shutdown_signal, previous_handler in previous_handlers.items(): + signal.signal(shutdown_signal, previous_handler) if __name__ == "__main__": diff --git a/tests/test_local_worker.py b/tests/test_local_worker.py index 12a0a26..d618635 100644 --- a/tests/test_local_worker.py +++ b/tests/test_local_worker.py @@ -5,6 +5,7 @@ import unittest from pathlib import Path from tempfile import TemporaryDirectory +from threading import Event from time import sleep from unittest.mock import Mock, patch @@ -19,11 +20,12 @@ from vidxp.infrastructure.local_worker import LocalWorkerSupervisor from vidxp.job_service import JobService from vidxp.settings import LocalExecutionSettings, VidXPSettings -from vidxp.workflow_worker import _configure_logging +from vidxp.workflow_worker import _configure_logging, run_worker from vidxp.workflow_runtime import ( LOCAL_WORKER_BOOTSTRAP_ENV, LocalWorkerBootstrap, LocalWorkerReady, + LocalWorkerStopRequest, local_worker_bootstrap, local_executor_id, workflow_application_version, @@ -318,16 +320,41 @@ def test_stop_terminates_only_ready_lock_owner(self): return_value=worker_lock, ), patch( - "vidxp.infrastructure.local_worker.os.kill" - ) as kill, + "vidxp.infrastructure.local_worker.write_json_atomic" + ) as write_stop, ): stopped = supervisor.stop() self.assertTrue(stopped) - kill.assert_called_once() - self.assertEqual(kill.call_args.args[0], 123) + write_stop.assert_called_once() + self.assertEqual(write_stop.call_args.args[1]["pid"], 123) + self.assertEqual( + write_stop.call_args.args[1]["application_version"], + workflow_application_version(), + ) self.assertFalse(ready_path.exists()) + def test_worker_destroys_dbos_after_stop_request(self): + stop_event = Event() + stop_event.set() + + with ( + patch("vidxp.workflow_worker.DBOS") as dbos, + patch("vidxp.workflow_worker.VidXPWorkerWorkflows"), + patch("vidxp.workflow_worker.create_application"), + ): + run_worker( + settings=VidXPSettings(runtime_backend="cpu"), + database_url="sqlite:///test.db", + executor_id="test", + role="cpu", + stop_event=stop_event, + ) + + dbos.destroy.assert_called_once_with( + workflow_completion_timeout_sec=30 + ) + def test_worker_uses_live_rotating_log_handler(self): with TemporaryDirectory() as directory: log_path = Path(directory) / "worker.log" @@ -361,6 +388,7 @@ def test_fresh_database_runs_a_job_in_a_separate_worker_process(self): database_url = workflow_database_url(settings) ready_path = settings.layout.local_workflows / "test.ready" lock_path = settings.layout.local_workflows / "test.lock" + stop_path = settings.layout.local_workflows / "test.stop" supervisor = LocalWorkerSupervisor(settings) bootstrap = local_worker_bootstrap(settings) bootstrap_path = supervisor._write_bootstrap(bootstrap) @@ -383,6 +411,8 @@ def test_fresh_database_runs_a_job_in_a_separate_worker_process(self): str(lock_path), "--ready-file", str(ready_path), + "--stop-file", + str(stop_path), "--log-file", str(settings.layout.local_workflows / "test.log"), ], @@ -419,9 +449,15 @@ def test_fresh_database_runs_a_job_in_a_separate_worker_process(self): finally: if backend is not None: backend.close() - process.terminate() + stop_path.write_text( + LocalWorkerStopRequest( + pid=process.pid, + application_version=workflow_application_version(), + ).model_dump_json(), + encoding="utf-8", + ) try: - process.wait(timeout=5) + process.wait(timeout=35) except subprocess.TimeoutExpired: process.kill() process.wait(timeout=5) diff --git a/tests/test_packaging.py b/tests/test_packaging.py index b069e7b..04e8356 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -1,8 +1,10 @@ +import json import unittest from pathlib import Path import tomllib from packaging.requirements import Requirement +from packaging.version import Version from vidxp.capabilities.registry import create_capability_registry @@ -181,6 +183,65 @@ def test_optional_ollama_profile_never_pulls_a_model_implicitly(self): ) self.assertNotIn("ollama pull", compose.lower()) + def test_desktop_manifest_matches_published_package_contract(self): + project = tomllib.loads( + (ROOT / "pyproject.toml").read_text(encoding="utf-8") + ) + manifest = json.loads( + (ROOT / "desktop" / "runtime-manifest.json").read_text( + encoding="utf-8" + ) + ) + package = json.loads( + (ROOT / "desktop" / "package.json").read_text(encoding="utf-8") + ) + tauri = json.loads( + ( + ROOT + / "desktop" + / "src-tauri" + / "tauri.conf.json" + ).read_text(encoding="utf-8") + ) + + self.assertEqual(manifest["package_name"], project["project"]["name"]) + self.assertEqual( + Version(manifest["package_version"]), + Version(project["project"]["version"]), + ) + self.assertEqual(package["version"], manifest["desktop_version"]) + self.assertEqual(tauri["version"], manifest["desktop_version"]) + self.assertEqual( + Version(manifest["desktop_version"]), + Version(manifest["package_version"]), + ) + self.assertEqual(manifest["python_version"], "3.14.6") + self.assertEqual(manifest["uv_version"], "0.12.0") + sidecars = json.loads( + (ROOT / "desktop" / "sidecars.json").read_text(encoding="utf-8") + ) + self.assertEqual(sidecars["uv_version"], manifest["uv_version"]) + self.assertEqual( + set(sidecars["targets"]), + { + "x86_64-pc-windows-msvc", + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + }, + ) + for target in sidecars["targets"].values(): + self.assertRegex(target["sha256"], r"^[a-f0-9]{64}$") + self.assertEqual(manifest["always_install_extras"], ["frontend"]) + dynamic_extras = project["tool"]["setuptools"]["dynamic"][ + "optional-dependencies" + ] + for capability in manifest["capabilities"].values(): + self.assertIn(capability["extra"], dynamic_extras) + self.assertEqual( + manifest["media_runtime"]["strategy"], + "system", + ) + if __name__ == "__main__": unittest.main() From b600164e798a451eb37d924b427b6689314b7f42 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 13:21:39 +0500 Subject: [PATCH 14/46] docs: evaluate gpu worker compatibility --- changes/gpu-evaluation.documentation.md | 4 + docs/architecture/platform.md | 8 ++ docs/deployment/gpu-evaluation.md | 136 ++++++++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 changes/gpu-evaluation.documentation.md create mode 100644 docs/deployment/gpu-evaluation.md diff --git a/changes/gpu-evaluation.documentation.md b/changes/gpu-evaluation.documentation.md new file mode 100644 index 0000000..e0ad24e --- /dev/null +++ b/changes/gpu-evaluation.documentation.md @@ -0,0 +1,4 @@ +### GPU worker evaluation + +- Recorded the supported CUDA 12.6 dependency intersection, capability routing, + readiness contract, and validation gates without pulling images or models. diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md index b85a93e..977807f 100644 --- a/docs/architecture/platform.md +++ b/docs/architecture/platform.md @@ -1348,6 +1348,14 @@ the operation grammar. - pinned model smoke tests - resource limits +The 2026-07-29 evaluation selects PyTorch 2.13 `cu126` as the first compatible +NVIDIA baseline. CUDA 12.8 has no Torch 2.13 distribution, while CUDA 13 does +not share current CTranslate2/faster-whisper's CUDA 12 runtime contract. The +full worker set resolves on Python 3.14 to Torch 2.13.0, CTranslate2 4.8.1, +faster-whisper 1.2.1, and cuDNN 9.10.2. Implementation remains deferred until a +CUDA host and prepared model cache are available; no model or image was pulled +during the evaluation. See `docs/deployment/gpu-evaluation.md`. + Gate: GPU readiness and execution are explicit; no silent CPU fallback. Clean-image dependency/SBOM checks, CPU-without-CUDA proof, selected scene and transcription provider smoke tests, CPU actor routing, OOM handling, cancellation, diff --git a/docs/deployment/gpu-evaluation.md b/docs/deployment/gpu-evaluation.md new file mode 100644 index 0000000..c0e5f19 --- /dev/null +++ b/docs/deployment/gpu-evaluation.md @@ -0,0 +1,136 @@ +# GPU worker evaluation + +Status: evaluated on 2026-07-29; implementation and model execution are +intentionally deferred. + +No model, CUDA image, or Python wheel was pulled for this evaluation. Dependency +checks used registry metadata only. + +## Decision + +The first NVIDIA worker should use the supported CUDA 12.6 line: + +| Component | Evaluated version or policy | Decision | +| --- | --- | --- | +| Python | 3.14.6 | Keep the project-wide runtime | +| PyTorch | 2.13.0 `cu126` | Accept; latest stable Torch with its supported CUDA 12 fallback | +| CTranslate2 | 4.8.1 | Accept; current wheel supports CUDA 12 | +| faster-whisper | 1.2.1 | Accept with CUDA 12 cuBLAS and cuDNN 9 | +| cuDNN | 9.10.2.21 from the resolved `cu126` stack | Accept | +| Actor models | OpenCV CPU | Keep CPU-only; do not route through CUDA | +| Ollama | Separate optional service | Do not couple to the indexing worker GPU | + +CUDA 12.8 is rejected because the registry has no PyTorch 2.13 `cu128` +distribution. CUDA 12.9 resolved during metadata probing, but PyTorch 2.13's +published support direction retains CUDA 12.6 and moves the default to CUDA +13. CUDA 13 is rejected for the first worker because current CTranslate2 and +faster-whisper require the CUDA 12 family. Using 12.6 is a justified +compatibility exception, not an old default retained without review. + +The metadata-only resolver command was: + +```text +uv pip compile pyproject.toml + --extra server-worker + --no-sources + --python-version 3.14 + --python-platform x86_64-manylinux_2_28 + --torch-backend cu126 + --default-index https://pypi.org/simple + --index-strategy first-index +``` + +The exact implementation lock must be generated from `pyproject.toml` with the +`server-worker` extra and committed separately from the CPU `uv.lock`. +`--no-sources` is mandatory: without it, the current `tool.uv.sources` CPU +override wins and produces `torch+cpu` even when `--torch-backend cu126` is +present. `tool.uv.sources` must remain CPU-only for ordinary development and +publishing; the CUDA index is a release-resolver input, never a direct package +URL. + +## Capability routing + +| Capability operation | Device policy | Precision gate | +| --- | --- | --- | +| Scene SigLIP2 embedding/search | `cuda:` | Start with the current float32 contract; evaluate lower precision separately | +| Dialogue Qwen3 embedding/search | `cuda:` | Use bfloat16 only when `torch.cuda.is_bf16_supported()` passes; otherwise use an explicitly tested fallback | +| Dialogue transcription | CUDA device and index through faster-whisper | float16 | +| Actor detection/recognition and overlays | CPU | float32 | +| Media import and snippet extraction | CPU | Not applicable | +| SLM query planning/synthesis | External Ollama endpoint or deterministic fallback | Owned by the Ollama deployment | + +The existing durable queue split is directionally correct: CUDA repositories +submit model jobs to the GPU queue, while actor overlays, media import, and +snippets remain CPU jobs. An index job can still execute actor work on the GPU +worker process, but `ModelRuntime.device_for("actor")` keeps that operation on +CPU. + +## Required implementation boundary + +The GPU worker should remain a supplemental deployment, not alter the default +CPU/Coolify stack: + +1. Generate a target-specific GPU requirements lock using uv's `cu126` + resolver backend. +2. Add a dedicated worker image target based on the existing Python 3.14 + runtime. The resolved NVIDIA Python libraries supply CUDA 12.6/cuDNN 9; + configure their library directories for CTranslate2 before Python starts. +3. Add `compose.gpu.yaml` with one explicit `device_ids` entry and + `capabilities: [gpu]`. Do not expose all host GPUs implicitly. +4. Run only `vidxp-worker --role gpu` in that service and set + `VIDXP_RUNTIME_BACKEND=cuda:0` inside the container's visible-device + namespace. +5. Keep the CPU worker for CPU-queue jobs. Do not replace it with the GPU + worker. + +The host requires the NVIDIA Container Toolkit and a driver compatible with the +selected CUDA 12 runtime. Release qualification should use the CUDA 12.6 +toolkit driver floor rather than relying only on CUDA 12 minor-version +compatibility. + +## Readiness gaps to close + +Current worker health checks only verify PostgreSQL and Chroma. A GPU worker +must additionally fail readiness unless all of these are true: + +- the requested backend is exactly `cuda:`; +- `torch.backends.cuda.is_built()` and `torch.cuda.is_available()` are true; +- the selected index exists; +- the device capability appears in `torch.cuda.get_arch_list()`; +- the resolved PyTorch build reports CUDA 12.6; +- CTranslate2 reports at least one CUDA device; +- device name, capability, total memory, Torch version, CUDA version, cuDNN + version, and selected precision policy are exposed in structured readiness. + +`auto` must continue to select CPU. There must be no silent CPU fallback after +an explicit CUDA request. + +## Validation required before enablement + +No performance comparison is needed to enable the first functional GPU worker. +The release gate is correctness and failure behavior: + +- build the locked image without CPU Torch or CUDA 13 packages; +- start on a clean NVIDIA host and prove explicit device isolation; +- run dependency/readiness checks without model downloads; +- with already prepared model assets, smoke one scene index/search and one + dialogue transcription/search; +- verify actor processing remains on CPU; +- compare CPU and CUDA result shape, provenance, and retrieval tolerances; +- translate PyTorch OOM into a typed resource-limit failure; +- verify cancellation, worker restart, and DBOS recovery after OOM; +- record peak device memory and wall time without claiming an effectiveness + improvement. + +Blackwell, multi-GPU scheduling, MIG, ROCm, CUDA desktop installers, lower +precision tuning, and Ollama GPU sharing remain separate evaluations. + +## Current blockers + +Implementation must not begin until: + +- the target GPU/driver matrix is selected for CI; +- the separate CUDA lock format and update workflow are agreed; +- a CUDA host is available for readiness and no-model dependency validation; +- model assets are already cached for the two functional smokes; and +- CUDA/cuDNN redistribution notices and the generated image SBOM are reviewed. From 51a3be9db97cc0c2cdd621b0626cb7e9b279279e Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 13:51:50 +0500 Subject: [PATCH 15/46] fix: close release smoke gaps --- changes/coolify-shutdown.bugfix.md | 2 + changes/large-local-import.feature.md | 2 + changes/local-runtime-smoke.bugfix.md | 3 + changes/mcp-discovery.feature.md | 2 + compose.coolify.yaml | 2 + docs/architecture/platform.md | 33 ++++---- docs/deployment/coolify.md | 6 ++ src/vidxp/api_cli.py | 17 +++- src/vidxp/cli_commands/runtime.py | 18 +++-- src/vidxp/frontend.py | 95 ++++++++++++++++++++++- src/vidxp/infrastructure/local_worker.py | 6 +- src/vidxp/mcp.py | 98 +++++++++++++++++++++++- src/vidxp/settings.py | 6 ++ tests/test_api_cli.py | 20 +++++ tests/test_cli.py | 21 +++++ tests/test_frontend.py | 37 +++++++++ tests/test_local_worker.py | 5 ++ tests/test_mcp.py | 81 +++++++++++++++++++- tests/test_settings.py | 18 +++++ 19 files changed, 441 insertions(+), 31 deletions(-) create mode 100644 changes/coolify-shutdown.bugfix.md create mode 100644 changes/large-local-import.feature.md create mode 100644 changes/local-runtime-smoke.bugfix.md create mode 100644 changes/mcp-discovery.feature.md create mode 100644 tests/test_api_cli.py create mode 100644 tests/test_settings.py diff --git a/changes/coolify-shutdown.bugfix.md b/changes/coolify-shutdown.bugfix.md new file mode 100644 index 0000000..ec8d90b --- /dev/null +++ b/changes/coolify-shutdown.bugfix.md @@ -0,0 +1,2 @@ +Give DBOS workers and tusd enough Compose shutdown grace time to complete their +configured drains, and document the one-repository-per-stack storage boundary. diff --git a/changes/large-local-import.feature.md b/changes/large-local-import.feature.md new file mode 100644 index 0000000..854c3ea --- /dev/null +++ b/changes/large-local-import.feature.md @@ -0,0 +1,2 @@ +Add a local-path video import control so desktop and local UI users can bypass +the browser upload limit for large media. diff --git a/changes/local-runtime-smoke.bugfix.md b/changes/local-runtime-smoke.bugfix.md new file mode 100644 index 0000000..dcfed34 --- /dev/null +++ b/changes/local-runtime-smoke.bugfix.md @@ -0,0 +1,3 @@ +Allow the detached local worker enough time to initialize, make API help +side-effect free, preserve repeated runtime capability options, and expose +cataloged videos in a fresh UI session. diff --git a/changes/mcp-discovery.feature.md b/changes/mcp-discovery.feature.md new file mode 100644 index 0000000..5ef70d7 --- /dev/null +++ b/changes/mcp-discovery.feature.md @@ -0,0 +1,2 @@ +Let MCP clients discover registered media and durable jobs, inspect media +metadata, and retry failed work through the shared application services. diff --git a/compose.coolify.yaml b/compose.coolify.yaml index 2e1015a..b13b2b5 100644 --- a/compose.coolify.yaml +++ b/compose.coolify.yaml @@ -151,6 +151,7 @@ services: condition: service_completed_successfully chroma-ready: condition: service_completed_successfully + stop_grace_period: 40s restart: unless-stopped ollama: @@ -195,6 +196,7 @@ services: depends_on: hooks: condition: service_healthy + stop_grace_period: 35s healthcheck: test: - CMD diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md index 977807f..97b06b0 100644 --- a/docs/architecture/platform.md +++ b/docs/architecture/platform.md @@ -875,17 +875,21 @@ Initial curated tools: - `list_capabilities` - `get_capability` +- `list_media` +- `get_media` - `get_index_status` - `start_indexing` - `search_moments` +- `query_video` +- `list_jobs` - `get_job` +- `retry_job` - `cancel_job` -Generic job polling/cancellation covers both indexing and search without -duplicating tool contracts. Actor inspection is added only after its existing -index-read logic is extracted behind a model-free application service; MCP must -not construct `ModelRuntime` merely to inspect clusters. `query_video` is added -when its Phase 9 application service exists. +Media and job discovery let an agent recover registered assets and durable work +without carrying IDs across sessions. Generic job polling, retry, and cancellation +cover indexing, search, and query without duplicating operation contracts. Video +bytes remain on the HTTP/tus ingestion boundary rather than crossing MCP. Tool results use real output schemas and structured content. Descriptions remain short and agent-oriented; full API response schemas are not embedded as prose. @@ -977,20 +981,19 @@ The same command and result contracts support three composition profiles: with a local worker, DBOS SQLite, embedded Chroma, and a platform app-data repository. Media can be imported from allowlisted local paths. This is the default Apple Silicon experience and does not require Docker. -2. **Remote client:** a thin CLI, UI, desktop, or agent connects to a self-hosted - VidXP deployment. It contains no indexing models or local vector database. - Media crosses the boundary through the resumable upload flow and is subsequently - referenced by `media_id`. Human-facing clients use the HTTP API; agents use the - remote MCP Streamable HTTP endpoint. +2. **Remote client (planned):** a thin CLI, UI, or desktop adapter will connect + to a self-hosted VidXP deployment without local models or a vector database. + The current release rejects `VIDXP_MODE=remote` instead of silently composing + local storage. Remote agents are supported now through the Streamable HTTP MCP + endpoint, and other clients can use the authenticated HTTP/tus APIs directly. 3. **Self-hosted server:** API/MCP, workers, workflow state, media ingestion, and vector search run as the Coolify stack. The same server also supports ordinary Compose outside Coolify. -Client configuration is limited to server base URL, authentication, repository, -timeouts, and local upload source. Selecting remote mode changes composition, not -business behavior. A client transport implements the same client-facing command -interface as the native application facade; it does not duplicate validation, -indexing, search, or artifact policy. +The planned client configuration is limited to server base URL, authentication, +repository, timeouts, and local upload source. Its transport must implement the +same client-facing command interface as the native application facade without +duplicating validation, indexing, search, or artifact policy. The distributable Python package has a lightweight client/core installation and explicit local/server extras. Installing the thin client must not install PyTorch, diff --git a/docs/deployment/coolify.md b/docs/deployment/coolify.md index 6829998..c94c296 100644 --- a/docs/deployment/coolify.md +++ b/docs/deployment/coolify.md @@ -84,3 +84,9 @@ hooks, worker, and tusd should report healthy; Chroma is checked by the complete This profile is intentionally single-node. Its named content, upload, model, and Chroma volumes are not a multi-replica storage design. Back up the PostgreSQL and named data volumes before replacing a release. + +Treat each deployed stack as one repository boundary. The current PostgreSQL +catalog tables and Chroma collection names are deployment-scoped rather than +repository-namespaced, so multiple repository IDs must not share the same +PostgreSQL database and Chroma service. Deploy another stack (and volumes) for a +separate repository. diff --git a/src/vidxp/api_cli.py b/src/vidxp/api_cli.py index d298b7d..a67c315 100644 --- a/src/vidxp/api_cli.py +++ b/src/vidxp/api_cli.py @@ -1,7 +1,22 @@ from __future__ import annotations +import argparse +from collections.abc import Sequence + + +def _arguments(values: Sequence[str] | None = None) -> None: + parser = argparse.ArgumentParser( + description=( + "Run the VidXP HTTP API and remote MCP server. " + "Configure the service with VIDXP_* environment variables." + ) + ) + parser.parse_args(values) + + +def main(arguments: Sequence[str] | None = None) -> None: + _arguments(arguments) -def main() -> None: import uvicorn from vidxp.api import create_app diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py index 564e048..962dec2 100644 --- a/src/vidxp/cli_commands/runtime.py +++ b/src/vidxp/cli_commands/runtime.py @@ -22,11 +22,14 @@ def doctor( ctx: typer.Context, modalities: Annotated[ - str | None, + list[str] | None, typer.Option( "--modalities", "-m", - help="Only validate dependencies for these modalities.", + help=( + "Only validate dependencies for these modalities. " + "Accepts comma-separated values or repeated options." + ), ), ] = None, json_output: Annotated[ @@ -43,7 +46,7 @@ def doctor( selected = ( available if modalities is None - else parse_modalities(modalities, available) + else parse_modalities(",".join(modalities), available) ) result = state.service.check_dependencies( DependencyCheckCommand(modalities=selected) @@ -82,11 +85,14 @@ def doctor( def prepare( ctx: typer.Context, modalities: Annotated[ - str | None, + list[str] | None, typer.Option( "--modalities", "-m", - help="Only prepare models for these modalities.", + help=( + "Only prepare models for these modalities. " + "Accepts comma-separated values or repeated options." + ), ), ] = None, capability_options: Annotated[ @@ -122,7 +128,7 @@ def prepare( selected = ( preparable if modalities is None - else parse_modalities(modalities, preparable) + else parse_modalities(",".join(modalities), preparable) ) job = state.jobs.submit_prepare_models( PrepareModelsCommand( diff --git a/src/vidxp/frontend.py b/src/vidxp/frontend.py index e430fa4..0ee8c80 100644 --- a/src/vidxp/frontend.py +++ b/src/vidxp/frontend.py @@ -18,6 +18,7 @@ ErrorCategory, ImportMediaCommand, JobState, + ListMediaCommand, QueryVideoCommand, SearchCommand, ) @@ -64,6 +65,7 @@ def _settings_from_arguments( CANCEL_REQUESTED_KEY = "_vidxp_cancel_requested" MEDIA_ID_KEY = "_vidxp_media_id" UPLOAD_TOKEN_KEY = "_vidxp_upload_token" +MEDIA_NOTICE_KEY = "_vidxp_media_notice" INDEX_JOB_QUERY_PARAM = "index_job" SEARCH_JOB_QUERY_PARAM = "search_job" SEARCH_TYPE_QUERY_PARAM = "search_type" @@ -547,9 +549,82 @@ def poll_search_job(): ) -def _select_video(busy, media_id): +def _default_media_id(media_id, assets): + available_ids = {asset.media_id for asset in assets} + if media_id in available_ids: + return media_id + if len(assets) == 1: + return assets[0].media_id + return None + + +def _import_local_video(service, raw_path): + return service.import_media( + ImportMediaCommand(path=Path(raw_path.strip()).expanduser()) + ) + + +def _select_video(busy, media_id, media_page): service = _configured_service() st.subheader("Video") + assets = tuple(media_page.items) if media_page is not None else () + media_id = _default_media_id(media_id, assets) + if media_id is not None: + st.session_state[MEDIA_ID_KEY] = media_id + else: + st.session_state.pop(MEDIA_ID_KEY, None) + + if not busy and assets: + asset_by_id = {asset.media_id: asset for asset in assets} + selected_media_id = st.selectbox( + "Registered video", + tuple(asset_by_id), + index=( + tuple(asset_by_id).index(media_id) + if media_id is not None + else 0 + ), + format_func=lambda value: ( + f"{asset_by_id[value].original_filename} " + f"({asset_by_id[value].duration_seconds:.1f}s)" + ), + ) + if selected_media_id != media_id: + st.session_state.pop(SEARCH_RESULT_KEY, None) + media_id = selected_media_id + st.session_state[MEDIA_ID_KEY] = media_id + if media_page.next_cursor is not None: + st.caption( + "Showing the first 100 registered videos. " + "Use the CLI or API to inspect the full catalog." + ) + + with st.expander("Import a large local video"): + st.caption( + "Use a local path to avoid sending large files through the " + "browser uploader." + ) + local_path = st.text_input( + "Local video path", + disabled=busy, + placeholder="C:\\Videos\\example.mp4 or /Users/me/Videos/example.mp4", + ) + if st.button( + "Register local video", + disabled=busy or not local_path.strip(), + ): + try: + imported = _import_local_video(service, local_path) + except ApplicationError as exc: + st.error(str(exc)) + else: + st.session_state[MEDIA_ID_KEY] = imported.media_id + st.session_state.pop(SEARCH_RESULT_KEY, None) + st.session_state[MEDIA_NOTICE_KEY] = ( + f"Registered {imported.original_filename}." + ) + st.rerun() + upload_slot = st.empty() has_session_upload = st.session_state.get("video_upload") is not None if busy and not has_session_upload and media_id is not None: @@ -575,7 +650,7 @@ def _select_video(busy, media_id): if not busy: st.caption("Using the registered video.") st.video(str(resource.path), width=560) - return uploaded_video + return uploaded_video, media_id def _search_controls(ready, uploaded_video, available_modalities): @@ -627,6 +702,8 @@ def run(): st.title("VidXP") st.caption("Index and search video by dialogue, scene, and actor.") st.caption(f"Index repository: {service.layout.root}") + if notice := st.session_state.pop(MEDIA_NOTICE_KEY, None): + st.success(notice) jobs = _configured_jobs() _restore_durable_jobs() @@ -669,6 +746,14 @@ def run(): if len(indexed_media) == 1: media_id = indexed_media[0] st.session_state[MEDIA_ID_KEY] = media_id + try: + media_page = service.list_media(ListMediaCommand(page_size=100)) + except ApplicationError: + media_page = None + st.warning( + "Registered videos are temporarily unavailable. " + "The current index remains usable." + ) installed_modalities = _available_index_modalities() video_column, workflow_column = st.columns( [0.95, 1.05], @@ -677,7 +762,11 @@ def run(): ) with video_column: - uploaded_video = _select_video(busy, media_id) + uploaded_video, media_id = _select_video( + busy, + media_id, + media_page, + ) selected_media_id = ( media_id if uploaded_video is None diff --git a/src/vidxp/infrastructure/local_worker.py b/src/vidxp/infrastructure/local_worker.py index a22bfc8..5f8eddb 100644 --- a/src/vidxp/infrastructure/local_worker.py +++ b/src/vidxp/infrastructure/local_worker.py @@ -29,6 +29,7 @@ class LocalWorkerSupervisor: """Start one detached repository-scoped worker without owning job state.""" _retry_delay_seconds = 5.0 + _startup_timeout_seconds = 15.0 def __init__(self, settings: VidXPSettings) -> None: self.settings = settings @@ -131,6 +132,7 @@ def _ensure_running(self) -> None: worker_lock, ready_path, fingerprint=bootstrap.fingerprint, + timeout_seconds=self._startup_timeout_seconds, ) except Exception: if process is not None: @@ -244,7 +246,7 @@ def _wait_for_existing_worker( ready_path: Path, *, fingerprint: str, - timeout_seconds: float = 5, + timeout_seconds: float = _startup_timeout_seconds, ) -> bool: deadline = monotonic() + timeout_seconds while monotonic() < deadline: @@ -273,7 +275,7 @@ def _wait_for_worker( ready_path: Path, *, fingerprint: str, - timeout_seconds: float = 5, + timeout_seconds: float = _startup_timeout_seconds, ) -> None: deadline = monotonic() + timeout_seconds while monotonic() < deadline: diff --git a/src/vidxp/mcp.py b/src/vidxp/mcp.py index c20037c..997d349 100644 --- a/src/vidxp/mcp.py +++ b/src/vidxp/mcp.py @@ -5,7 +5,7 @@ from contextvars import ContextVar from dataclasses import dataclass from functools import partial -from typing import Callable, TypeVar +from typing import Annotated, Callable, TypeVar import anyio from mcp.server import MCPServer @@ -17,6 +17,7 @@ from mcp.shared.exceptions import MCPError from mcp.server.mcpserver.exceptions import ToolError from mcp.types import ToolAnnotations +from pydantic import Field from starlette.requests import Request from starlette.types import ASGIApp, Receive, Scope, Send @@ -31,6 +32,12 @@ IndexStatus, Job, JobId, + JobPage, + ListJobsCommand, + ListMediaCommand, + MediaAsset, + MediaId, + MediaPage, Principal, QueryVideoCommand, SearchCommand, @@ -314,8 +321,10 @@ def create_mcp_server( title="VidXP", description="Index and search registered video media.", instructions=( - "Register and upload video through the HTTP/tus API first. " - "Use the resulting media_id with these durable MCP tools." + "Discover registered media with list_media. Register and upload " + "new video through the HTTP/tus API, then use its media_id with " + "the durable indexing and query tools. Use list_jobs to recover " + "job IDs across agent sessions." ), version=__version__, token_verifier=token_verifier, @@ -350,6 +359,40 @@ async def get_capability(name: Identifier) -> CapabilityInfo: operation=lambda _actor: context.application.get_capability(name), ) + @server.tool( + description="List registered video metadata without transferring bytes.", + annotations=_READ_ONLY, + structured_output=True, + ) + async def list_media( + page_size: Annotated[int, Field(gt=0, le=100)] = 50, + cursor: Annotated[ + str | None, + Field(min_length=1, max_length=512), + ] = None, + ) -> MediaPage: + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=lambda _actor: context.application.list_media( + ListMediaCommand(page_size=page_size, cursor=cursor) + ), + ) + + @server.tool( + description="Get metadata for one registered video.", + annotations=_READ_ONLY, + structured_output=True, + ) + async def get_media(media_id: MediaId) -> MediaAsset: + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=lambda _actor: context.application.get_media(media_id), + ) + @server.tool( description="Inspect the active index state.", annotations=_READ_ONLY, @@ -450,6 +493,27 @@ def submit(actor: Principal) -> Job: operation=submit, ) + @server.tool( + description="List durable jobs so work can be recovered across sessions.", + annotations=_READ_ONLY, + structured_output=True, + ) + async def list_jobs( + page_size: Annotated[int, Field(gt=0, le=100)] = 50, + cursor: Annotated[ + str | None, + Field(min_length=1, max_length=512), + ] = None, + ) -> JobPage: + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=lambda _actor: context.jobs.list( + ListJobsCommand(page_size=page_size, cursor=cursor) + ), + ) + @server.tool( description="Poll a durable VidXP job and its typed result.", annotations=_READ_ONLY, @@ -463,6 +527,34 @@ async def get_job(job_id: JobId) -> Job: operation=lambda _actor: context.jobs.get(job_id), ) + @server.tool( + description="Retry a failed or cancelled durable job.", + annotations=_SUBMIT, + structured_output=True, + ) + async def retry_job( + job_id: JobId, + idempotency_key: IdempotencyKey, + ) -> Job: + def retry(actor: Principal) -> Job: + return context.jobs.retry( + job_id, + retry_id=scoped_job_id( + repository_id=settings.repository_id, + principal=actor, + transport="mcp", + operation=f"retry:{job_id}", + idempotency_key=idempotency_key, + ), + ) + + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.write, + operation=retry, + ) + @server.tool( description="Explicitly cancel an active durable job.", annotations=_CANCEL, diff --git a/src/vidxp/settings.py b/src/vidxp/settings.py index 59d2817..da31351 100644 --- a/src/vidxp/settings.py +++ b/src/vidxp/settings.py @@ -483,6 +483,12 @@ def _clean_import_roots( @model_validator(mode="after") def _require_explicit_server_backend(self) -> "VidXPSettings": + if self.mode == ApplicationMode.remote: + raise ValueError( + "Remote client mode is not available in this release. " + "Connect agents to the remote MCP endpoint or use the HTTP " + "API directly." + ) if ( self.mode == ApplicationMode.server and not re.fullmatch(r"(cpu|cuda(?::[0-9]+)?)", self.runtime_backend) diff --git a/tests/test_api_cli.py b/tests/test_api_cli.py new file mode 100644 index 0000000..60682e6 --- /dev/null +++ b/tests/test_api_cli.py @@ -0,0 +1,20 @@ +import unittest +from contextlib import redirect_stdout +from io import StringIO + +from vidxp.api_cli import main + + +class ApiCliTests(unittest.TestCase): + def test_help_exits_without_starting_the_service(self): + output = StringIO() + + with redirect_stdout(output), self.assertRaises(SystemExit) as caught: + main(["--help"]) + + self.assertEqual(caught.exception.code, 0) + self.assertIn("VIDXP_* environment variables", output.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py index a5aef2c..1bfe81d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -344,6 +344,27 @@ def test_doctor_and_prepare_use_shared_models(self): ("scene",), ) + def test_doctor_accepts_repeated_modality_options(self): + self.service.check_dependencies.return_value = DependencyCheckResult( + ok=True, + modalities=("dialogue", "scene"), + checks=(), + ) + result = self.invoke( + [ + "doctor", + "--modalities", + "dialogue", + "--modalities", + "scene", + "--json", + ], + ) + + self.assertEqual(result.exit_code, 0, result.output) + command = self.service.check_dependencies.call_args.args[0] + self.assertEqual(command.modalities, ("dialogue", "scene")) + def test_invalid_capability_is_a_cli_parameter_error(self): result = self.invoke( ["doctor", "--modalities", "unknown", "--json"] diff --git a/tests/test_frontend.py b/tests/test_frontend.py index cb6ef5f..f2df720 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -163,6 +163,43 @@ def test_media_identity_controls_search_readiness(self): self.assertTrue(frontend._is_search_ready(matching, MEDIA_ID)) self.assertFalse(frontend._is_search_ready(stale, MEDIA_ID)) + def test_single_registered_media_is_selected_before_indexing(self): + asset = SimpleNamespace(media_id=MEDIA_ID) + + self.assertEqual( + frontend._default_media_id(None, (asset,)), + MEDIA_ID, + ) + self.assertEqual( + frontend._default_media_id("missing", (asset,)), + MEDIA_ID, + ) + + def test_multiple_registered_media_preserves_only_valid_selection(self): + assets = ( + SimpleNamespace(media_id=MEDIA_ID), + SimpleNamespace(media_id="323456781234423481234567890abcde"), + ) + + self.assertEqual( + frontend._default_media_id(MEDIA_ID, assets), + MEDIA_ID, + ) + self.assertIsNone(frontend._default_media_id("missing", assets)) + + def test_local_path_import_uses_the_shared_application_command(self): + service = Mock() + service.import_media.return_value = SimpleNamespace(media_id=MEDIA_ID) + + result = frontend._import_local_video( + service, + " sample.mp4 ", + ) + + self.assertEqual(result.media_id, MEDIA_ID) + command = service.import_media.call_args.args[0] + self.assertEqual(command.path, Path("sample.mp4")) + def test_available_modalities_use_application_dependency_commands(self): with TemporaryDirectory() as directory: service = self.service(Path(directory)) diff --git a/tests/test_local_worker.py b/tests/test_local_worker.py index d618635..21c41d6 100644 --- a/tests/test_local_worker.py +++ b/tests/test_local_worker.py @@ -91,6 +91,11 @@ def spawn(command, **options): command = popen.call_args.args[0] options = popen.call_args.kwargs + self.assertEqual( + wait.call_args.kwargs["timeout_seconds"], + supervisor._startup_timeout_seconds, + ) + self.assertGreaterEqual(supervisor._startup_timeout_seconds, 10) self.assertEqual(command[:3], [sys.executable, "-m", "vidxp.workflow_worker"]) self.assertIn("--lock-file", command) lock_file = command[command.index("--lock-file") + 1] diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 81bdb14..38dcd5a 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -20,8 +20,10 @@ IndexStatus, Job, JobKind, + JobPage, JobQueue, JobState, + MediaPage, Principal, QueryVideoCommand, ) @@ -114,11 +116,15 @@ async def test_curated_tools_have_structured_output_schemas(self): [ "list_capabilities", "get_capability", + "list_media", + "get_media", "get_index_status", "start_indexing", "search_moments", "query_video", + "list_jobs", "get_job", + "retry_job", "cancel_job", ], ) @@ -202,6 +208,75 @@ async def test_query_video_submits_the_shared_durable_command(self): context.jobs.submit_query.call_args.kwargs, ) + async def test_discovery_tools_use_shared_media_and_job_pages(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.application.list_media.return_value = MediaPage(total=0) + context.jobs.list.return_value = JobPage() + server = create_mcp_server( + context, + default_principal=Principal( + subject="agent", + scopes=frozenset({"vidxp.read"}), + ), + ) + async with Client(server) as client: + media = await client.call_tool( + "list_media", + {"page_size": 7}, + ) + jobs = await client.call_tool( + "list_jobs", + {"page_size": 9}, + ) + + self.assertFalse(media.is_error) + self.assertFalse(jobs.is_error) + self.assertEqual( + media.structured_content, + {"items": [], "total": 0, "next_cursor": None}, + ) + self.assertEqual( + jobs.structured_content, + {"items": [], "next_cursor": None}, + ) + self.assertEqual( + context.application.list_media.call_args.args[0].page_size, + 7, + ) + self.assertEqual(context.jobs.list.call_args.args[0].page_size, 9) + + async def test_retry_job_uses_shared_stable_idempotency(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.jobs.retry.return_value = queued_job() + server = create_mcp_server( + context, + default_principal=Principal( + subject="agent", + scopes=frozenset({"vidxp.write"}), + ), + ) + arguments = { + "job_id": JOB_ID, + "idempotency_key": "agent-retry-0001", + } + async with Client(server) as client: + first = await client.call_tool("retry_job", arguments) + second = await client.call_tool("retry_job", arguments) + + self.assertFalse(first.is_error) + self.assertEqual( + first.structured_content, + second.structured_content, + ) + calls = context.jobs.retry.call_args_list + self.assertEqual(len(calls), 2) + self.assertEqual( + calls[0].kwargs["retry_id"], + calls[1].kwargs["retry_id"], + ) + async def test_application_errors_are_machine_readable_and_safe(self): with TemporaryDirectory() as directory: context = self.context(Path(directory)) @@ -298,11 +373,15 @@ async def test_stdio_entrypoint_serves_the_same_curated_surface(self): [ "list_capabilities", "get_capability", + "list_media", + "get_media", "get_index_status", "start_indexing", "search_moments", "query_video", + "list_jobs", "get_job", + "retry_job", "cancel_job", ], ) @@ -357,7 +436,7 @@ async def test_streamable_http_works_with_the_official_remote_client(self): server.should_exit = True await serving - self.assertEqual(len(discovered.tools), 8) + self.assertEqual(len(discovered.tools), 12) self.assertEqual(result.structured_content, {"items": []}) async def test_oidc_verifier_projects_the_shared_validated_token(self): diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..6daee72 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,18 @@ +import unittest + +from pydantic import ValidationError + +from vidxp.settings import ApplicationMode, VidXPSettings + + +class SettingsTests(unittest.TestCase): + def test_unimplemented_remote_mode_is_rejected_explicitly(self): + with self.assertRaisesRegex( + ValidationError, + "Remote client mode is not available", + ): + VidXPSettings(mode=ApplicationMode.remote) + + +if __name__ == "__main__": + unittest.main() From 76c95b755627759dcba01755af60c7030aed3671 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 16:23:00 +0500 Subject: [PATCH 16/46] refactor: simplify server storage topology --- README.md | 5 +- compose.coolify.yaml | 7 +- docs/architecture/platform.md | 85 +++--- docs/deployment/coolify.md | 25 +- src/vidxp/api_routes/dependencies.py | 2 - src/vidxp/application_models.py | 1 - src/vidxp/composition.py | 22 +- src/vidxp/core/identifiers.py | 1 - src/vidxp/core/storage.py | 2 + src/vidxp/core/uploads.py | 3 - src/vidxp/database_cli.py | 9 +- src/vidxp/health_cli.py | 13 +- src/vidxp/idempotency.py | 6 +- src/vidxp/infrastructure/sql_catalog.py | 46 +-- src/vidxp/infrastructure/sql_snapshots.py | 55 ++-- src/vidxp/infrastructure/sql_tables.py | 51 ++-- src/vidxp/mcp.py | 4 - .../versions/20260729_02_single_repository.py | 264 ++++++++++++++++++ src/vidxp/settings.py | 24 +- src/vidxp/upload_service.py | 35 +-- src/vidxp/workflow_runtime.py | 34 +-- src/vidxp/workflow_worker.py | 28 +- tests/test_cli.py | 13 + tests/test_database_cli.py | 23 ++ tests/test_job_contracts.py | 37 ++- tests/test_local_worker.py | 11 +- tests/test_models.py | 4 +- tests/test_upload_service.py | 15 +- 28 files changed, 516 insertions(+), 309 deletions(-) create mode 100644 src/vidxp/migrations/versions/20260729_02_single_repository.py create mode 100644 tests/test_database_cli.py diff --git a/README.md b/README.md index 35db4eb..88771f9 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,6 @@ per-media ownership rules. ```text VIDXP_MODE=server VIDXP_RUNTIME_BACKEND=cpu -VIDXP_DATABASE_URL=postgresql://... VIDXP_HTTP_BIND_HOST=0.0.0.0 VIDXP_HTTP_AUTH_MODE=static VIDXP_HTTP_STATIC_BEARER_TOKEN= @@ -247,6 +246,10 @@ VIDXP_HTTP_TRUSTED_HOSTS=["api.example.com"] VIDXP_MCP_ALLOWED_HOSTS=["api.example.com"] ``` +Server mode is supported through the bundled Compose topology and always connects +to its internal `postgres` and `chroma` services. Database and Chroma endpoints are +not user-configurable settings. + Use `Authorization: Bearer ` for every route except `/health` and `/ready`. Configure `VIDXP_HTTP_ALLOWED_ORIGINS` only for browser origins that must call `/api/*`; MCP has its own Host and Origin policy. Authenticated profiles diff --git a/compose.coolify.yaml b/compose.coolify.yaml index b13b2b5..cd6d601 100644 --- a/compose.coolify.yaml +++ b/compose.coolify.yaml @@ -1,12 +1,9 @@ x-vidxp-environment: &vidxp-environment VIDXP_MODE: server VIDXP_RUNTIME_BACKEND: cpu - VIDXP_REPOSITORY_ID: ${VIDXP_REPOSITORY_ID:-default} VIDXP_REPOSITORY_ROOT: /var/lib/vidxp/data VIDXP_MODEL_CACHE: /var/lib/vidxp/models - VIDXP_DATABASE_URL: postgresql+psycopg://vidxp@postgres:5432/vidxp PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} - VIDXP_CHROMA_SERVER_URL: http://chroma:8000 VIDXP_HTTP_BIND_HOST: 0.0.0.0 VIDXP_HTTP_AUTH_MODE: static VIDXP_HTTP_STATIC_BEARER_TOKEN: ${VIDXP_HTTP_STATIC_BEARER_TOKEN:?Set a bearer token of at least 32 characters} @@ -17,7 +14,7 @@ x-vidxp-environment: &vidxp-environment VIDXP_UPLOAD_CLEANUP_TOKEN: ${VIDXP_UPLOAD_CLEANUP_TOKEN:?Set a cleanup token of at least 32 characters} VIDXP_UPLOAD_QUARANTINE_ROOT: /var/lib/vidxp/uploads VIDXP_UPLOAD_MAX_BYTES: ${VIDXP_UPLOAD_MAX_BYTES:-53687091200} - VIDXP_UPLOAD_PRINCIPAL_QUOTA_BYTES: ${VIDXP_UPLOAD_PRINCIPAL_QUOTA_BYTES:-107374182400} + VIDXP_UPLOAD_QUOTA_BYTES: ${VIDXP_UPLOAD_QUOTA_BYTES:-107374182400} services: postgres: @@ -61,8 +58,6 @@ services: chroma-ready: image: ${VIDXP_CONTROL_IMAGE:?Set the immutable VidXP control image} - environment: - VIDXP_CHROMA_SERVER_URL: http://chroma:8000 command: ["python", "-m", "vidxp.health_cli", "chroma"] depends_on: chroma: diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md index 97b06b0..658c8da 100644 --- a/docs/architecture/platform.md +++ b/docs/architecture/platform.md @@ -81,7 +81,7 @@ Domain contracts and ports │ ▼ Infrastructure implementations - ├── filesystem / object storage + ├── managed local filesystem storage ├── Chroma ├── DBOS SQLite / Postgres ├── FFmpeg / ffprobe / OpenCV @@ -99,15 +99,14 @@ Use one immutable `VidXPSettings` model built with `pydantic-settings`. It owns: -- repository root and repository selection -- media and artifact storage backends +- repository root and active local repository selection - trusted local import roots - upload limits and retention - authentication mode and secrets - public base URL - MCP transport configuration - MCP canonical resource URL, body limit, allowed hosts, and allowed origins -- workflow database and queue selection +- local/server workflow mode and queue selection - resolved CPU/GPU runtime request - model cache path and offline/download policy - allowed model identifiers @@ -299,8 +298,8 @@ Local media does not need to travel through an HTTP upload endpoint. Remote upload is a four-stage protocol: 1. An authenticated client creates an upload intent. -2. tusd's blocking `pre-create` HTTP hook validates the principal, repository, - declared size/type, and intent and assigns an opaque upload ID. +2. tusd's blocking `pre-create` HTTP hook authenticates the request, validates the + declared size/type and intent, and assigns an opaque upload ID. 3. tusd returns an HTTPS upload URL. That URL is an unscoped bearer credential for subsequent HEAD/PATCH requests because tusd cannot bind resumptions to the user who created the upload. @@ -320,17 +319,23 @@ from the hook request body and redacted; client tokens are never stored in tus metadata. Only the tus upload route is public. A completed upload is not a `MediaAsset` until durable probe/import succeeds. -The single-node default uses tusd filestore on a named quarantine volume shared -read-only with the hook service and worker. It is not horizontally scalable. The -production/HA profile uses tusd's S3-compatible backend; no local media/artifact -volume is shared across replicas. Clients upload directly to tusd/storage; FastAPI -does not proxy large video bodies. +The supported server topology uses tusd filestore on a named quarantine volume +shared read-only with the hook service and worker. Managed media and artifacts use +the stack's named content volume. The whole deployment is single-node and one +deployed stack is one repository boundary. Clients upload directly to tusd; +FastAPI does not proxy large video bodies. + +S3-compatible storage is deferred and not implemented. If revisited, it would +apply only to upload quarantine, source media, and generated artifacts—never to +embeddings, which remain in Chroma. Although tusd can receive uploads into S3, +VidXP would still require explicit import, processing, publication, recovery, +cleanup, and delivery integration. tusd 2.10.0 is pinned to a tested release digest, uses an explicit base path/public HTTPS -URL, a default 50 GiB maximum upload size, per-principal/repository storage quotas, +URL, a default 50 GiB maximum upload size, a deployment-wide upload quota, restricted CORS origins, disabled downloads, and disabled concatenation. Deployments -may lower the maximum but cannot raise it above the storage backend's reviewed hard -ceiling. Termination remains enabled so tusd owns its file locks and deletion. +may lower the configured maximum. Termination remains enabled so tusd owns its file +locks and deletion. The blocking `pre-terminate` hook prevents deletion while import is processing and admits completed/expired cleanup only with the private cleanup credential. @@ -744,27 +749,16 @@ remains available when it is disabled. - state - creation/expiry -Local artifacts are served through protected Starlette `FileResponse`, including -byte-range support. Object-storage deployments return short-lived presigned GET -URLs. FastAPI does not proxy object-store bodies. +Media and artifacts are served from the managed local content volume through +protected Starlette `FileResponse`, including byte-range support. -Every delivery URL is minted only after repository/owner authorization. Local +Every delivery request is handled only after repository authorization. Artifact delivery resolves an `ArtifactStore` key beneath its configured root, rejects symlink/path escapes, and only then constructs `FileResponse`. Local source playback applies the identical configured-root and symlink/path-escape checks to the `MediaStore` key before constructing `FileResponse`. -Presigned URLs are least-privilege bearer credentials, returned with -`Cache-Control: private, no-store`, and never persisted or logged. Source-playback -URLs expire within five minutes; downloadable artifact URLs expire within fifteen -minutes. These are hard upper bounds in settings, not caller-selected TTLs. - -Each object-store implementation must pass GET/HEAD, single-range 206, -`Accept-Ranges`, `Content-Range`, `Content-Length`, MIME/`Content-Disposition`, -expired-signature, and browser-CORS tests. Clients refresh an expired authorized URL -before resuming. - Search results normally use an authorized range-enabled source URL and client-side seeking. VidXP does not generate a clip for every search hit. @@ -1003,7 +997,7 @@ client. ## 22. Deployment topology -Default Coolify stack: +Supported Coolify/Compose server stack: ```text api-mcp @@ -1029,9 +1023,19 @@ tusd single-node media/artifact/quarantine volumes ``` -Optional: +This is one node, one application stack, and one repository. PostgreSQL, Chroma, +tusd, and the named content volumes are deployment components of that stack, not +provider plug-in points. A separate repository requires a separate stack and +separate databases and volumes. Arbitrary hosted databases, externally shared +Chroma, multiple API/worker replicas, failover, and provider compatibility are +outside the supported topology. + +Server code connects only to the internal Compose service names `postgres` and +`chroma`. Their endpoints are fixed rather than exposed through +`VIDXP_DATABASE_URL`, `VIDXP_CHROMA_SERVER_URL`, or equivalent user settings. + +Optional application services: -- S3-compatible media/artifact storage, required for the HA profile - GPU worker supplementing the CPU worker - internal CPU-default Ollama `slm` profile - external OIDC provider configuration @@ -1058,10 +1062,10 @@ Use immutable release tags or digests. API/MCP and workers share an application release but use purpose-specific image targets. API/MCP carries no heavy model or CUDA dependencies; workers and the optional `slm` service do. -Deployment v1 is one API/MCP replica and one node. Named media/artifact/quarantine -volumes are single-node only. Multiple API/worker replicas are a future gate that -requires Postgres leases, Chroma server, S3 media/artifacts, shared snapshot/catalog -transaction semantics, and load/failover tests. +Deployment v1 is exactly one API/MCP replica and one node. Named +media/artifact/quarantine volumes are part of this topology. The fixed internal +service names and storage paths are not promises of arbitrary external-provider, +multi-replica, or high-availability compatibility. The one-click template consumes published tags/digests and must pass a real Coolify deployment, upgrade, rollback, persistent-data and healthcheck test. @@ -1200,9 +1204,8 @@ Capability discovery tests: - import failure - deterministic behavior on supported Python 3.11 through 3.14 -Artifact backend tests cover authorization, path/symlink containment, TTL upper -bounds, URL log redaction, GET/HEAD/range headers, MIME/disposition, expired URL -refresh, and browser CORS. +Managed artifact delivery tests cover authorization, path/symlink containment, +missing or corrupt content, GET/HEAD/range headers, and MIME/disposition metadata. ### 24.7 Platforms and installation profiles @@ -1413,9 +1416,9 @@ Validated on 2026-07-28: - **tusd:** accepted with the capability-URL and hook semantics documented above. HTTP hooks are duplicate/out-of-order capable; the durable importer and sweep own correctness. -- **Artifacts:** accepted. Starlette and S3-compatible range delivery provide the - required primitives; the authorization, containment, TTL, CORS, and bearer-URL - rules above remain mandatory. +- **Artifacts:** accepted for the managed local content volume. Protected Starlette + range delivery, authorization, storage-key containment, integrity checks, and + response metadata remain mandatory. - **Pydantic AI/Ollama:** accepted as the local SLM provider boundary. Typed native output is supported. The exact model remains deliberately blocked by the no-large-model-download constraint until Phase 9's measured gate. diff --git a/docs/deployment/coolify.md b/docs/deployment/coolify.md index c94c296..1c3aac4 100644 --- a/docs/deployment/coolify.md +++ b/docs/deployment/coolify.md @@ -28,6 +28,10 @@ VIDXP_UPLOAD_PUBLIC_ENDPOINT=https://uploads.example.com/uploads/ VIDXP_UPLOAD_CORS_ORIGIN_REGEX=^https://app\.example\.com$ ``` +Optional deployment-wide upload limits use `VIDXP_UPLOAD_MAX_BYTES` for one object +and `VIDXP_UPLOAD_QUOTA_BYTES` for all reserved upload bytes in the singleton +repository. There is no per-principal or per-repository quota setting. + Route the API service's port 8000 to the API hostname. Route only tusd's `/uploads/` path on port 8080 to the upload hostname. Do not publish PostgreSQL, Chroma, or the hook service. @@ -81,12 +85,17 @@ The migration and readiness containers should exit successfully. PostgreSQL, API hooks, worker, and tusd should report healthy; Chroma is checked by the completed `chroma-ready` gate. -This profile is intentionally single-node. Its named content, upload, model, and -Chroma volumes are not a multi-replica storage design. Back up the PostgreSQL and -named data volumes before replacing a release. +This is the supported server topology: one node, one API/MCP service, one hook +service, one CPU worker, and the bundled PostgreSQL, Chroma, tusd, and named +volumes. It is not a multi-replica, failover, or provider-portability design. Back +up PostgreSQL and the named data volumes before replacing a release. + +Treat each deployed stack as its singleton repository boundary. The current +PostgreSQL catalog and Chroma collections intentionally contain no repository +namespace. Deploy another complete stack, with separate databases and volumes, for +a separate repository. -Treat each deployed stack as one repository boundary. The current PostgreSQL -catalog tables and Chroma collection names are deployment-scoped rather than -repository-namespaced, so multiple repository IDs must not share the same -PostgreSQL database and Chroma service. Deploy another stack (and volumes) for a -separate repository. +VidXP server mode connects to the internal Compose service names `postgres` and +`chroma`. Those endpoints are fixed by the supported topology: do not set +`VIDXP_DATABASE_URL` or `VIDXP_CHROMA_SERVER_URL`, and do not substitute hosted +PostgreSQL, hosted Chroma, or alternative database providers. diff --git a/src/vidxp/api_routes/dependencies.py b/src/vidxp/api_routes/dependencies.py index 226eb07..15afef6 100644 --- a/src/vidxp/api_routes/dependencies.py +++ b/src/vidxp/api_routes/dependencies.py @@ -87,7 +87,6 @@ def scoped_job_id( """Derive a non-reversible DBOS workflow ID from an HTTP request key.""" return derive_scoped_job_id( - repository_id=service.settings.repository_id, principal=actor, transport="http", operation=operation, @@ -103,7 +102,6 @@ def scoped_request_key( idempotency_key: str, ) -> str: return derive_scoped_request_key( - repository_id=service.settings.repository_id, principal=actor, transport="http", operation=operation, diff --git a/src/vidxp/application_models.py b/src/vidxp/application_models.py index b40f265..aa92a79 100644 --- a/src/vidxp/application_models.py +++ b/src/vidxp/application_models.py @@ -25,7 +25,6 @@ JobId as JobId, MediaId as MediaId, MimeType, - RepositoryId as RepositoryId, Sha256, UploadIntentId as UploadIntentId, VideoId as VideoId, diff --git a/src/vidxp/composition.py b/src/vidxp/composition.py index 058cb9d..545a226 100644 --- a/src/vidxp/composition.py +++ b/src/vidxp/composition.py @@ -17,6 +17,7 @@ create_capability_registry, ) from vidxp.control_plane import ControlPlaneApplication +from vidxp.core.storage import BUNDLED_CHROMA_SERVER_URL from vidxp.dependencies import active_requirements, packaged_requirements from vidxp.infrastructure.local_index import ( LOCAL_INDEX_RUNTIME_CHECKS, @@ -146,14 +147,15 @@ class _ControlPlaneComponents: snapshots: LocalSnapshotRepository +def _server_chroma_url(settings: VidXPSettings) -> str | None: + if settings.mode != ApplicationMode.server: + return None + return BUNDLED_CHROMA_SERVER_URL + + def _create_control_plane_components( settings: VidXPSettings, ) -> _ControlPlaneComponents: - if ( - settings.mode == ApplicationMode.server - and settings.chroma_server_url is None - ): - raise ValueError("Server applications require a remote Chroma URL.") settings.layout.ensure_local_directories() server_mode = settings.mode == ApplicationMode.server registry = create_capability_registry( @@ -188,7 +190,6 @@ def _create_control_plane_components( SQLSnapshotRepository( settings.layout.indexes, engine=catalog.engine, - repository_id=settings.repository_id, ) if settings.mode == ApplicationMode.server else LocalSnapshotRepository(settings.layout.indexes) @@ -216,7 +217,10 @@ def _create_control_plane_components( def settings_for_repository(repository: RepositoryConfig) -> VidXPSettings: - values = {"repository_root": repository.index_directory} + values = { + "mode": ApplicationMode.local, + "repository_root": repository.index_directory, + } if repository.device is not None: values["runtime_backend"] = repository.device return VidXPSettings(**values) @@ -242,7 +246,7 @@ def create_application( components.registry, runtime, active_settings.layout, - chroma_server_url=active_settings.chroma_server_url, + chroma_server_url=_server_chroma_url(active_settings), snapshot_repository=components.snapshots, ) artifacts = ArtifactService( @@ -339,7 +343,7 @@ def create_job_service( layout=settings.layout, index=LocalIndexReader( settings.layout, - chroma_server_url=settings.chroma_server_url, + chroma_server_url=_server_chroma_url(settings), snapshot_repository=snapshots, ), ) diff --git a/src/vidxp/core/identifiers.py b/src/vidxp/core/identifiers.py index fc4f8a0..8305094 100644 --- a/src/vidxp/core/identifiers.py +++ b/src/vidxp/core/identifiers.py @@ -71,7 +71,6 @@ def _require_workflow_uuid(value: str) -> str: ), ] -RepositoryId: TypeAlias = Identifier MediaId: TypeAlias = Uuid4Hex VideoId: TypeAlias = MediaId IndexGenerationId: TypeAlias = Uuid4Hex diff --git a/src/vidxp/core/storage.py b/src/vidxp/core/storage.py index d145815..c125999 100644 --- a/src/vidxp/core/storage.py +++ b/src/vidxp/core/storage.py @@ -13,6 +13,8 @@ _T = TypeVar("_T") +BUNDLED_CHROMA_SERVER_URL = "http://chroma:8000" + class IndexStorageUnavailableError(RuntimeError): """The configured remote vector store could not serve an operation.""" diff --git a/src/vidxp/core/uploads.py b/src/vidxp/core/uploads.py index 248acb9..d373a2b 100644 --- a/src/vidxp/core/uploads.py +++ b/src/vidxp/core/uploads.py @@ -8,7 +8,6 @@ JobId, MediaId, MimeType, - RepositoryId, Uuid4Hex, ) from vidxp.core.media import validate_display_filename @@ -34,8 +33,6 @@ class UploadIntentRecord(BaseModel): intent_id: Uuid4Hex request_key: str = Field(pattern=r"^[0-9a-f]{64}$") - repository_id: RepositoryId - owner_subject: str = Field(min_length=1, max_length=255) original_filename: str = Field(min_length=1, max_length=255) byte_size: int = Field(gt=0) declared_mime_type: MimeType | None = None diff --git a/src/vidxp/database_cli.py b/src/vidxp/database_cli.py index c9626b3..147c81f 100644 --- a/src/vidxp/database_cli.py +++ b/src/vidxp/database_cli.py @@ -27,17 +27,10 @@ def main(arguments: Sequence[str] | None = None) -> None: parser = argparse.ArgumentParser( description="Apply VidXP relational database migrations." ) - parser.add_argument("--database-url") - options = parser.parse_args(arguments) - overrides = ( - {"database_url": options.database_url} - if options.database_url is not None - else {} - ) + parser.parse_args(arguments) settings = VidXPSettings( mode=ApplicationMode.server, runtime_backend="cpu", - **overrides, ) upgrade_database(workflow_database_url(settings)) diff --git a/src/vidxp/health_cli.py b/src/vidxp/health_cli.py index f2cff73..da6b269 100644 --- a/src/vidxp/health_cli.py +++ b/src/vidxp/health_cli.py @@ -7,7 +7,10 @@ from sqlalchemy import create_engine, select -from vidxp.core.storage import ChromaClientFactory +from vidxp.core.storage import ( + BUNDLED_CHROMA_SERVER_URL, + ChromaClientFactory, +) from vidxp.settings import VidXPSettings from vidxp.workflow_runtime import workflow_database_url @@ -18,10 +21,8 @@ def main(arguments: Sequence[str] | None = None) -> None: options = parser.parse_args(arguments) settings = VidXPSettings() if options.role == "chroma": - if settings.chroma_server_url is None: - raise RuntimeError("Chroma readiness requires a server URL.") deadline = monotonic() + 120 - endpoint = settings.chroma_server_url.rstrip("/") + "/api/v2/heartbeat" + endpoint = BUNDLED_CHROMA_SERVER_URL + "/api/v2/heartbeat" while True: try: with urlopen(endpoint, timeout=3) as response: @@ -41,9 +42,7 @@ def main(arguments: Sequence[str] | None = None) -> None: finally: engine.dispose() if options.role == "worker": - if settings.chroma_server_url is None: - raise RuntimeError("Worker readiness requires Chroma server mode.") - ChromaClientFactory(settings.chroma_server_url).heartbeat() + ChromaClientFactory(BUNDLED_CHROMA_SERVER_URL).heartbeat() if __name__ == "__main__": diff --git a/src/vidxp/idempotency.py b/src/vidxp/idempotency.py index dce22e3..a54c1ae 100644 --- a/src/vidxp/idempotency.py +++ b/src/vidxp/idempotency.py @@ -18,11 +18,11 @@ ), ] RequestTransport: TypeAlias = Literal["http", "mcp"] +_SINGLE_REPOSITORY_SCOPE = "default" def scoped_request_key( *, - repository_id: str, principal: Principal, transport: RequestTransport, operation: str, @@ -33,7 +33,7 @@ def scoped_request_key( material = "\0".join( ( f"vidxp-{transport}-request-v1", - repository_id, + _SINGLE_REPOSITORY_SCOPE, principal.subject, operation, idempotency_key, @@ -44,7 +44,6 @@ def scoped_request_key( def scoped_job_id( *, - repository_id: str, principal: Principal, transport: RequestTransport, operation: str, @@ -53,7 +52,6 @@ def scoped_job_id( """Project a scoped request identity into a valid deterministic job UUID.""" digest = scoped_request_key( - repository_id=repository_id, principal=principal, transport=transport, operation=operation, diff --git a/src/vidxp/infrastructure/sql_catalog.py b/src/vidxp/infrastructure/sql_catalog.py index 97655fc..fce4680 100644 --- a/src/vidxp/infrastructure/sql_catalog.py +++ b/src/vidxp/infrastructure/sql_catalog.py @@ -21,7 +21,7 @@ media_import_requests, metadata, upload_intents, - upload_quotas, + upload_quota, ) _RESERVED_UPLOAD_STATES = { @@ -30,6 +30,7 @@ UploadState.processing.value, UploadState.failed.value, } +_UPLOAD_QUOTA_ID = "1" class UploadQuotaExceededError(RuntimeError): @@ -52,8 +53,6 @@ def _upload_record(row: Any) -> UploadIntentRecord: return UploadIntentRecord( intent_id=row.intent_id, request_key=row.request_key, - repository_id=row.repository_id, - owner_subject=row.owner_subject, original_filename=row.original_filename, byte_size=row.byte_size, declared_mime_type=row.declared_mime_type, @@ -469,8 +468,6 @@ def create_upload_intent( with self._write_transaction() as connection: self._reserve_upload_quota( connection, - repository_id=record.repository_id, - owner_subject=record.owner_subject, byte_size=record.byte_size, quota_limit=quota_limit, ) @@ -478,8 +475,6 @@ def create_upload_intent( insert(upload_intents).values( intent_id=record.intent_id, request_key=record.request_key, - repository_id=record.repository_id, - owner_subject=record.owner_subject, original_filename=record.original_filename, byte_size=record.byte_size, declared_mime_type=record.declared_mime_type, @@ -497,43 +492,36 @@ def create_upload_intent( def _reserve_upload_quota( connection: Connection, *, - repository_id: str, - owner_subject: str, byte_size: int, quota_limit: int, ) -> None: - identity = ( - upload_quotas.c.repository_id == repository_id, - upload_quotas.c.owner_subject == owner_subject, - ) row = connection.execute( - select(upload_quotas.c.reserved_bytes) - .where(*identity) + select(upload_quota.c.reserved_bytes) + .where(upload_quota.c.singleton_id == _UPLOAD_QUOTA_ID) .with_for_update() ).one_or_none() if row is None: try: with connection.begin_nested(): connection.execute( - insert(upload_quotas).values( - repository_id=repository_id, - owner_subject=owner_subject, + insert(upload_quota).values( + singleton_id=_UPLOAD_QUOTA_ID, reserved_bytes=0, ) ) except IntegrityError: pass row = connection.execute( - select(upload_quotas.c.reserved_bytes) - .where(*identity) + select(upload_quota.c.reserved_bytes) + .where(upload_quota.c.singleton_id == _UPLOAD_QUOTA_ID) .with_for_update() ).one() reserved = int(row.reserved_bytes) if reserved + byte_size > quota_limit: raise UploadQuotaExceededError connection.execute( - update(upload_quotas) - .where(*identity) + update(upload_quota) + .where(upload_quota.c.singleton_id == _UPLOAD_QUOTA_ID) .values(reserved_bytes=reserved + byte_size) ) @@ -601,8 +589,6 @@ def update_upload( ) -> bool: current = connection.execute( select( - upload_intents.c.repository_id, - upload_intents.c.owner_subject, upload_intents.c.byte_size, upload_intents.c.state, ) @@ -637,20 +623,16 @@ def update_upload( and state.value not in _RESERVED_UPLOAD_STATES ): quota = connection.execute( - select(upload_quotas.c.reserved_bytes) + select(upload_quota.c.reserved_bytes) .where( - upload_quotas.c.repository_id - == current.repository_id, - upload_quotas.c.owner_subject == current.owner_subject, + upload_quota.c.singleton_id == _UPLOAD_QUOTA_ID, ) .with_for_update() ).scalar_one() connection.execute( - update(upload_quotas) + update(upload_quota) .where( - upload_quotas.c.repository_id - == current.repository_id, - upload_quotas.c.owner_subject == current.owner_subject, + upload_quota.c.singleton_id == _UPLOAD_QUOTA_ID, ) .values( reserved_bytes=max(0, int(quota) - current.byte_size) diff --git a/src/vidxp/infrastructure/sql_snapshots.py b/src/vidxp/infrastructure/sql_snapshots.py index cd7d6e4..7ce42f7 100644 --- a/src/vidxp/infrastructure/sql_snapshots.py +++ b/src/vidxp/infrastructure/sql_snapshots.py @@ -26,9 +26,12 @@ from vidxp.infrastructure.sql_tables import ( index_generations, index_snapshots, - repositories, + index_state, ) +_INDEX_STATE_ID = "1" +_INDEX_LOCK_IDENTITY = "vidxp:index" + def _checksum(snapshot: IndexSnapshot) -> str: payload = json.dumps( @@ -48,11 +51,9 @@ def __init__( indexes: str | Path, *, engine: Engine, - repository_id: str, ) -> None: super().__init__(indexes) self.engine = engine - self.repository_id = repository_id @contextmanager def lease(self) -> Iterator[None]: @@ -60,7 +61,7 @@ def lease(self) -> Iterator[None]: with self.engine.connect() as connection: acquired = connection.execute( text("SELECT pg_try_advisory_lock(hashtext(:identity))"), - {"identity": f"vidxp:index:{self.repository_id}"}, + {"identity": _INDEX_LOCK_IDENTITY}, ).scalar_one() if not acquired: raise IndexingInProgressError( @@ -71,34 +72,34 @@ def lease(self) -> Iterator[None]: finally: connection.execute( text("SELECT pg_advisory_unlock(hashtext(:identity))"), - {"identity": f"vidxp:index:{self.repository_id}"}, + {"identity": _INDEX_LOCK_IDENTITY}, ) def mutation_in_progress(self) -> bool: with self.engine.connect() as connection: acquired = connection.execute( text("SELECT pg_try_advisory_lock(hashtext(:identity))"), - {"identity": f"vidxp:index:{self.repository_id}"}, + {"identity": _INDEX_LOCK_IDENTITY}, ).scalar_one() if acquired: connection.execute( text("SELECT pg_advisory_unlock(hashtext(:identity))"), - {"identity": f"vidxp:index:{self.repository_id}"}, + {"identity": _INDEX_LOCK_IDENTITY}, ) return not bool(acquired) @staticmethod - def _ensure_repository(connection: Connection, repository_id: str) -> None: + def _ensure_index_state(connection: Connection) -> None: if connection.execute( - select(repositories.c.repository_id).where( - repositories.c.repository_id == repository_id + select(index_state.c.singleton_id).where( + index_state.c.singleton_id == _INDEX_STATE_ID ) ).scalar_one_or_none() is not None: return try: with connection.begin_nested(): connection.execute( - insert(repositories).values(repository_id=repository_id) + insert(index_state).values(singleton_id=_INDEX_STATE_ID) ) except IntegrityError: pass @@ -107,9 +108,9 @@ def read_active(self, *, required: bool = False) -> IndexSnapshot | None: with self.engine.connect() as connection: row = connection.execute( select( - repositories.c.active_snapshot_id, - repositories.c.active_snapshot_sha256, - ).where(repositories.c.repository_id == self.repository_id) + index_state.c.active_snapshot_id, + index_state.c.active_snapshot_sha256, + ).where(index_state.c.singleton_id == _INDEX_STATE_ID) ).one_or_none() if row is None or row.active_snapshot_id is None: if required: @@ -145,12 +146,11 @@ def _read_snapshot( ) -> IndexSnapshot: row = connection.execute( select( - index_snapshots.c.repository_id, index_snapshots.c.sha256, index_snapshots.c.payload, ).where(index_snapshots.c.snapshot_id == snapshot_id) ).one_or_none() - if row is None or row.repository_id != self.repository_id: + if row is None: raise IndexSchemaError(f"Index snapshot {snapshot_id} is missing.") if expected_sha256 is not None and row.sha256 != expected_sha256: raise IndexSchemaError( @@ -180,7 +180,6 @@ def validate_generation( select(index_generations.c.payload).where( index_generations.c.generation_id == reference.generation_id, - index_generations.c.repository_id == self.repository_id, ) ).scalar_one_or_none() if ( @@ -289,22 +288,22 @@ def _publish( configuration: dict[str, Any], ) -> IndexSnapshot: with self.engine.begin() as connection: - self._ensure_repository(connection, self.repository_id) - repository = connection.execute( + self._ensure_index_state(connection) + state = connection.execute( select( - repositories.c.active_snapshot_id, - repositories.c.active_snapshot_sha256, + index_state.c.active_snapshot_id, + index_state.c.active_snapshot_sha256, ) - .where(repositories.c.repository_id == self.repository_id) + .where(index_state.c.singleton_id == _INDEX_STATE_ID) .with_for_update() ).one() active = ( None - if repository.active_snapshot_id is None + if state.active_snapshot_id is None else self._read_snapshot( connection, - repository.active_snapshot_id, - expected_sha256=repository.active_snapshot_sha256, + state.active_snapshot_id, + expected_sha256=state.active_snapshot_sha256, ) ) generations = dict(active.generations) if active is not None else {} @@ -323,7 +322,6 @@ def _publish( connection.execute( insert(index_generations).values( generation_id=replacement.generation_id, - repository_id=self.repository_id, media_id=replacement.media_id, manifest_sha256=replacement.manifest_sha256, payload=replacement.model_dump(mode="json"), @@ -354,15 +352,14 @@ def _publish( connection.execute( insert(index_snapshots).values( snapshot_id=snapshot.snapshot_id, - repository_id=self.repository_id, created_at=snapshot.created_at.isoformat(), sha256=checksum, payload=snapshot.model_dump(mode="json"), ) ) connection.execute( - update(repositories) - .where(repositories.c.repository_id == self.repository_id) + update(index_state) + .where(index_state.c.singleton_id == _INDEX_STATE_ID) .values( active_snapshot_id=snapshot.snapshot_id, active_snapshot_sha256=checksum, diff --git a/src/vidxp/infrastructure/sql_tables.py b/src/vidxp/infrastructure/sql_tables.py index d3732a2..9e1030f 100644 --- a/src/vidxp/infrastructure/sql_tables.py +++ b/src/vidxp/infrastructure/sql_tables.py @@ -9,7 +9,6 @@ String, Table, Text, - UniqueConstraint, ) @@ -70,8 +69,6 @@ metadata, Column("intent_id", String(32), primary_key=True), Column("request_key", String(64), nullable=False, unique=True), - Column("repository_id", String(255), nullable=False), - Column("owner_subject", String(255), nullable=False), Column("original_filename", String(255), nullable=False), Column("byte_size", BigInteger, nullable=False), Column("declared_mime_type", String(127), nullable=True), @@ -86,51 +83,45 @@ ForeignKey("media.media_id"), nullable=True, ), - UniqueConstraint( - "repository_id", - "intent_id", - name="upload_intents_repository_intent_key", - ), ) -upload_quotas = Table( - "upload_quotas", +upload_quota = Table( + "upload_quota", metadata, - Column("repository_id", String(255), primary_key=True), - Column("owner_subject", String(255), primary_key=True), + Column("singleton_id", String(1), primary_key=True), Column("reserved_bytes", BigInteger, nullable=False, default=0), CheckConstraint( "reserved_bytes >= 0", - name="upload_quotas_reserved_bytes_nonnegative", + name="upload_quota_reserved_bytes_nonnegative", + ), + CheckConstraint( + "singleton_id = '1'", + name="upload_quota_singleton", ), ) -repositories = Table( - "repositories", +index_state = Table( + "index_state", metadata, - Column("repository_id", String(255), primary_key=True), + Column("singleton_id", String(1), primary_key=True), Column("active_snapshot_id", String(32), nullable=True), Column("active_snapshot_sha256", String(64), nullable=True), + CheckConstraint( + "singleton_id = '1'", + name="index_state_singleton", + ), ) index_generations = Table( "index_generations", metadata, Column("generation_id", String(32), primary_key=True), - Column("repository_id", String(255), nullable=False), Column("media_id", String(32), nullable=False), Column("manifest_sha256", String(64), nullable=False), Column("payload", JSON, nullable=False), - UniqueConstraint( - "repository_id", - "media_id", - "generation_id", - name="index_generations_repository_media_generation_key", - ), ) Index( - "index_generations_repository_media", - index_generations.c.repository_id, + "index_generations_media", index_generations.c.media_id, ) @@ -138,22 +129,14 @@ "index_snapshots", metadata, Column("snapshot_id", String(32), primary_key=True), - Column("repository_id", String(255), nullable=False), Column("created_at", Text, nullable=False), Column("sha256", String(64), nullable=False), Column("payload", JSON, nullable=False), ) Index( - "index_snapshots_repository_created", - index_snapshots.c.repository_id, + "index_snapshots_created", index_snapshots.c.created_at, ) -Index( - "upload_intents_owner_state", - upload_intents.c.repository_id, - upload_intents.c.owner_subject, - upload_intents.c.state, -) Index( "upload_intents_expiry", upload_intents.c.expires_at, diff --git a/src/vidxp/mcp.py b/src/vidxp/mcp.py index 997d349..b069de9 100644 --- a/src/vidxp/mcp.py +++ b/src/vidxp/mcp.py @@ -419,7 +419,6 @@ def submit(actor: Principal) -> Job: return context.jobs.submit_index( command, job_id=scoped_job_id( - repository_id=settings.repository_id, principal=actor, transport="mcp", operation="index", @@ -447,7 +446,6 @@ def submit(actor: Principal) -> Job: return context.jobs.submit_search( command, job_id=scoped_job_id( - repository_id=settings.repository_id, principal=actor, transport="mcp", operation="search", @@ -478,7 +476,6 @@ def submit(actor: Principal) -> Job: return context.jobs.submit_query( command, job_id=scoped_job_id( - repository_id=settings.repository_id, principal=actor, transport="mcp", operation="query", @@ -540,7 +537,6 @@ def retry(actor: Principal) -> Job: return context.jobs.retry( job_id, retry_id=scoped_job_id( - repository_id=settings.repository_id, principal=actor, transport="mcp", operation=f"retry:{job_id}", diff --git a/src/vidxp/migrations/versions/20260729_02_single_repository.py b/src/vidxp/migrations/versions/20260729_02_single_repository.py new file mode 100644 index 0000000..5588ec4 --- /dev/null +++ b/src/vidxp/migrations/versions/20260729_02_single_repository.py @@ -0,0 +1,264 @@ +"""Collapse server catalog state to one repository and one upload quota. + +Revision ID: 20260729_02 +Revises: 20260729_01 +Create Date: 2026-07-29 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "20260729_02" +down_revision = "20260729_01" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + DO $$ + BEGIN + IF ( + SELECT count(DISTINCT repository_id) + FROM ( + SELECT repository_id FROM repositories + UNION ALL + SELECT repository_id FROM index_generations + UNION ALL + SELECT repository_id FROM index_snapshots + UNION ALL + SELECT repository_id FROM upload_intents + UNION ALL + SELECT repository_id FROM upload_quotas + ) AS repository_ids + ) > 1 THEN + RAISE EXCEPTION + 'VidXP cannot collapse multiple repositories into one stack'; + END IF; + END + $$; + """ + ) + + op.rename_table("repositories", "index_state") + op.alter_column( + "index_state", + "repository_id", + new_column_name="singleton_id", + ) + op.execute("UPDATE index_state SET singleton_id = '1'") + op.alter_column( + "index_state", + "singleton_id", + existing_type=sa.String(length=255), + type_=sa.String(length=1), + existing_nullable=False, + ) + op.create_check_constraint( + "index_state_singleton", + "index_state", + "singleton_id = '1'", + ) + + op.drop_index( + "index_generations_repository_media", + table_name="index_generations", + ) + op.drop_constraint( + "index_generations_repository_media_generation_key", + "index_generations", + type_="unique", + ) + op.drop_column("index_generations", "repository_id") + op.create_index( + "index_generations_media", + "index_generations", + ["media_id"], + unique=False, + ) + + op.drop_index( + "index_snapshots_repository_created", + table_name="index_snapshots", + ) + op.drop_column("index_snapshots", "repository_id") + op.create_index( + "index_snapshots_created", + "index_snapshots", + ["created_at"], + unique=False, + ) + + op.create_table( + "upload_quota", + sa.Column("singleton_id", sa.String(length=1), nullable=False), + sa.Column("reserved_bytes", sa.BigInteger(), nullable=False), + sa.CheckConstraint( + "reserved_bytes >= 0", + name="upload_quota_reserved_bytes_nonnegative", + ), + sa.CheckConstraint( + "singleton_id = '1'", + name="upload_quota_singleton", + ), + sa.PrimaryKeyConstraint("singleton_id"), + ) + op.execute( + """ + INSERT INTO upload_quota (singleton_id, reserved_bytes) + SELECT '1', COALESCE(sum(reserved_bytes), 0) + FROM upload_quotas + """ + ) + op.drop_table("upload_quotas") + + op.drop_index( + "upload_intents_owner_state", + table_name="upload_intents", + ) + op.drop_constraint( + "upload_intents_repository_intent_key", + "upload_intents", + type_="unique", + ) + op.drop_column("upload_intents", "owner_subject") + op.drop_column("upload_intents", "repository_id") + + +def downgrade() -> None: + op.add_column( + "upload_intents", + sa.Column("repository_id", sa.String(length=255), nullable=True), + ) + op.add_column( + "upload_intents", + sa.Column("owner_subject", sa.String(length=255), nullable=True), + ) + op.execute( + """ + UPDATE upload_intents + SET repository_id = 'default', owner_subject = 'shared' + """ + ) + op.alter_column( + "upload_intents", + "repository_id", + existing_type=sa.String(length=255), + nullable=False, + ) + op.alter_column( + "upload_intents", + "owner_subject", + existing_type=sa.String(length=255), + nullable=False, + ) + op.create_unique_constraint( + "upload_intents_repository_intent_key", + "upload_intents", + ["repository_id", "intent_id"], + ) + op.create_index( + "upload_intents_owner_state", + "upload_intents", + ["repository_id", "owner_subject", "state"], + unique=False, + ) + + op.create_table( + "upload_quotas", + sa.Column("repository_id", sa.String(length=255), nullable=False), + sa.Column("owner_subject", sa.String(length=255), nullable=False), + sa.Column("reserved_bytes", sa.BigInteger(), nullable=False), + sa.CheckConstraint( + "reserved_bytes >= 0", + name="upload_quotas_reserved_bytes_nonnegative", + ), + sa.PrimaryKeyConstraint("repository_id", "owner_subject"), + ) + op.execute( + """ + INSERT INTO upload_quotas ( + repository_id, + owner_subject, + reserved_bytes + ) + SELECT 'default', 'shared', reserved_bytes + FROM upload_quota + """ + ) + op.drop_table("upload_quota") + + op.drop_index( + "index_snapshots_created", + table_name="index_snapshots", + ) + op.add_column( + "index_snapshots", + sa.Column("repository_id", sa.String(length=255), nullable=True), + ) + op.execute( + "UPDATE index_snapshots SET repository_id = 'default'" + ) + op.alter_column( + "index_snapshots", + "repository_id", + existing_type=sa.String(length=255), + nullable=False, + ) + op.create_index( + "index_snapshots_repository_created", + "index_snapshots", + ["repository_id", "created_at"], + unique=False, + ) + + op.drop_index( + "index_generations_media", + table_name="index_generations", + ) + op.add_column( + "index_generations", + sa.Column("repository_id", sa.String(length=255), nullable=True), + ) + op.execute( + "UPDATE index_generations SET repository_id = 'default'" + ) + op.alter_column( + "index_generations", + "repository_id", + existing_type=sa.String(length=255), + nullable=False, + ) + op.create_unique_constraint( + "index_generations_repository_media_generation_key", + "index_generations", + ["repository_id", "media_id", "generation_id"], + ) + op.create_index( + "index_generations_repository_media", + "index_generations", + ["repository_id", "media_id"], + unique=False, + ) + + op.drop_constraint( + "index_state_singleton", + "index_state", + type_="check", + ) + op.alter_column( + "index_state", + "singleton_id", + existing_type=sa.String(length=1), + type_=sa.String(length=255), + existing_nullable=False, + ) + op.alter_column( + "index_state", + "singleton_id", + new_column_name="repository_id", + ) + op.execute("UPDATE index_state SET repository_id = 'default'") + op.rename_table("index_state", "repositories") diff --git a/src/vidxp/settings.py b/src/vidxp/settings.py index da31351..84a3e20 100644 --- a/src/vidxp/settings.py +++ b/src/vidxp/settings.py @@ -42,12 +42,6 @@ class VidXPSettings(BaseSettings): ) mode: ApplicationMode = ApplicationMode.local - repository_id: str = Field( - default="default", - min_length=1, - max_length=255, - pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$", - ) repository_root: Path = Path("chroma_data") runtime_backend: str = "auto" model_cache: Path = Field( @@ -57,7 +51,6 @@ class VidXPSettings(BaseSettings): max_loaded_models: int = Field(default=3, gt=0, le=16) max_concurrent_indexing: int = Field(default=1, gt=0, le=16) max_concurrent_inference: int = Field(default=2, gt=0, le=64) - database_url: str | None = Field(default=None, min_length=1) workflow_poll_interval_seconds: float = Field( default=0.25, gt=0, @@ -147,7 +140,7 @@ class VidXPSettings(BaseSettings): default=50 * 1024 * 1024 * 1024, gt=0, ) - upload_principal_quota_bytes: int = Field( + upload_quota_bytes: int = Field( default=100 * 1024 * 1024 * 1024, gt=0, ) @@ -163,11 +156,6 @@ class VidXPSettings(BaseSettings): ) upload_quarantine_root: Path | None = None upload_cleanup_token: SecretStr | None = None - chroma_server_url: str | None = Field( - default=None, - min_length=1, - max_length=2048, - ) slm_base_url: str | None = Field( default=None, min_length=1, @@ -381,7 +369,6 @@ def _validate_oidc_url( @field_validator( "upload_public_endpoint", "upload_internal_endpoint", - "chroma_server_url", "slm_base_url", ) @classmethod @@ -422,11 +409,6 @@ def _validate_service_url( raise ValueError( "upload_public_endpoint must use HTTPS outside loopback." ) - if ( - info.field_name == "chroma_server_url" - and parsed.path not in {"", "/"} - ): - raise ValueError("chroma_server_url must not contain a path.") if info.field_name == "slm_base_url": if parsed.path.rstrip("/") != "/v1": raise ValueError("slm_base_url must end with /v1.") @@ -435,7 +417,7 @@ def _validate_service_url( "slm_base_url must use a self-hosted Ollama service." ) if ( - info.field_name not in {"chroma_server_url", "slm_base_url"} + info.field_name != "slm_base_url" and not value.endswith("/") ): raise ValueError(f"{info.field_name} must end with a slash.") @@ -646,11 +628,9 @@ def application_settings(self) -> VidXPSettings: defaults = VidXPSettings.model_construct().model_dump(mode="python") defaults.update(self.model_dump(mode="python")) defaults["mode"] = ApplicationMode.local - defaults["database_url"] = None defaults["upload_public_endpoint"] = None defaults["upload_internal_endpoint"] = None defaults["upload_cleanup_token"] = None - defaults["chroma_server_url"] = None defaults["http_auth_mode"] = HttpAuthMode.none defaults["http_static_bearer_token"] = None defaults["http_oidc_issuer"] = None diff --git a/src/vidxp/upload_service.py b/src/vidxp/upload_service.py index 9af129a..2a0bb90 100644 --- a/src/vidxp/upload_service.py +++ b/src/vidxp/upload_service.py @@ -72,6 +72,7 @@ def create_intent( request_key: str, ) -> UploadIntent: self._require_configured() + del principal if command.byte_size > self.settings.upload_max_bytes: raise ApplicationError( "upload_too_large", @@ -79,15 +80,12 @@ def create_intent( "The requested upload exceeds the configured limit.", ) if existing := self.catalog.get_upload_intent_by_request(request_key): - self._require_owner(existing, principal) self._require_same_request(existing, command) return _public_intent(existing) now = utc_now() record = UploadIntentRecord( intent_id=uuid4().hex, request_key=request_key, - repository_id=self.settings.repository_id, - owner_subject=principal.subject, original_filename=command.original_filename, byte_size=command.byte_size, declared_mime_type=command.declared_mime_type, @@ -100,7 +98,7 @@ def create_intent( return _public_intent( self.catalog.create_upload_intent( record, - quota_limit=self.settings.upload_principal_quota_bytes, + quota_limit=self.settings.upload_quota_bytes, ) ) except UploadQuotaExceededError as exc: @@ -112,7 +110,6 @@ def create_intent( except IntegrityError: replay = self.catalog.get_upload_intent_by_request(request_key) if replay is not None: - self._require_owner(replay, principal) self._require_same_request(replay, command) return _public_intent(replay) raise @@ -123,6 +120,7 @@ def get_intent( *, principal: Principal, ) -> UploadIntent: + del principal record = self.catalog.get_upload_intent(intent_id) if record is None: raise ApplicationError( @@ -130,7 +128,6 @@ def get_intent( ErrorCategory.not_found, "The requested upload intent was not found.", ) - self._require_owner(record, principal) return _public_intent(record) def upload_url( @@ -139,6 +136,7 @@ def upload_url( *, principal: Principal, ) -> str | None: + del principal record = self.catalog.get_upload_intent(intent_id) if record is None: raise ApplicationError( @@ -146,7 +144,6 @@ def upload_url( ErrorCategory.not_found, "The requested upload intent was not found.", ) - self._require_owner(record, principal) if record.state != UploadState.accepted or record.upload_id is None: return None assert self.settings.upload_public_endpoint is not None @@ -159,6 +156,7 @@ def accept_creation( principal: Principal, byte_size: int, ) -> UploadIntentRecord: + del principal now = utc_now() def accept(connection: Connection) -> UploadIntentRecord | None: @@ -173,13 +171,6 @@ def accept(connection: Connection) -> UploadIntentRecord | None: ErrorCategory.validation, "The upload intent is invalid.", ) - self._require_owner(record, principal) - if record.repository_id != self.settings.repository_id: - raise ApplicationError( - "upload_intent_invalid", - ErrorCategory.validation, - "The upload intent is invalid.", - ) if record.expires_at <= now: self.catalog.update_upload( record.intent_id, @@ -253,7 +244,6 @@ def complete(connection: Connection) -> str: if ( record is None or record.upload_id != upload_id - or record.repository_id != self.settings.repository_id ): raise ApplicationError( "upload_completion_invalid", @@ -652,21 +642,6 @@ def _require_configured(self) -> None: "Remote resumable uploads are not configured.", ) - def _require_owner( - self, - record: UploadIntentRecord, - principal: Principal, - ) -> None: - if ( - record.repository_id != self.settings.repository_id - or record.owner_subject != principal.subject - ): - raise ApplicationError( - "resource_not_found", - ErrorCategory.not_found, - "The requested upload intent was not found.", - ) - @staticmethod def _require_same_request( record: UploadIntentRecord, diff --git a/src/vidxp/workflow_runtime.py b/src/vidxp/workflow_runtime.py index a02208d..7f2b142 100644 --- a/src/vidxp/workflow_runtime.py +++ b/src/vidxp/workflow_runtime.py @@ -5,7 +5,6 @@ import sys from functools import lru_cache from pathlib import Path -from urllib.parse import urlsplit from pydantic import BaseModel, ConfigDict, Field @@ -20,6 +19,9 @@ LOCAL_WORKER_BOOTSTRAP_ENV = "VIDXP_LOCAL_WORKER_BOOTSTRAP_FILE" +BUNDLED_POSTGRES_DATABASE_URL = ( + "postgresql+psycopg://vidxp@postgres:5432/vidxp" +) class LocalWorkerBootstrap(BaseModel): @@ -52,20 +54,8 @@ class LocalWorkerStopRequest(BaseModel): def workflow_database_url(settings: VidXPSettings) -> str: - if settings.database_url is not None: - database_url = settings.database_url - if ( - settings.mode == ApplicationMode.server - and not urlsplit(database_url).scheme.startswith("postgresql") - ): - raise ValueError( - "Server mode requires a PostgreSQL workflow database URL." - ) - return database_url if settings.mode == ApplicationMode.server: - raise ValueError( - "Server mode requires VIDXP_DATABASE_URL." - ) + return BUNDLED_POSTGRES_DATABASE_URL database = settings.layout.workflow_database.resolve() return f"sqlite:///{database.as_posix()}" @@ -94,13 +84,9 @@ def _local_execution_provenance( } -def local_worker_bootstrap( - settings: VidXPSettings, - *, - database_url: str | None = None, -) -> LocalWorkerBootstrap: +def local_worker_bootstrap(settings: VidXPSettings) -> LocalWorkerBootstrap: local_settings = LocalExecutionSettings.from_settings(settings) - active_database_url = database_url or workflow_database_url(settings) + active_database_url = workflow_database_url(settings) identity = { "application_version": workflow_application_version(), "database_url": active_database_url, @@ -131,10 +117,10 @@ def local_executor_id(settings: VidXPSettings) -> str: return f"{workflow_application_version()}-local-0" -def server_executor_id(*, role: str, ordinal: int) -> str: - if role not in {"cpu", "gpu"} or ordinal < 0: - raise ValueError("The worker role or ordinal is invalid.") - return f"{workflow_application_version()}-{role}-{ordinal}" +def server_executor_id(*, role: str) -> str: + if role not in {"cpu", "gpu"}: + raise ValueError("The worker role is invalid.") + return f"{workflow_application_version()}-{role}-0" def sqlite_database_path(database_url: str) -> Path | None: diff --git a/src/vidxp/workflow_worker.py b/src/vidxp/workflow_worker.py index f5110bd..2c7c4a9 100644 --- a/src/vidxp/workflow_worker.py +++ b/src/vidxp/workflow_worker.py @@ -36,10 +36,8 @@ def _arguments(values: Sequence[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description="Run VidXP DBOS workflows.") - parser.add_argument("--database-url") parser.add_argument("--executor-id") parser.add_argument("--role", choices=("all", "cpu", "gpu"), default="cpu") - parser.add_argument("--ordinal", type=int, default=0) parser.add_argument("--lock-file", type=Path) parser.add_argument("--ready-file", type=Path) parser.add_argument("--stop-file", type=Path) @@ -78,17 +76,6 @@ def _local_bootstrap() -> LocalWorkerBootstrap | None: durable_unlink(path, missing_ok=True) -def _resolved_database_url( - settings: VidXPSettings, - override: str | None, -) -> str: - if override is None: - return workflow_database_url(settings) - return workflow_database_url( - settings.model_copy(update={"database_url": override}) - ) - - def run_worker( *, settings: VidXPSettings, @@ -157,29 +144,22 @@ def main(arguments: Sequence[str] | None = None) -> None: else VidXPSettings() ) if bootstrap is not None: - expected_bootstrap = local_worker_bootstrap( - settings, - database_url=bootstrap.database_url, - ) + expected_bootstrap = local_worker_bootstrap(settings) if expected_bootstrap.fingerprint != bootstrap.fingerprint: raise ValueError( "The local worker bootstrap identity is invalid." ) - database_override = ( + database_url = ( bootstrap.database_url if bootstrap is not None - else options.database_url + else workflow_database_url(settings) ) - database_url = _resolved_database_url(settings, database_override) role = options.role executor_id = options.executor_id if executor_id is None: if role == "all": raise ValueError("The all-queue worker requires an executor ID.") - executor_id = server_executor_id( - role=role, - ordinal=options.ordinal, - ) + executor_id = server_executor_id(role=role) if options.lock_file is None: stop_event = Event() diff --git a/tests/test_cli.py b/tests/test_cli.py index 1bfe81d..b949fa9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,6 +10,7 @@ from vidxp import cli from vidxp.composition import LocalApplicationContext, settings_for_repository +from vidxp.settings import ApplicationMode from vidxp.application_models import ( CreateIndexCommand, DependencyCheckResult, @@ -387,6 +388,18 @@ def test_repository_without_device_preserves_runtime_environment(self): self.assertEqual(settings.runtime_backend, "cpu") + def test_local_repository_ignores_server_mode_environment(self): + repository = RepositoryConfig( + "default", + Path("repo"), + device=None, + configured=False, + ) + with patch.dict(os.environ, {"VIDXP_MODE": "server"}): + settings = settings_for_repository(repository) + + self.assertEqual(settings.mode, ApplicationMode.local) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_database_cli.py b/tests/test_database_cli.py new file mode 100644 index 0000000..c88e54e --- /dev/null +++ b/tests/test_database_cli.py @@ -0,0 +1,23 @@ +import unittest +from unittest.mock import patch + +from vidxp.database_cli import main +from vidxp.workflow_runtime import BUNDLED_POSTGRES_DATABASE_URL + + +class DatabaseCliTests(unittest.TestCase): + def test_migrations_use_the_bundled_server_database(self): + with patch("vidxp.database_cli.upgrade_database") as upgrade: + main([]) + + upgrade.assert_called_once_with(BUNDLED_POSTGRES_DATABASE_URL) + + def test_database_url_command_line_override_is_not_supported(self): + with self.assertRaises(SystemExit) as raised: + main(["--database-url", "postgresql://external.example/vidxp"]) + + self.assertEqual(raised.exception.code, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_job_contracts.py b/tests/test_job_contracts.py index 3612fc5..817f05f 100644 --- a/tests/test_job_contracts.py +++ b/tests/test_job_contracts.py @@ -24,12 +24,16 @@ SearchCommand, SearchJobRequest, ) +from vidxp.core.storage import BUNDLED_CHROMA_SERVER_URL from vidxp.job_service import JobService from vidxp.ports import InvalidJobBackendRequestError from vidxp.execution import ExecutionContext +from vidxp.composition import _server_chroma_url from vidxp.settings import ApplicationMode, VidXPSettings -from vidxp.workflow_runtime import workflow_database_url -from vidxp.workflow_worker import _resolved_database_url +from vidxp.workflow_runtime import ( + BUNDLED_POSTGRES_DATABASE_URL, + workflow_database_url, +) MEDIA_ID = "123456781234423481234567890abcde" @@ -249,17 +253,34 @@ def test_canonical_dbos_job_id_has_a_hex_operation_identity(self): self.assertEqual(execution.operation_id, JOB_ID) - def test_server_workflow_database_must_be_postgres(self): + def test_local_storage_uses_repository_services(self): + settings = VidXPSettings( + repository_root=Path("repository"), + ) + + self.assertEqual( + workflow_database_url(settings), + ( + "sqlite:///" + f"{settings.layout.workflow_database.resolve().as_posix()}" + ), + ) + self.assertIsNone(_server_chroma_url(settings)) + + def test_server_uses_bundled_storage_services(self): settings = VidXPSettings( mode=ApplicationMode.server, runtime_backend="cpu", - database_url="sqlite:///jobs.sqlite3", ) - with self.assertRaisesRegex(ValueError, "PostgreSQL"): - workflow_database_url(settings) - with self.assertRaisesRegex(ValueError, "PostgreSQL"): - _resolved_database_url(settings, "sqlite:///override.sqlite3") + self.assertEqual( + workflow_database_url(settings), + BUNDLED_POSTGRES_DATABASE_URL, + ) + self.assertEqual( + _server_chroma_url(settings), + BUNDLED_CHROMA_SERVER_URL, + ) def test_job_backend_errors_are_normalized_for_every_adapter(self): backend = Mock() diff --git a/tests/test_local_worker.py b/tests/test_local_worker.py index 21c41d6..e631677 100644 --- a/tests/test_local_worker.py +++ b/tests/test_local_worker.py @@ -28,6 +28,7 @@ LocalWorkerStopRequest, local_worker_bootstrap, local_executor_id, + server_executor_id, workflow_application_version, workflow_database_url, ) @@ -43,6 +44,14 @@ def test_workflow_version_identifies_the_release_and_implementation(self): rf"^{re.escape(__version__)}\+[0-9a-f]{{16}}$", ) + def test_server_executor_identity_keeps_the_single_worker_ordinal(self): + version = workflow_application_version() + + self.assertEqual(server_executor_id(role="cpu"), f"{version}-cpu-0") + self.assertEqual(server_executor_id(role="gpu"), f"{version}-gpu-0") + with self.assertRaisesRegex(ValueError, "role"): + server_executor_id(role="other") + def test_worker_is_spawned_detached_without_job_state(self): with TemporaryDirectory() as directory: settings = VidXPSettings( @@ -149,7 +158,7 @@ def test_application_settings_ignore_inherited_vidxp_environment(self): self.assertEqual(settings.http_auth_mode, "none") self.assertIsNone(settings.http_static_bearer_token) - self.assertIsNone(settings.database_url) + self.assertNotIn("database_url", type(settings).model_fields) def test_startup_wait_finishes_when_worker_owns_lock(self): with TemporaryDirectory() as directory: diff --git a/tests/test_models.py b/tests/test_models.py index 7559093..86a1422 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -428,8 +428,8 @@ def test_server_runtime_and_external_allowlist_are_explicit(self): capability_allowlist=("acme-capabilities:ocr",), ) self.assertEqual(settings.runtime_backend, "cuda:0") - with self.assertRaises(ValidationError): - VidXPSettings(chroma_server_url="http://localhost:8000/prefix") + self.assertNotIn("database_url", VidXPSettings.model_fields) + self.assertNotIn("chroma_server_url", VidXPSettings.model_fields) with self.assertRaises(ValidationError): VidXPSettings(slm_base_url="http://localhost:11434/v1") with self.assertRaises(ValidationError): diff --git a/tests/test_upload_service.py b/tests/test_upload_service.py index 6d0f6b2..df6c1a0 100644 --- a/tests/test_upload_service.py +++ b/tests/test_upload_service.py @@ -60,7 +60,7 @@ def _service( upload_internal_endpoint="http://localhost:8080/uploads/", upload_cleanup_token="x" * 32, upload_max_bytes=quota, - upload_principal_quota_bytes=quota, + upload_quota_bytes=quota, ) return ( RemoteUploadService( @@ -82,7 +82,9 @@ def _command(size: int = 60) -> CreateUploadIntentCommand: ) -def test_upload_intent_is_idempotent_and_owner_scoped(tmp_path: Path) -> None: +def test_upload_intent_is_idempotent_and_repository_shared( + tmp_path: Path, +) -> None: service, catalog, _ = _service(tmp_path) owner = Principal(subject="owner") created = service.create_intent( @@ -99,12 +101,13 @@ def test_upload_intent_is_idempotent_and_owner_scoped(tmp_path: Path) -> None: ) == created ) - with pytest.raises(ApplicationError) as denied: + assert ( service.get_intent( created.intent_id, principal=Principal(subject="other"), ) - assert denied.value.detail.code == "resource_not_found" + == created + ) catalog.close() @@ -260,8 +263,6 @@ def test_expired_creation_persists_state_and_releases_quota( record = UploadIntentRecord( intent_id=uuid4().hex, request_key="a" * 64, - repository_id=service.settings.repository_id, - owner_subject="owner", original_filename="sample.mp4", byte_size=60, declared_mime_type="video/mp4", @@ -296,8 +297,6 @@ def test_active_resumable_upload_is_not_expired(tmp_path: Path) -> None: record = UploadIntentRecord( intent_id=uuid4().hex, request_key="a" * 64, - repository_id=service.settings.repository_id, - owner_subject="owner", original_filename="sample.mp4", byte_size=60, declared_mime_type="video/mp4", From fa7137e14d3d4e55b956e8e58188e7607b86fa23 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 18:01:22 +0500 Subject: [PATCH 17/46] fix: stabilize storage and publication foundations --- .github/workflows/ci.yml | 25 ++ .github/workflows/release-to-pypi.yml | 121 ++++++-- .github/workflows/release-to-test-pypi.yml | 7 + INSTALLATION_GUIDE.md | 16 +- README.md | 12 +- changes/publication-foundation.bugfix.md | 4 + desktop/package-lock.json | 4 +- desktop/package.json | 2 +- desktop/runtime-manifest.json | 6 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/src/lib.rs | 124 ++++++-- desktop/src-tauri/tauri.conf.json | 2 +- docs/architecture/platform.md | 2 +- docs/desktop.md | 19 +- docs/releasing.md | 20 +- pyproject.toml | 11 +- src/vidxp/application.py | 52 +++- src/vidxp/core/generations.py | 2 +- src/vidxp/core/manifest.py | 4 +- src/vidxp/core/snapshots.py | 2 +- src/vidxp/core/storage.py | 71 +++-- src/vidxp/index_state.py | 33 +++ src/vidxp/infrastructure/local_catalog.py | 18 +- src/vidxp/infrastructure/local_index.py | 60 ++-- src/vidxp/infrastructure/local_snapshots.py | 27 +- src/vidxp/infrastructure/sql_catalog.py | 5 +- src/vidxp/infrastructure/sql_snapshots.py | 76 +---- .../versions/20260729_01_catalog_uploads.py | 66 ++--- .../versions/20260729_02_single_repository.py | 264 ------------------ src/vidxp/ports.py | 2 +- tests/test_application.py | 26 +- tests/test_database_cli.py | 14 + tests/test_generation_manifest.py | 6 + tests/test_local_snapshots.py | 26 +- tests/test_media_catalog.py | 15 + tests/test_snapshots.py | 7 + tests/test_storage.py | 48 +++- tests/test_tusd_hooks.py | 7 +- tests/test_upload_service.py | 9 +- utils/build_package.sh | 2 + utils/sync_desktop_lock_versions.py | 85 ++++++ 42 files changed, 763 insertions(+), 543 deletions(-) create mode 100644 changes/publication-foundation.bugfix.md delete mode 100644 src/vidxp/migrations/versions/20260729_02_single_repository.py create mode 100644 utils/sync_desktop_lock_versions.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd1caac..00fbd49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,17 @@ on: - opened - synchronize - reopened + workflow_call: + inputs: + checkout_ref: + description: Exact commit to validate. + required: false + type: string + full: + description: Run every validation surface without PR path scoping. + required: false + default: false + type: boolean concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -26,15 +37,27 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 + ref: ${{ inputs.checkout_ref || github.sha }} - name: Determine validation scope id: scope shell: bash env: BASE_SHA: ${{ github.event.pull_request.base.sha }} + FORCE_FULL: ${{ inputs.full || false }} GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | + if [[ "$FORCE_FULL" == "true" ]]; then + { + echo "run_suite=true" + echo "run_container=true" + echo "require_changelog=false" + echo "needs_python=true" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + changed_files="$(git diff --name-only "$BASE_SHA" HEAD)" if grep -Eq '^(\.github/workflows/ci\.yml$|src/|tests/|utils/|LICENSE$|MANIFEST\.in$|pyproject\.toml$|uv\.lock$)' <<< "$changed_files"; then run_suite=true @@ -196,6 +219,8 @@ jobs: - macos-15 steps: - uses: actions/checkout@v7 + with: + ref: ${{ inputs.checkout_ref || github.sha }} - uses: actions/setup-python@v7 with: diff --git a/.github/workflows/release-to-pypi.yml b/.github/workflows/release-to-pypi.yml index 688d268..ae896a3 100644 --- a/.github/workflows/release-to-pypi.yml +++ b/.github/workflows/release-to-pypi.yml @@ -53,15 +53,6 @@ jobs: .release-smoke/bin/python -m pip check .release-smoke/bin/vidxp --version - - name: Publish GitHub release - if: steps.release.outputs.released == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - semantic-release publish --tag "${{ steps.release.outputs.tag }}" - gh release edit "${{ steps.release.outputs.tag }}" \ - --notes-file .release-notes.md - - name: Preserve distribution if: steps.release.outputs.released == 'true' uses: actions/upload-artifact@v7 @@ -71,9 +62,28 @@ jobs: if-no-files-found: error retention-days: 7 - publish-pypi: + - name: Preserve release notes + if: steps.release.outputs.released == 'true' + uses: actions/upload-artifact@v7 + with: + name: github-release-notes + path: .release-notes.md + if-no-files-found: error + retention-days: 7 + + validate-release: if: needs.release.outputs.released == 'true' needs: release + uses: ./.github/workflows/ci.yml + with: + checkout_ref: ${{ needs.release.outputs.commit_sha }} + full: true + + publish-pypi: + if: needs.release.outputs.released == 'true' + needs: + - release + - validate-release runs-on: ubuntu-latest environment: pypi permissions: @@ -94,7 +104,9 @@ jobs: if: >- github.ref == 'refs/heads/main' && needs.release.outputs.released == 'true' - needs: release + needs: + - release + - validate-release runs-on: ubuntu-latest permissions: contents: read @@ -134,6 +146,28 @@ jobs: tags: ${{ steps.metadata.outputs.tags }} labels: ${{ steps.metadata.outputs.labels }} + - name: Build stable control container + uses: docker/build-push-action@v7 + with: + context: . + load: true + platforms: linux/amd64 + push: false + target: control + tags: ghcr.io/${{ github.repository }}:${{ needs.release.outputs.version }}-control + labels: ${{ steps.metadata.outputs.labels }} + + - name: Build stable worker container + uses: docker/build-push-action@v7 + with: + context: . + load: true + platforms: linux/amd64 + push: false + target: worker + tags: ghcr.io/${{ github.repository }}:${{ needs.release.outputs.version }}-worker + labels: ${{ steps.metadata.outputs.labels }} + - name: Smoke stable container shell: bash run: | @@ -164,6 +198,19 @@ jobs: docker logs vidxp-release exit 1 + - name: Smoke stable server containers + shell: bash + run: | + for target in control worker; do + image="ghcr.io/${{ github.repository }}:${{ needs.release.outputs.version }}-${target}" + docker run --rm "$image" vidxp --version + done + control="ghcr.io/${{ github.repository }}:${{ needs.release.outputs.version }}-control" + worker="ghcr.io/${{ github.repository }}:${{ needs.release.outputs.version }}-worker" + docker run --rm "$control" vidxp-api --help + docker run --rm "$control" vidxp-mcp --help + docker run --rm "$worker" vidxp doctor --json + - name: Log in to GitHub Container Registry uses: docker/login-action@v4 with: @@ -178,8 +225,50 @@ jobs: shell: bash run: | docker logout ghcr.io - image="ghcr.io/${{ github.repository }}:${{ needs.release.outputs.version }}" - if ! docker buildx imagetools inspect "$image"; then - echo "::error::Make the GHCR package public, then rerun this failed job." - exit 1 - fi + for suffix in "" "-control" "-worker"; do + image="ghcr.io/${{ github.repository }}:${{ needs.release.outputs.version }}${suffix}" + if ! docker buildx imagetools inspect "$image"; then + echo "::error::Make the GHCR package public, then rerun this failed job." + exit 1 + fi + done + + publish-github: + if: needs.release.outputs.released == 'true' + needs: + - release + - validate-release + - publish-pypi + - publish-container + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ needs.release.outputs.commit_sha }} + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.14" + + - name: Install release tooling + run: | + python -m pip install --upgrade pip + python -m pip install -r utils/build-requirements.txt + + - uses: actions/download-artifact@v8 + with: + name: github-release-notes + path: . + + - name: Publish GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + semantic-release publish --tag "${{ needs.release.outputs.tag }}" + gh release edit "${{ needs.release.outputs.tag }}" \ + --notes-file .release-notes.md diff --git a/.github/workflows/release-to-test-pypi.yml b/.github/workflows/release-to-test-pypi.yml index f7440c8..20180da 100644 --- a/.github/workflows/release-to-test-pypi.yml +++ b/.github/workflows/release-to-test-pypi.yml @@ -23,7 +23,14 @@ concurrency: cancel-in-progress: false jobs: + validate: + uses: ./.github/workflows/ci.yml + with: + checkout_ref: ${{ github.sha }} + full: true + prerelease: + needs: validate runs-on: ubuntu-latest permissions: contents: write diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md index e060ec7..e8deb62 100644 --- a/INSTALLATION_GUIDE.md +++ b/INSTALLATION_GUIDE.md @@ -10,7 +10,8 @@ dependencies are never installed accidentally. - Apple Silicon macOS 14 or newer - Linux x86-64 - Windows x86-64 -- FFmpeg and ffprobe on `PATH` for video/audio processing +- FFmpeg and ffprobe on `PATH` for video/audio processing. VidXP wheels do not + install or modify operating-system packages. The actor capability uses OpenCV YuNet and SFace. It does not use `dlib`, CMake, or a local C++ compiler. @@ -120,7 +121,7 @@ Model weights are cached separately from the Python environment: | Capability | Provider/model | |---|---| | Dialogue embeddings | `Qwen/Qwen3-Embedding-0.6B` | -| Transcription | `mobiuslabsgmbh/faster-whisper-large-v3-turbo` | +| Transcription | `dropbox-dash/faster-whisper-large-v3-turbo` | | Scene search | `google/siglip2-base-patch16-224` | | Actor detection | OpenCV Zoo YuNet | | Actor recognition | OpenCV Zoo SFace | @@ -192,15 +193,18 @@ VIDXP_PORT=8501 VIDXP_DEVICE=cpu ``` -The separate API+MCP/Coolify composition belongs to the later server phases and -is not claimed by this image yet. +The local image above is distinct from the prebuilt server topology. Stable +releases also publish `-control` and `-worker` images used by +the [Coolify deployment](docs/deployment/coolify.md). ## Common problems ### FFmpeg is not found -Run `ffmpeg -version` and `ffprobe -version` in the same terminal. Install -FFmpeg or add its executable directory to `PATH`, then rerun `vidxp doctor`. +Run `vidxp doctor`; it checks both executables without downloading or changing +the machine. If either check fails, run `ffmpeg -version` and `ffprobe -version` +in the same terminal, install FFmpeg with the operating-system package manager +or add its executable directory to `PATH`, then rerun the doctor command. ### Linux pulls CUDA or NVIDIA packages diff --git a/README.md b/README.md index 88771f9..6087d27 100644 --- a/README.md +++ b/README.md @@ -80,10 +80,12 @@ Some ideas on how to use VidXP: ## Quick start -VidXP supports Python 3.11 through 3.14 and requires FFmpeg for media -processing. See the [installation guide](INSTALLATION_GUIDE.md) for -platform-specific local-worker installation, model preparation, and -troubleshooting. +VidXP supports Python 3.11 through 3.14. Media operations require the +`ffmpeg` and `ffprobe` executables on `PATH`; Python wheels intentionally do +not install operating-system packages. `vidxp doctor` reports either missing +executable before media work begins. See the +[installation guide](INSTALLATION_GUIDE.md) for platform-specific +local-worker installation, model preparation, and troubleshooting. Install the lightweight command line in an isolated environment with [pipx](https://packaging.python.org/en/latest/guides/installing-stand-alone-command-line-tools/). @@ -361,7 +363,7 @@ VidXP is an evolving beta. We'd love to hear your feedback and where you'd like | Capability | Model | |---|---| | Dialogue embeddings | `Qwen/Qwen3-Embedding-0.6B` | -| Transcription | `mobiuslabsgmbh/faster-whisper-large-v3-turbo` | +| Transcription | `dropbox-dash/faster-whisper-large-v3-turbo` | | Scene search | `google/siglip2-base-patch16-224` | | Actor detection | OpenCV Zoo YuNet | | Actor recognition | OpenCV Zoo SFace | diff --git a/changes/publication-foundation.bugfix.md b/changes/publication-foundation.bugfix.md new file mode 100644 index 0000000..7cb4129 --- /dev/null +++ b/changes/publication-foundation.bugfix.md @@ -0,0 +1,4 @@ +Simplify the unreleased single-repository schema, harden local and remote Chroma +storage contracts, validate FFmpeg dependencies before media work, and make +desktop/server release artifacts resolve and publish from the exact validated +release commit. diff --git a/desktop/package-lock.json b/desktop/package-lock.json index edbaf8d..b6972e3 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "vidxp-desktop", - "version": "0.2.1-beta.1", + "version": "0.2.1-b.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vidxp-desktop", - "version": "0.2.1-beta.1", + "version": "0.2.1-b.1", "devDependencies": { "@tauri-apps/cli": "2.11.4" } diff --git a/desktop/package.json b/desktop/package.json index 9e8fac2..d45831b 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "vidxp-desktop", "private": true, - "version": "0.2.1-beta.1", + "version": "0.2.1-b.1", "type": "module", "scripts": { "tauri": "tauri", diff --git a/desktop/runtime-manifest.json b/desktop/runtime-manifest.json index 611fd46..cc0c845 100644 --- a/desktop/runtime-manifest.json +++ b/desktop/runtime-manifest.json @@ -1,9 +1,9 @@ { "schema_version": 1, - "desktop_version": "0.2.1-beta.1", + "desktop_version": "0.2.1-b.1", "package_name": "vidxp", - "package_version": "0.2.1b1", - "package_index": "https://pypi.org/simple", + "package_version": "0.2.1-b.1", + "dependency_index": "https://pypi.org/simple", "python_version": "3.14.6", "uv_version": "0.12.0", "always_install_extras": [ diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 03ed936..490d7eb 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -4260,7 +4260,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vidxp-desktop" -version = "0.2.1-beta.1" +version = "0.2.1-b.1" dependencies = [ "atomic-write-file", "hex", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index a5ff30a..3124280 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vidxp-desktop" -version = "0.2.1-beta.1" +version = "0.2.1-b.1" description = "A thin desktop installer and supervisor for VidXP" edition = "2024" rust-version = "1.85" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e244023..b0ed451 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -45,7 +45,7 @@ struct RuntimeManifest { desktop_version: String, package_name: String, package_version: String, - package_index: String, + dependency_index: String, python_version: String, uv_version: String, always_install_extras: Vec, @@ -188,6 +188,58 @@ fn package_specification(manifest: &RuntimeManifest, capabilities: &[String]) -> ) } +fn base_package_specification(manifest: &RuntimeManifest) -> String { + format!("{}=={}", manifest.package_name, manifest.package_version) +} + +fn package_index(package_version: &str) -> &'static str { + if package_version.split_once('-').is_some() { + "https://test.pypi.org/simple" + } else { + "https://pypi.org/simple" + } +} + +fn package_acquisition_arguments(manifest: &RuntimeManifest, python: &Path) -> Vec { + vec![ + "pip".into(), + "install".into(), + "--python".into(), + python.to_string_lossy().into_owned(), + "--no-config".into(), + "--no-deps".into(), + "--default-index".into(), + package_index(&manifest.package_version).into(), + "--index-strategy".into(), + "first-index".into(), + base_package_specification(manifest), + ] +} + +fn dependency_installation_arguments( + manifest: &RuntimeManifest, + capabilities: &[String], + python: &Path, + cpu_torch: bool, +) -> Vec { + let mut arguments = vec![ + "pip".into(), + "install".into(), + "--python".into(), + python.to_string_lossy().into_owned(), + "--no-config".into(), + "--default-index".into(), + manifest.dependency_index.clone(), + "--index-strategy".into(), + "first-index".into(), + ]; + if cpu_torch { + arguments.extend(["--torch-backend".into(), "cpu".into()]); + } + arguments.push(package_specification(manifest, capabilities)); + arguments +} + fn capability_command_arguments( manifest: &RuntimeManifest, operation: &str, @@ -529,28 +581,25 @@ async fn install_runtime( ) .await?; - let mut install_arguments = vec![ - "pip".into(), - "install".into(), - "--python".into(), - executable(&staging, "python") - .to_string_lossy() - .into_owned(), - "--no-config".into(), - "--default-index".into(), - manifest.package_index.clone(), - "--index-strategy".into(), - "first-index".into(), - ]; - if !cfg!(target_os = "macos") { - install_arguments.extend(["--torch-backend".into(), "cpu".into()]); - } - install_arguments.push(package_specification(&manifest, &capabilities)); uv_output( &app, &state, &paths, - install_arguments, + package_acquisition_arguments(&manifest, &executable(&staging, "python")), + "VidXP package acquisition", + ) + .await?; + + uv_output( + &app, + &state, + &paths, + dependency_installation_arguments( + &manifest, + &capabilities, + &executable(&staging, "python"), + !cfg!(target_os = "macos"), + ), "VidXP package installation", ) .await?; @@ -772,8 +821,11 @@ pub fn run() { #[cfg(test)] mod tests { use super::{ - capability_command_arguments, manifest, package_specification, selected_capabilities, + base_package_specification, capability_command_arguments, + dependency_installation_arguments, manifest, package_acquisition_arguments, package_index, + package_specification, selected_capabilities, }; + use std::path::Path; #[test] fn capability_selection_is_sorted_and_rejects_unknown_values() { @@ -792,10 +844,40 @@ mod tests { fn package_specification_has_one_sorted_extra_set() { let manifest = manifest().expect("manifest"); + assert_eq!(base_package_specification(&manifest), "vidxp==0.2.1-b.1"); assert_eq!( package_specification(&manifest, &["scene".into(), "dialogue".into()]), - "vidxp[dialogue,frontend,scene]==0.2.1b1" + "vidxp[dialogue,frontend,scene]==0.2.1-b.1" + ); + } + + #[test] + fn prerelease_package_and_dependencies_use_separate_indexes() { + let manifest = manifest().expect("manifest"); + let python = Path::new("managed-python"); + let acquisition = package_acquisition_arguments(&manifest, python); + let dependencies = + dependency_installation_arguments(&manifest, &["scene".into()], python, true); + + assert_eq!( + package_index(&manifest.package_version), + "https://test.pypi.org/simple" + ); + assert_eq!(package_index("0.3.0"), "https://pypi.org/simple"); + assert_eq!(manifest.dependency_index, "https://pypi.org/simple"); + assert!(acquisition.iter().any(|item| item == "--no-deps")); + assert!( + acquisition + .iter() + .any(|item| item == "https://test.pypi.org/simple") + ); + assert!(!dependencies.iter().any(|item| item == "--no-deps")); + assert!( + dependencies + .iter() + .any(|item| item == &manifest.dependency_index) ); + assert!(dependencies.iter().any(|item| item == "--torch-backend")); } #[test] diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 7d31112..455433c 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "VidXP", - "version": "0.2.1-beta.1", + "version": "0.2.1-b.1", "identifier": "dev.grayhat.vidxp", "build": { "frontendDist": "../web" diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md index 658c8da..d745867 100644 --- a/docs/architecture/platform.md +++ b/docs/architecture/platform.md @@ -632,7 +632,7 @@ composition root and is sorted deterministically. blocks current Python and constrains an older Torch family. The first replacement is latest stable `faster-whisper`/CTranslate2 using batched transcription, VAD, and word timestamps. The default is - `mobiuslabsgmbh/faster-whisper-large-v3-turbo` at immutable revision + `dropbox-dash/faster-whisper-large-v3-turbo` at immutable revision `0a363e9161cbc7ed1431c9597a8ceaf0c4f78fcf`. Its transcript/timestamp output is tested against the existing dialogue contract. Forced alignment is an optional provider behind a separate diff --git a/docs/desktop.md b/docs/desktop.md index 54577e0..3d0f847 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -3,7 +3,8 @@ The desktop application is a thin Tauri v2 installer and process supervisor. It does not contain a second VidXP implementation: -- the selected capability extras are installed from the exact PyPI release; +- the selected capability extras are installed from the exact configured + package release; - the existing Streamlit adapter remains the local human interface; - the repository, model cache, managed Python, and package environments use platform application-data directories; @@ -43,6 +44,12 @@ outputs are ignored by Git. Users select dialogue, scene, and actor capabilities independently. The installer always adds the `frontend` extra and uses a single sorted extra set. +For the current beta, it first acquires only the exact VidXP package from +TestPyPI with dependency resolution disabled. It then resolves that installed +package's selected extras from production PyPI. This prevents TestPyPI from +becoming a competing source for transitive dependencies. The acquisition index +is derived from the stamped package version: prereleases use TestPyPI and stable +versions use production PyPI. Windows and Linux resolve CPU-only PyTorch wheels using uv's `--torch-backend cpu`; macOS uses native PyPI wheels. The custom PyTorch index is therefore a resolver input and is not embedded as a package URL, avoiding @@ -53,10 +60,12 @@ the selected modalities. Setup subprocesses are owned by the Tauri supervisor; closing the app cancels the active process and stops a preparation worker before exit. No model is bundled in the installer. -FFmpeg and ffprobe are currently validated as system dependencies. Target -binaries will not be bundled until the exact build provenance, enabled codecs, -and redistribution licenses are recorded. This is an explicit packaging gate, -not a silent download from an unaudited third party. +`vidxp doctor` validates FFmpeg and ffprobe as system dependencies before +activating the staged runtime. The Python package and desktop bootstrap do not +install OS packages. Target binaries will not be bundled until the exact build +provenance, enabled codecs, and redistribution licenses are recorded. This is +an explicit packaging gate, not a silent download from an unaudited third +party. ## Release targets diff --git a/docs/releasing.md b/docs/releasing.md index eaf514e..a284268 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -6,9 +6,10 @@ publication branch. ## Prereleases -A release-relevant merge to `main` runs CI, lets Python Semantic Release -calculate the next version, creates a `b` prerelease tag and GitHub prerelease, -builds the distributions, and publishes them to TestPyPI. Commits that do not +A release-relevant merge to `main` first calls the full reusable CI workflow +for that exact commit. Only a successful gate lets Python Semantic Release +calculate the next version, create a `b` prerelease tag and GitHub prerelease, +build the distributions, and publish them to TestPyPI. Commits that do not require a semantic version bump do not publish a package. Towncrier renders the pending fragments into the GitHub prerelease body without @@ -23,12 +24,19 @@ consuming them. The fragments remain in `changes/` for the stable release. 5. Confirm the new tag, GitHub release, PyPI package, and emptied pending fragment set. -The workflow only runs from a `main` dispatch, and every publication job uses -the release commit created from that immutable revision. Python Semantic +The workflow only runs from a `main` dispatch. It creates the release commit, +runs the full reusable CI workflow against that exact commit SHA, and allows no +registry publication unless that gate succeeds. PyPI and the local, control, +and worker containers are published from the same release commit; the GitHub +release is published only after both registry jobs succeed. Python Semantic Release is the only version authority. Towncrier is the only release-note renderer. During the stable build it receives the calculated version, renders the GitHub release body, updates `CHANGELOG.md` with the same section, and removes the released fragments before the release commit and tag are created. Release and CI tools are declared once in `utils/build-requirements.txt`. Do not -duplicate their versions in workflow files. +duplicate their versions in workflow files. Python Semantic Release stamps the +Python, Cargo, npm, Tauri, and runtime-manifest source versions directly. The +build helper only mirrors that already-stamped value into the generated npm and +Cargo lockfiles, whose repeated version keys cannot be targeted safely by +Semantic Release. diff --git a/pyproject.toml b/pyproject.toml index 4ba50ee..f60949d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -137,7 +137,16 @@ url = "https://download.pytorch.org/whl/cpu" explicit = true [tool.semantic_release] -version_toml = ["pyproject.toml:project.version"] +version_toml = [ + "pyproject.toml:project.version", + "desktop/src-tauri/Cargo.toml:package.version", +] +version_variables = [ + "desktop/package.json:version", + "desktop/runtime-manifest.json:desktop_version", + "desktop/runtime-manifest.json:package_version", + "desktop/src-tauri/tauri.conf.json:version", +] tag_format = "v{version}" build_command = "bash ./utils/build_package.sh" build_command_env = ["BUILD_CHANGELOG", "BUILD_RELEASE_NOTES"] diff --git a/src/vidxp/application.py b/src/vidxp/application.py index fb1d264..daf89c9 100644 --- a/src/vidxp/application.py +++ b/src/vidxp/application.py @@ -2,6 +2,7 @@ from contextlib import contextmanager from pathlib import Path +from shutil import which from typing import Any, Callable, Iterator, Mapping, cast from pydantic import BaseModel @@ -10,12 +11,14 @@ from vidxp.application_models import ( ApplicationError, Artifact, + CapabilityDependencyCheck, ComponentReadiness, CreateIndexCommand, CreateActorOverlayCommand, CreateSnippetCommand, DependencyCheckCommand, DependencyCheckResult, + DependencyKind, DependencyUnavailableError, ErrorCategory, FusedSearchResult, @@ -244,9 +247,16 @@ def check_dependencies( command: DependencyCheckCommand, ) -> DependencyCheckResult: selected = self.registry.validate_names(command.modalities) - checks = self.registry.dependency_checks( - selected, - include_runtime_checks=command.include_runtime_checks, + checks = ( + *self.registry.dependency_checks( + selected, + include_runtime_checks=command.include_runtime_checks, + ), + *( + self._media_runtime_checks() + if command.include_runtime_checks + else () + ), ) return DependencyCheckResult( ok=all(check.ok for check in checks), @@ -254,6 +264,42 @@ def check_dependencies( checks=checks, ) + def _media_runtime_checks( + self, + ) -> tuple[CapabilityDependencyCheck, ...]: + checks = [] + for name, executable, setting in ( + ( + "ffmpeg", + self.settings.ffmpeg_executable, + "VIDXP_FFMPEG_EXECUTABLE", + ), + ( + "ffprobe", + self.settings.ffprobe_executable, + "VIDXP_FFPROBE_EXECUTABLE", + ), + ): + resolved = which(executable) + checks.append( + CapabilityDependencyCheck( + capability="media", + kind=DependencyKind.runtime, + name=name, + ok=resolved is not None, + error=( + None + if resolved is not None + else ( + f"{executable!r} is unavailable. Install FFmpeg " + "with the operating-system package manager or set " + f"{setting}; VidXP does not install OS packages." + ) + ), + ) + ) + return tuple(checks) + @application_boundary def prepare_models( self, diff --git a/src/vidxp/core/generations.py b/src/vidxp/core/generations.py index 03961eb..690db5b 100644 --- a/src/vidxp/core/generations.py +++ b/src/vidxp/core/generations.py @@ -84,7 +84,7 @@ class CompletedGenerationManifest(_GenerationManifestModel): interrupted_videos: tuple[Identifier, ...] = Field(max_length=0) processed_frames: NonNegativeInt record_counts: dict[Identifier, NonNegativeInt] - store_size_bytes_at_commit: NonNegativeInt + store_size_bytes_at_commit: NonNegativeInt | None @model_validator(mode="after") def _validate_completed_generation(self) -> CompletedGenerationManifest: diff --git a/src/vidxp/core/manifest.py b/src/vidxp/core/manifest.py index 47bd765..f0790f7 100644 --- a/src/vidxp/core/manifest.py +++ b/src/vidxp/core/manifest.py @@ -324,7 +324,7 @@ def initialize( "interrupted_videos": [], "processed_frames": 0, "record_counts": {}, - "store_size_bytes_at_commit": 0, + "store_size_bytes_at_commit": None, } if reset: for path in ( @@ -529,7 +529,7 @@ def interrupt_video(self, video_id: str, stage: str) -> None: def complete_run( self, *, - store_size_bytes_at_commit: int, + store_size_bytes_at_commit: int | None, ) -> dict[str, Any]: manifest = self.read() self._refresh_runtime(manifest) diff --git a/src/vidxp/core/snapshots.py b/src/vidxp/core/snapshots.py index d300920..b03c2fe 100644 --- a/src/vidxp/core/snapshots.py +++ b/src/vidxp/core/snapshots.py @@ -39,7 +39,7 @@ class GenerationReference(_SnapshotModel): config_fingerprint: Sha256 modalities: tuple[Identifier, ...] = Field(min_length=1) record_counts: dict[Identifier, int] - store_size_bytes_at_commit: int = Field(ge=0) + store_size_bytes_at_commit: int | None = Field(ge=0) @model_validator(mode="after") def _validate_record_counts(self) -> GenerationReference: diff --git a/src/vidxp/core/storage.py b/src/vidxp/core/storage.py index c125999..6dfd9d5 100644 --- a/src/vidxp/core/storage.py +++ b/src/vidxp/core/storage.py @@ -20,6 +20,25 @@ class IndexStorageUnavailableError(RuntimeError): """The configured remote vector store could not serve an operation.""" +def _is_remote_unavailable(exc: Exception) -> bool: + """Return whether an exception represents a retryable remote failure.""" + + import httpx + from chromadb.errors import ( + InternalError, + RateLimitError, + ) + + return isinstance( + exc, + ( + httpx.TransportError, + InternalError, + RateLimitError, + ), + ) + + def metadata_filter( config: IndexConfig, *, @@ -106,28 +125,36 @@ def __init__( self.path = config.index_directory self._create = create self._client_factory = client_factory or ChromaClientFactory() + self._names = dict(config.collection_names) + self._collections: dict[str, Any] = {} if create and not self._client_factory.remote: self.path.mkdir(parents=True, exist_ok=True) - elif ( - not self._client_factory.remote - and ( - not self.path.is_dir() - or not (self.path / "chroma.sqlite3").is_file() - ) - ): + elif not self._client_factory.remote and not self.path.is_dir(): raise FileNotFoundError( f"The committed Chroma store is missing at {self.path}." ) try: self.client = self._client_factory.create(self.path) except Exception as exc: - if self._client_factory.remote: + if ( + self._client_factory.remote + and _is_remote_unavailable(exc) + ): raise IndexStorageUnavailableError( "The remote Chroma service is unavailable." ) from exc raise - self._names = dict(config.collection_names) - self._collections: dict[str, Any] = {} + if not create: + try: + existing = self._call(self.client.list_collections) + except Exception: + self.close() + raise + if not existing: + self.close() + raise FileNotFoundError( + f"The committed Chroma store is missing at {self.path}." + ) def close(self) -> None: self._collections.clear() @@ -158,17 +185,14 @@ def collection(self, modality: str): from chromadb.errors import NotFoundError try: - collection = self.client.get_collection(name=name) + collection = self._call( + self.client.get_collection, + name=name, + ) except NotFoundError as exc: raise FileNotFoundError( f"Committed Chroma collection {name!r} is missing." ) from exc - except Exception as exc: - if self._client_factory.remote: - raise IndexStorageUnavailableError( - "The remote Chroma service is unavailable." - ) from exc - raise configuration = getattr(collection, "configuration", {}) or {} actual_distance = (configuration.get("hnsw") or {}).get("space") if ( @@ -186,7 +210,10 @@ def _call(self, operation: Callable[..., _T], /, *args, **kwargs) -> _T: try: return operation(*args, **kwargs) except Exception as exc: - if self._client_factory.remote: + if ( + self._client_factory.remote + and _is_remote_unavailable(exc) + ): raise IndexStorageUnavailableError( "The remote Chroma service is unavailable." ) from exc @@ -372,8 +399,10 @@ def count_records( ) return len(result.get("ids") or ()) - def size_bytes(self) -> int: - return 0 if self._client_factory.remote else directory_size(self.path) + def size_bytes(self) -> int | None: + if self._client_factory.remote: + return None + return directory_size(self.path) class SnapshotScopedIndexStore: @@ -397,7 +426,7 @@ def __enter__(self) -> "SnapshotScopedIndexStore": def __exit__(self, *args: Any) -> Any: return self._store.__exit__(*args) - def size_bytes(self) -> int: + def size_bytes(self) -> int | None: return self._store.size_bytes() def query( diff --git a/src/vidxp/index_state.py b/src/vidxp/index_state.py index af2bbd6..3fcd530 100644 --- a/src/vidxp/index_state.py +++ b/src/vidxp/index_state.py @@ -1,3 +1,11 @@ +from __future__ import annotations + +from typing import Any + +from vidxp.core.contracts import INDEX_SCHEMA_VERSION +from vidxp.core.snapshots import IndexSnapshot + + INDEX_STATUS_SCHEMA = 1 INDEX_STATUS_MEDIA_ID_LIMIT = 100 @@ -10,6 +18,31 @@ def bounded_media_ids(media_ids: list[str]) -> dict[str, object]: } +def snapshot_status(snapshot: IndexSnapshot) -> dict[str, Any]: + media_ids = sorted(snapshot.generations) + ready = bool(media_ids) + return { + "schema_version": INDEX_STATUS_SCHEMA, + "state": "ready" if ready else "empty", + "stage": "status", + "message": ( + f"The active snapshot contains {len(media_ids)} media item(s)." + if ready + else "The active index snapshot is empty." + ), + "updated_at": snapshot.created_at.isoformat(), + "summary": { + "index_schema_version": INDEX_SCHEMA_VERSION, + "snapshot_id": snapshot.snapshot_id, + "media_count": len(media_ids), + **bounded_media_ids(media_ids), + "modalities": tuple( + snapshot.configuration.get("enabled_modalities", ()) + ), + }, + } + + class IndexNotReadyError(RuntimeError): """Raised when a search is attempted without a completed index.""" diff --git a/src/vidxp/infrastructure/local_catalog.py b/src/vidxp/infrastructure/local_catalog.py index b5cb42a..5d337b8 100644 --- a/src/vidxp/infrastructure/local_catalog.py +++ b/src/vidxp/infrastructure/local_catalog.py @@ -5,6 +5,13 @@ from sqlalchemy import Column, Integer, MetaData, Table, insert, select, update from vidxp.infrastructure.sql_catalog import SQLCatalog +from vidxp.infrastructure.sql_tables import ( + artifact_requests, + artifacts, + media, + media_import_requests, + metadata, +) CATALOG_SCHEMA_VERSION = 3 _local_metadata = MetaData() @@ -22,7 +29,16 @@ def __init__(self, database: Path) -> None: database.parent.mkdir(parents=True, exist_ok=True) super().__init__( f"sqlite:///{database.resolve().as_posix()}", - initialize=True, + initialize=False, + ) + metadata.create_all( + self.engine, + tables=( + media, + artifacts, + artifact_requests, + media_import_requests, + ), ) _local_metadata.create_all(self.engine) with self.transaction() as connection: diff --git a/src/vidxp/infrastructure/local_index.py b/src/vidxp/infrastructure/local_index.py index 99920cc..e333d3e 100644 --- a/src/vidxp/infrastructure/local_index.py +++ b/src/vidxp/infrastructure/local_index.py @@ -467,39 +467,41 @@ def _cleanup_abandoned_generations( snapshot_id=None, snapshot_sha256=None, ) - database = repository.store / "chroma.sqlite3" - if clients.remote or database.is_file(): - with IndexStorage( - cleanup_config, - create=False, - client_factory=clients, - ) as storage: - stored_ids: set[str] = set() - for modality in cleanup_config.enabled_modalities: - try: - records = storage.records(modality) - except FileNotFoundError: - continue - for record in records: - generation_id = record.get("generation_id") - if ( - isinstance(generation_id, str) - and LocalIndexBackend._is_generation_id( - generation_id - ) - ): - stored_ids.add(generation_id) - for generation_id in sorted( - set(incomplete) | (stored_ids - completed) - ): + if clients.remote or repository.store.is_dir(): + try: + with IndexStorage( + cleanup_config, + create=False, + client_factory=clients, + ) as storage: + stored_ids: set[str] = set() for modality in cleanup_config.enabled_modalities: try: - storage.delete_generation( - generation_id, - modalities=(modality,), - ) + records = storage.records(modality) except FileNotFoundError: continue + for record in records: + generation_id = record.get("generation_id") + if ( + isinstance(generation_id, str) + and LocalIndexBackend._is_generation_id( + generation_id + ) + ): + stored_ids.add(generation_id) + for generation_id in sorted( + set(incomplete) | (stored_ids - completed) + ): + for modality in cleanup_config.enabled_modalities: + try: + storage.delete_generation( + generation_id, + modalities=(modality,), + ) + except FileNotFoundError: + continue + except FileNotFoundError: + pass for generation_id, path in incomplete.items(): resolved = path.resolve() diff --git a/src/vidxp/infrastructure/local_snapshots.py b/src/vidxp/infrastructure/local_snapshots.py index 43f95e2..e364f27 100644 --- a/src/vidxp/infrastructure/local_snapshots.py +++ b/src/vidxp/infrastructure/local_snapshots.py @@ -11,7 +11,6 @@ from pydantic import ValidationError from vidxp.core.contracts import ( - INDEX_SCHEMA_VERSION, IndexConfig, IndexSchemaError, ) @@ -25,7 +24,7 @@ from vidxp.index_state import ( IndexNotReadyError, IndexingInProgressError, - bounded_media_ids, + snapshot_status, ) from vidxp.repository_layout import RepositoryLayout @@ -480,26 +479,4 @@ def status(self) -> dict[str, Any] | None: resolved = self._read_active() if resolved is None: return None - _, snapshot = resolved - media_ids = sorted(snapshot.generations) - ready = bool(media_ids) - return { - "schema_version": 1, - "state": "ready" if ready else "empty", - "stage": "status", - "message": ( - f"The active snapshot contains {len(media_ids)} media item(s)." - if ready - else "The active index snapshot is empty." - ), - "updated_at": snapshot.created_at.isoformat(), - "summary": { - "index_schema_version": INDEX_SCHEMA_VERSION, - "snapshot_id": snapshot.snapshot_id, - "media_count": len(media_ids), - **bounded_media_ids(media_ids), - "modalities": tuple( - snapshot.configuration.get("enabled_modalities", ()) - ), - }, - } + return snapshot_status(resolved[1]) diff --git a/src/vidxp/infrastructure/sql_catalog.py b/src/vidxp/infrastructure/sql_catalog.py index fce4680..695505a 100644 --- a/src/vidxp/infrastructure/sql_catalog.py +++ b/src/vidxp/infrastructure/sql_catalog.py @@ -3,7 +3,6 @@ from collections.abc import Callable, Iterator from contextlib import contextmanager from datetime import datetime -import json from typing import Any from sqlalchemy import Engine, create_engine, delete, event, func, insert, select, update @@ -44,9 +43,7 @@ def _payload(value: Any) -> dict[str, Any]: def _record(model: Any, value: Any) -> Any: - return model.model_validate_json( - json.dumps(_payload(value), separators=(",", ":")) - ) + return model.model_validate(_payload(value), strict=False) def _upload_record(row: Any) -> UploadIntentRecord: diff --git a/src/vidxp/infrastructure/sql_snapshots.py b/src/vidxp/infrastructure/sql_snapshots.py index 7ce42f7..43a1947 100644 --- a/src/vidxp/infrastructure/sql_snapshots.py +++ b/src/vidxp/infrastructure/sql_snapshots.py @@ -13,14 +13,14 @@ from sqlalchemy.exc import IntegrityError from pydantic import ValidationError -from vidxp.core.contracts import INDEX_SCHEMA_VERSION, IndexConfig, IndexSchemaError +from vidxp.core.contracts import IndexConfig, IndexSchemaError from vidxp.core.generations import CompletedGenerationManifest from vidxp.core.manifest import MANIFEST_FILE, sha256_file from vidxp.core.snapshots import GenerationReference, IndexSnapshot from vidxp.index_state import ( IndexNotReadyError, IndexingInProgressError, - bounded_media_ids, + snapshot_status, ) from vidxp.infrastructure.local_snapshots import LocalSnapshotRepository from vidxp.infrastructure.sql_tables import ( @@ -157,9 +157,7 @@ def _read_snapshot( f"Index snapshot {snapshot_id} failed integrity validation." ) try: - snapshot = IndexSnapshot.model_validate_json( - json.dumps(row.payload, separators=(",", ":")) - ) + snapshot = IndexSnapshot.model_validate(row.payload, strict=False) except ValueError as exc: raise IndexSchemaError( f"Index snapshot {snapshot_id} is invalid." @@ -184,9 +182,7 @@ def validate_generation( ).scalar_one_or_none() if ( payload is None - or GenerationReference.model_validate_json( - json.dumps(payload, separators=(",", ":")) - ) + or GenerationReference.model_validate(payload, strict=False) != reference ): raise IndexSchemaError( @@ -238,23 +234,6 @@ def publish_generation( configuration=self.snapshot_configuration(config), ) - def require_compatible_profile( - self, - *, - media_id: str, - config_fingerprint: str, - ) -> IndexSnapshot | None: - active = self.read_active() - if active is not None and ( - set(active.generations) - {media_id} - and active.config_fingerprint != config_fingerprint - ): - raise IndexSchemaError( - "All media in one active snapshot must use the same index " - "profile. Re-index or clear the other media first." - ) - return active - def remove(self, media_id: str) -> bool: active = self.read_active() if active is None or media_id not in active.generations: @@ -328,9 +307,7 @@ def _publish( ) ) elif ( - GenerationReference.model_validate_json( - json.dumps(existing, separators=(",", ":")) - ) + GenerationReference.model_validate(existing, strict=False) != replacement ): raise IndexSchemaError( @@ -387,49 +364,8 @@ def active_config( snapshot, ) - def config_for_snapshot( - self, - snapshot_id: str, - *, - snapshot_sha256: str, - device: str, - ) -> IndexConfig: - snapshot = self.read_snapshot( - snapshot_id, - expected_sha256=snapshot_sha256, - ) - if not snapshot.generations: - raise IndexNotReadyError( - "The requested index snapshot contains no media." - ) - return self._config_for_snapshot( - snapshot, - snapshot_sha256=snapshot_sha256, - device=device, - ) - def status(self) -> dict[str, Any] | None: snapshot = self.read_active() if snapshot is None: return None - media_ids = sorted(snapshot.generations) - return { - "schema_version": 1, - "state": "ready" if media_ids else "empty", - "stage": "status", - "message": ( - f"The active snapshot contains {len(media_ids)} media item(s)." - if media_ids - else "The active index snapshot is empty." - ), - "updated_at": snapshot.created_at.isoformat(), - "summary": { - "index_schema_version": INDEX_SCHEMA_VERSION, - "snapshot_id": snapshot.snapshot_id, - "media_count": len(media_ids), - **bounded_media_ids(media_ids), - "modalities": tuple( - snapshot.configuration.get("enabled_modalities", ()) - ), - }, - } + return snapshot_status(snapshot) diff --git a/src/vidxp/migrations/versions/20260729_01_catalog_uploads.py b/src/vidxp/migrations/versions/20260729_01_catalog_uploads.py index 189fd33..a94f834 100644 --- a/src/vidxp/migrations/versions/20260729_01_catalog_uploads.py +++ b/src/vidxp/migrations/versions/20260729_01_catalog_uploads.py @@ -1,4 +1,4 @@ -"""Create shared catalog and remote upload tables. +"""Create the single-repository server catalog. Revision ID: 20260729_01 Revises: @@ -64,69 +64,66 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("request_key"), ) op.create_table( - "repositories", - sa.Column("repository_id", sa.String(length=255), nullable=False), + "index_state", + sa.Column("singleton_id", sa.String(length=1), nullable=False), sa.Column("active_snapshot_id", sa.String(length=32), nullable=True), sa.Column( "active_snapshot_sha256", sa.String(length=64), nullable=True, ), - sa.PrimaryKeyConstraint("repository_id"), + sa.CheckConstraint( + "singleton_id = '1'", + name="index_state_singleton", + ), + sa.PrimaryKeyConstraint("singleton_id"), ) op.create_table( "index_generations", sa.Column("generation_id", sa.String(length=32), nullable=False), - sa.Column("repository_id", sa.String(length=255), nullable=False), sa.Column("media_id", sa.String(length=32), nullable=False), sa.Column("manifest_sha256", sa.String(length=64), nullable=False), sa.Column("payload", sa.JSON(), nullable=False), sa.PrimaryKeyConstraint("generation_id"), - sa.UniqueConstraint( - "repository_id", - "media_id", - "generation_id", - name="index_generations_repository_media_generation_key", - ), ) op.create_index( - "index_generations_repository_media", + "index_generations_media", "index_generations", - ["repository_id", "media_id"], + ["media_id"], unique=False, ) op.create_table( "index_snapshots", sa.Column("snapshot_id", sa.String(length=32), nullable=False), - sa.Column("repository_id", sa.String(length=255), nullable=False), sa.Column("created_at", sa.Text(), nullable=False), sa.Column("sha256", sa.String(length=64), nullable=False), sa.Column("payload", sa.JSON(), nullable=False), sa.PrimaryKeyConstraint("snapshot_id"), ) op.create_index( - "index_snapshots_repository_created", + "index_snapshots_created", "index_snapshots", - ["repository_id", "created_at"], + ["created_at"], unique=False, ) op.create_table( - "upload_quotas", - sa.Column("repository_id", sa.String(length=255), nullable=False), - sa.Column("owner_subject", sa.String(length=255), nullable=False), + "upload_quota", + sa.Column("singleton_id", sa.String(length=1), nullable=False), sa.Column("reserved_bytes", sa.BigInteger(), nullable=False), sa.CheckConstraint( "reserved_bytes >= 0", - name="upload_quotas_reserved_bytes_nonnegative", + name="upload_quota_reserved_bytes_nonnegative", ), - sa.PrimaryKeyConstraint("repository_id", "owner_subject"), + sa.CheckConstraint( + "singleton_id = '1'", + name="upload_quota_singleton", + ), + sa.PrimaryKeyConstraint("singleton_id"), ) op.create_table( "upload_intents", sa.Column("intent_id", sa.String(length=32), nullable=False), sa.Column("request_key", sa.String(length=64), nullable=False), - sa.Column("repository_id", sa.String(length=255), nullable=False), - sa.Column("owner_subject", sa.String(length=255), nullable=False), sa.Column("original_filename", sa.String(length=255), nullable=False), sa.Column("byte_size", sa.BigInteger(), nullable=False), sa.Column( @@ -145,11 +142,6 @@ def upgrade() -> None: sa.UniqueConstraint("job_id"), sa.UniqueConstraint("request_key"), sa.UniqueConstraint("upload_id"), - sa.UniqueConstraint( - "repository_id", - "intent_id", - name="upload_intents_repository_intent_key", - ), ) op.create_index( "upload_intents_expiry", @@ -157,33 +149,23 @@ def upgrade() -> None: ["expires_at", "state"], unique=False, ) - op.create_index( - "upload_intents_owner_state", - "upload_intents", - ["repository_id", "owner_subject", "state"], - unique=False, - ) def downgrade() -> None: - op.drop_index( - "upload_intents_owner_state", - table_name="upload_intents", - ) op.drop_index("upload_intents_expiry", table_name="upload_intents") op.drop_table("upload_intents") - op.drop_table("upload_quotas") + op.drop_table("upload_quota") op.drop_index( - "index_snapshots_repository_created", + "index_snapshots_created", table_name="index_snapshots", ) op.drop_table("index_snapshots") op.drop_index( - "index_generations_repository_media", + "index_generations_media", table_name="index_generations", ) op.drop_table("index_generations") - op.drop_table("repositories") + op.drop_table("index_state") op.drop_table("media_import_requests") op.drop_table("artifact_requests") op.drop_index("artifacts_media_id", table_name="artifacts") diff --git a/src/vidxp/migrations/versions/20260729_02_single_repository.py b/src/vidxp/migrations/versions/20260729_02_single_repository.py deleted file mode 100644 index 5588ec4..0000000 --- a/src/vidxp/migrations/versions/20260729_02_single_repository.py +++ /dev/null @@ -1,264 +0,0 @@ -"""Collapse server catalog state to one repository and one upload quota. - -Revision ID: 20260729_02 -Revises: 20260729_01 -Create Date: 2026-07-29 -""" - -from alembic import op -import sqlalchemy as sa - - -revision = "20260729_02" -down_revision = "20260729_01" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.execute( - """ - DO $$ - BEGIN - IF ( - SELECT count(DISTINCT repository_id) - FROM ( - SELECT repository_id FROM repositories - UNION ALL - SELECT repository_id FROM index_generations - UNION ALL - SELECT repository_id FROM index_snapshots - UNION ALL - SELECT repository_id FROM upload_intents - UNION ALL - SELECT repository_id FROM upload_quotas - ) AS repository_ids - ) > 1 THEN - RAISE EXCEPTION - 'VidXP cannot collapse multiple repositories into one stack'; - END IF; - END - $$; - """ - ) - - op.rename_table("repositories", "index_state") - op.alter_column( - "index_state", - "repository_id", - new_column_name="singleton_id", - ) - op.execute("UPDATE index_state SET singleton_id = '1'") - op.alter_column( - "index_state", - "singleton_id", - existing_type=sa.String(length=255), - type_=sa.String(length=1), - existing_nullable=False, - ) - op.create_check_constraint( - "index_state_singleton", - "index_state", - "singleton_id = '1'", - ) - - op.drop_index( - "index_generations_repository_media", - table_name="index_generations", - ) - op.drop_constraint( - "index_generations_repository_media_generation_key", - "index_generations", - type_="unique", - ) - op.drop_column("index_generations", "repository_id") - op.create_index( - "index_generations_media", - "index_generations", - ["media_id"], - unique=False, - ) - - op.drop_index( - "index_snapshots_repository_created", - table_name="index_snapshots", - ) - op.drop_column("index_snapshots", "repository_id") - op.create_index( - "index_snapshots_created", - "index_snapshots", - ["created_at"], - unique=False, - ) - - op.create_table( - "upload_quota", - sa.Column("singleton_id", sa.String(length=1), nullable=False), - sa.Column("reserved_bytes", sa.BigInteger(), nullable=False), - sa.CheckConstraint( - "reserved_bytes >= 0", - name="upload_quota_reserved_bytes_nonnegative", - ), - sa.CheckConstraint( - "singleton_id = '1'", - name="upload_quota_singleton", - ), - sa.PrimaryKeyConstraint("singleton_id"), - ) - op.execute( - """ - INSERT INTO upload_quota (singleton_id, reserved_bytes) - SELECT '1', COALESCE(sum(reserved_bytes), 0) - FROM upload_quotas - """ - ) - op.drop_table("upload_quotas") - - op.drop_index( - "upload_intents_owner_state", - table_name="upload_intents", - ) - op.drop_constraint( - "upload_intents_repository_intent_key", - "upload_intents", - type_="unique", - ) - op.drop_column("upload_intents", "owner_subject") - op.drop_column("upload_intents", "repository_id") - - -def downgrade() -> None: - op.add_column( - "upload_intents", - sa.Column("repository_id", sa.String(length=255), nullable=True), - ) - op.add_column( - "upload_intents", - sa.Column("owner_subject", sa.String(length=255), nullable=True), - ) - op.execute( - """ - UPDATE upload_intents - SET repository_id = 'default', owner_subject = 'shared' - """ - ) - op.alter_column( - "upload_intents", - "repository_id", - existing_type=sa.String(length=255), - nullable=False, - ) - op.alter_column( - "upload_intents", - "owner_subject", - existing_type=sa.String(length=255), - nullable=False, - ) - op.create_unique_constraint( - "upload_intents_repository_intent_key", - "upload_intents", - ["repository_id", "intent_id"], - ) - op.create_index( - "upload_intents_owner_state", - "upload_intents", - ["repository_id", "owner_subject", "state"], - unique=False, - ) - - op.create_table( - "upload_quotas", - sa.Column("repository_id", sa.String(length=255), nullable=False), - sa.Column("owner_subject", sa.String(length=255), nullable=False), - sa.Column("reserved_bytes", sa.BigInteger(), nullable=False), - sa.CheckConstraint( - "reserved_bytes >= 0", - name="upload_quotas_reserved_bytes_nonnegative", - ), - sa.PrimaryKeyConstraint("repository_id", "owner_subject"), - ) - op.execute( - """ - INSERT INTO upload_quotas ( - repository_id, - owner_subject, - reserved_bytes - ) - SELECT 'default', 'shared', reserved_bytes - FROM upload_quota - """ - ) - op.drop_table("upload_quota") - - op.drop_index( - "index_snapshots_created", - table_name="index_snapshots", - ) - op.add_column( - "index_snapshots", - sa.Column("repository_id", sa.String(length=255), nullable=True), - ) - op.execute( - "UPDATE index_snapshots SET repository_id = 'default'" - ) - op.alter_column( - "index_snapshots", - "repository_id", - existing_type=sa.String(length=255), - nullable=False, - ) - op.create_index( - "index_snapshots_repository_created", - "index_snapshots", - ["repository_id", "created_at"], - unique=False, - ) - - op.drop_index( - "index_generations_media", - table_name="index_generations", - ) - op.add_column( - "index_generations", - sa.Column("repository_id", sa.String(length=255), nullable=True), - ) - op.execute( - "UPDATE index_generations SET repository_id = 'default'" - ) - op.alter_column( - "index_generations", - "repository_id", - existing_type=sa.String(length=255), - nullable=False, - ) - op.create_unique_constraint( - "index_generations_repository_media_generation_key", - "index_generations", - ["repository_id", "media_id", "generation_id"], - ) - op.create_index( - "index_generations_repository_media", - "index_generations", - ["repository_id", "media_id"], - unique=False, - ) - - op.drop_constraint( - "index_state_singleton", - "index_state", - type_="check", - ) - op.alter_column( - "index_state", - "singleton_id", - existing_type=sa.String(length=1), - type_=sa.String(length=255), - existing_nullable=False, - ) - op.alter_column( - "index_state", - "singleton_id", - new_column_name="repository_id", - ) - op.execute("UPDATE index_state SET repository_id = 'default'") - op.rename_table("index_state", "repositories") diff --git a/src/vidxp/ports.py b/src/vidxp/ports.py index b3d7d6a..03bb49c 100644 --- a/src/vidxp/ports.py +++ b/src/vidxp/ports.py @@ -268,7 +268,7 @@ def describe(self) -> dict[str, Any]: ... class IndexReader(Protocol): """Read-only vector records available to application queries.""" - def size_bytes(self) -> int: ... + def size_bytes(self) -> int | None: ... def query( self, diff --git a/tests/test_application.py b/tests/test_application.py index 7257a38..6580f49 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -941,6 +941,28 @@ def test_dependency_check_returns_shared_result_model(self): self.assertFalse(result.ok) self.assertEqual(result.modalities, ("scene",)) + @patch("vidxp.application.which", return_value=None) + def test_dependency_check_reports_missing_media_executables(self, _which): + registry = create_capability_registry() + registry.dependency_checks = Mock(return_value=()) + application, _ = self.application("unused", registry=registry) + + result = application.check_dependencies( + DependencyCheckCommand(modalities=("scene",)) + ) + + self.assertFalse(result.ok) + self.assertEqual( + [(check.capability, check.name) for check in result.checks], + [("media", "ffmpeg"), ("media", "ffprobe")], + ) + self.assertTrue( + all( + "does not install OS packages" in (check.error or "") + for check in result.checks + ) + ) + def test_runtime_readiness_includes_dependency_failures(self): application, _ = self.application("unused") application.control_plane_readiness = Mock( @@ -1092,6 +1114,7 @@ def test_local_backend_injects_storage_for_generation_build(self): ModelRuntime(settings), settings.layout, ) + cleanup_storage = MagicMock() storage = MagicMock() storage.__enter__.return_value = storage config = IndexConfig.local( @@ -1107,7 +1130,7 @@ def test_local_backend_injects_storage_for_generation_build(self): with ( patch( "vidxp.infrastructure.local_index.IndexStorage", - return_value=storage, + side_effect=(cleanup_storage, storage), ), patch( "vidxp.infrastructure.local_index.index_video", @@ -1153,6 +1176,7 @@ def test_local_backend_injects_storage_for_generation_build(self): self.assertIsNotNone( index_video.call_args.kwargs["config"].generation_id ) + cleanup_storage.__exit__.assert_called_once() storage.__exit__.assert_called_once() diff --git a/tests/test_database_cli.py b/tests/test_database_cli.py index c88e54e..48c7284 100644 --- a/tests/test_database_cli.py +++ b/tests/test_database_cli.py @@ -1,6 +1,9 @@ import unittest from unittest.mock import patch +from alembic.config import Config +from alembic.script import ScriptDirectory + from vidxp.database_cli import main from vidxp.workflow_runtime import BUNDLED_POSTGRES_DATABASE_URL @@ -18,6 +21,17 @@ def test_database_url_command_line_override_is_not_supported(self): self.assertEqual(raised.exception.code, 2) + def test_single_repository_baseline_is_the_only_migration_head(self): + config = Config() + config.set_main_option( + "script_location", + "src/vidxp/migrations", + ) + + scripts = ScriptDirectory.from_config(config) + + self.assertEqual(scripts.get_heads(), ["20260729_01"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_generation_manifest.py b/tests/test_generation_manifest.py index 96b73d6..1e10efe 100644 --- a/tests/test_generation_manifest.py +++ b/tests/test_generation_manifest.py @@ -172,6 +172,12 @@ def test_record_counts_exactly_match_modalities_and_sizes_are_nonnegative(self): with self.assertRaises(ValidationError): self.validate(payload) + unknown_size = completed_manifest() + unknown_size["store_size_bytes_at_commit"] = None + self.assertIsNone( + self.validate(unknown_size).store_size_bytes_at_commit + ) + for field in ("processed_frames", "store_size_bytes_at_commit"): with self.subTest(field=field): payload = completed_manifest() diff --git a/tests/test_local_snapshots.py b/tests/test_local_snapshots.py index 2a08ac1..091060c 100644 --- a/tests/test_local_snapshots.py +++ b/tests/test_local_snapshots.py @@ -35,7 +35,13 @@ def setUp(self): storage_directory=self.repository.store, ) - def generation(self, media_id: str, *, input_sha: str): + def generation( + self, + media_id: str, + *, + input_sha: str, + store_size_bytes_at_commit: int | None = 123, + ): generation_id = self.repository.new_generation_id() config = replace( self.config, @@ -49,6 +55,7 @@ def generation(self, media_id: str, *, input_sha: str): config, media_id=media_id, input_sha=input_sha, + store_size_bytes_at_commit=store_size_bytes_at_commit, ) def write_generation( @@ -58,6 +65,7 @@ def write_generation( media_id: str, input_sha: str, record_counts: dict[str, int] | None = None, + store_size_bytes_at_commit: int | None = 123, ): now = datetime.now(timezone.utc).isoformat() manifest = { @@ -104,7 +112,7 @@ def write_generation( modality: (record_counts or {}).get(modality, 0) for modality in config.enabled_modalities }, - "store_size_bytes_at_commit": 123, + "store_size_bytes_at_commit": store_size_bytes_at_commit, } write_json_atomic( config.run_directory / MANIFEST_FILE, @@ -115,6 +123,20 @@ def write_generation( media_id=media_id, ) + def test_unknown_store_size_round_trips_through_snapshot_metadata(self): + config, reference = self.generation( + "a", + input_sha="a" * 64, + store_size_bytes_at_commit=None, + ) + + snapshot = self.repository.publish_generation(reference, config) + + self.assertIsNone(reference.store_size_bytes_at_commit) + self.assertIsNone( + snapshot.generations["a"].store_size_bytes_at_commit + ) + def test_add_reindex_remove_and_clear_publish_immutable_snapshots(self): config_a1, a1 = self.generation("a", input_sha="a" * 64) snapshot_a1 = self.repository.publish_generation(a1, config_a1) diff --git a/tests/test_media_catalog.py b/tests/test_media_catalog.py index 25fec72..0ed23ac 100644 --- a/tests/test_media_catalog.py +++ b/tests/test_media_catalog.py @@ -65,6 +65,21 @@ def test_catalog_enforces_sqlite_integrity_and_schema_version(self): database = Path(directory) / "catalog.sqlite3" catalog = LocalCatalog(database) with catalog.engine.connect() as connection: + self.assertEqual( + set( + connection.exec_driver_sql( + "SELECT name FROM sqlite_master " + "WHERE type = 'table'" + ).scalars() + ), + { + "artifact_requests", + "artifacts", + "catalog_metadata", + "media", + "media_import_requests", + }, + ) self.assertEqual( connection.exec_driver_sql( "PRAGMA foreign_keys" diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py index 714c683..1057dd9 100644 --- a/tests/test_snapshots.py +++ b/tests/test_snapshots.py @@ -142,6 +142,13 @@ def test_generation_counts_must_match_modalities(self): record_counts={"dialogue": -1, "scene": 1} ) + def test_generation_store_size_may_be_unknown(self): + reference = self.generation_reference( + store_size_bytes_at_commit=None + ) + + self.assertIsNone(reference.store_size_bytes_at_commit) + def test_snapshot_configuration_must_be_json_safe(self): with self.assertRaises(ValidationError): self.index_snapshot(configuration={"runtime": object()}) diff --git a/tests/test_storage.py b/tests/test_storage.py index 3231532..3f3b8ec 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -1,8 +1,16 @@ import unittest +from dataclasses import replace from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import MagicMock +import httpx +from chromadb.errors import ( + InvalidArgumentError, + InvalidDimensionException, + VersionMismatchError, +) + from vidxp.core.contracts import ( CancellationToken, IndexConfig, @@ -99,7 +107,45 @@ def test_remote_transport_failures_are_retryable_storage_failures(self): storage._client_factory = MagicMock(remote=True) with self.assertRaises(IndexStorageUnavailableError): - storage._call(MagicMock(side_effect=RuntimeError("offline"))) + storage._call( + MagicMock(side_effect=httpx.ConnectError("offline")) + ) + + def test_remote_validation_failures_retain_their_original_type(self): + storage = object.__new__(IndexStorage) + storage._client_factory = MagicMock(remote=True) + + for failure in ( + InvalidArgumentError("invalid filter"), + InvalidDimensionException("wrong embedding dimensions"), + VersionMismatchError("incompatible client and server"), + ValueError("invalid schema"), + ): + with self.subTest(failure=type(failure).__name__): + with self.assertRaises(type(failure)): + storage._call(MagicMock(side_effect=failure)) + + def test_read_only_storage_uses_the_public_collection_api(self): + with TemporaryDirectory() as directory: + client = MagicMock() + client.list_collections.return_value = ["scene"] + factory = MagicMock(remote=False) + factory.create.return_value = client + + with IndexStorage( + replace(self.config, storage_directory=directory), + create=False, + client_factory=factory, + ): + pass + + client.list_collections.assert_called_once_with() + + def test_remote_store_size_is_unknown(self): + storage = object.__new__(IndexStorage) + storage._client_factory = MagicMock(remote=True) + + self.assertIsNone(storage.size_bytes()) def test_upserts_are_split_into_declared_write_batches(self): collection = FakeCollection() diff --git a/tests/test_tusd_hooks.py b/tests/test_tusd_hooks.py index d10b164..3915851 100644 --- a/tests/test_tusd_hooks.py +++ b/tests/test_tusd_hooks.py @@ -8,7 +8,7 @@ ) from vidxp.authentication import StaticBearerAuthenticator from vidxp.authorization import AuthorizationPolicy -from vidxp.infrastructure.local_catalog import LocalCatalog +from vidxp.infrastructure.sql_catalog import SQLCatalog from vidxp.infrastructure.tusd_contracts import TusdHookRequest from vidxp.settings import VidXPSettings from vidxp.tusd_hooks import TusdHookService @@ -29,7 +29,10 @@ def enqueue_media_import_in_transaction( def _hooks(tmp_path: Path): token = "t" * 32 - catalog = LocalCatalog(tmp_path / "catalog.sqlite3") + catalog = SQLCatalog( + f"sqlite:///{(tmp_path / 'server.sqlite3').resolve().as_posix()}", + initialize=True, + ) settings = VidXPSettings( repository_root=tmp_path, upload_public_endpoint="http://localhost:8080/uploads/", diff --git a/tests/test_upload_service.py b/tests/test_upload_service.py index df6c1a0..fc82b59 100644 --- a/tests/test_upload_service.py +++ b/tests/test_upload_service.py @@ -16,9 +16,9 @@ JobState, Principal, ) -from vidxp.infrastructure.local_catalog import LocalCatalog from vidxp.core.media import utc_now from vidxp.core.uploads import UploadIntentRecord, UploadState +from vidxp.infrastructure.sql_catalog import SQLCatalog from vidxp.settings import VidXPSettings from vidxp.upload_service import RemoteUploadService @@ -51,8 +51,11 @@ def _service( root: Path, *, quota: int = 100, -) -> tuple[RemoteUploadService, LocalCatalog, _Jobs]: - catalog = LocalCatalog(root / "catalog.sqlite3") +) -> tuple[RemoteUploadService, SQLCatalog, _Jobs]: + catalog = SQLCatalog( + f"sqlite:///{(root / 'server.sqlite3').resolve().as_posix()}", + initialize=True, + ) jobs = _Jobs() settings = VidXPSettings( repository_root=root, diff --git a/utils/build_package.sh b/utils/build_package.sh index 70a8495..86c173f 100644 --- a/utils/build_package.sh +++ b/utils/build_package.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +python utils/sync_desktop_lock_versions.py "${NEW_VERSION:?NEW_VERSION is required}" + BASE_URL="https://github.com/grayhatdevelopers/vidxp/blob/main" README="README.md" README_BAK="$README.bak" diff --git a/utils/sync_desktop_lock_versions.py b/utils/sync_desktop_lock_versions.py new file mode 100644 index 0000000..12b1dee --- /dev/null +++ b/utils/sync_desktop_lock_versions.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import argparse +import json +import re +import tomllib +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def _read_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def _require_release_versions(version: str) -> None: + manifest = _read_json(ROOT / "desktop" / "runtime-manifest.json") + expected = { + "pyproject.toml": tomllib.loads( + (ROOT / "pyproject.toml").read_text(encoding="utf-8") + )["project"]["version"], + "desktop/package.json": _read_json( + ROOT / "desktop" / "package.json" + )["version"], + "desktop/runtime-manifest.json:desktop_version": manifest[ + "desktop_version" + ], + "desktop/runtime-manifest.json:package_version": manifest[ + "package_version" + ], + "desktop/src-tauri/Cargo.toml": tomllib.loads( + (ROOT / "desktop" / "src-tauri" / "Cargo.toml").read_text( + encoding="utf-8" + ) + )["package"]["version"], + "desktop/src-tauri/tauri.conf.json": _read_json( + ROOT / "desktop" / "src-tauri" / "tauri.conf.json" + )["version"], + } + stale = [path for path, value in expected.items() if value != version] + if stale: + raise RuntimeError( + "python-semantic-release did not stamp the requested version in: " + + ", ".join(stale) + ) + + +def update(version: str) -> None: + _require_release_versions(version) + + package_lock_path = ROOT / "desktop" / "package-lock.json" + package_lock = _read_json(package_lock_path) + package_lock["version"] = version + package_lock["packages"][""]["version"] = version + package_lock_path.write_text( + json.dumps(package_lock, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + cargo_lock_path = ROOT / "desktop" / "src-tauri" / "Cargo.lock" + cargo_lock = cargo_lock_path.read_text(encoding="utf-8") + updated, count = re.subn( + ( + r'(?m)(^\[\[package\]\]\r?\nname = "vidxp-desktop"\r?\n' + r'version = ")[^"]+(")' + ), + rf"\g<1>{version}\g<2>", + cargo_lock, + count=1, + ) + if count != 1: + raise RuntimeError("Could not find vidxp-desktop in Cargo.lock.") + cargo_lock_path.write_text(updated, encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("version") + args = parser.parse_args() + update(args.version) + + +if __name__ == "__main__": + main() From 5b5d52e9d29de0faa733637046eedfc19c3dbd9a Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 18:29:58 +0500 Subject: [PATCH 18/46] fix: stream dependency check progress --- changes/publication-foundation.bugfix.md | 1 + src/vidxp/application.py | 11 +++- src/vidxp/capabilities/registry.py | 8 ++- src/vidxp/cli_commands/runtime.py | 65 +++++++++++++++++++++--- tests/test_capabilities.py | 20 +++++--- tests/test_cli.py | 49 ++++++++++++++++++ 6 files changed, 140 insertions(+), 14 deletions(-) diff --git a/changes/publication-foundation.bugfix.md b/changes/publication-foundation.bugfix.md index 7cb4129..aaa2feb 100644 --- a/changes/publication-foundation.bugfix.md +++ b/changes/publication-foundation.bugfix.md @@ -2,3 +2,4 @@ Simplify the unreleased single-repository schema, harden local and remote Chroma storage contracts, validate FFmpeg dependencies before media work, and make desktop/server release artifacts resolve and publish from the exact validated release commit. +Stream timestamped doctor progress and print actionable dependency remediation. diff --git a/src/vidxp/application.py b/src/vidxp/application.py index daf89c9..9b657f0 100644 --- a/src/vidxp/application.py +++ b/src/vidxp/application.py @@ -245,15 +245,20 @@ def indexing_in_progress(self) -> bool: def check_dependencies( self, command: DependencyCheckCommand, + *, + on_runtime_check_start: Callable[[str, str], None] | None = None, ) -> DependencyCheckResult: selected = self.registry.validate_names(command.modalities) checks = ( *self.registry.dependency_checks( selected, include_runtime_checks=command.include_runtime_checks, + on_runtime_check_start=on_runtime_check_start, ), *( - self._media_runtime_checks() + self._media_runtime_checks( + on_runtime_check_start=on_runtime_check_start + ) if command.include_runtime_checks else () ), @@ -266,6 +271,8 @@ def check_dependencies( def _media_runtime_checks( self, + *, + on_runtime_check_start: Callable[[str, str], None] | None = None, ) -> tuple[CapabilityDependencyCheck, ...]: checks = [] for name, executable, setting in ( @@ -280,6 +287,8 @@ def _media_runtime_checks( "VIDXP_FFPROBE_EXECUTABLE", ), ): + if on_runtime_check_start is not None: + on_runtime_check_start("media", name) resolved = which(executable) checks.append( CapabilityDependencyCheck( diff --git a/src/vidxp/capabilities/registry.py b/src/vidxp/capabilities/registry.py index 1186813..7dfd646 100644 --- a/src/vidxp/capabilities/registry.py +++ b/src/vidxp/capabilities/registry.py @@ -4,7 +4,7 @@ from importlib.metadata import EntryPoint, entry_points from threading import RLock from types import MappingProxyType -from typing import Any, Iterable, Mapping +from typing import Any, Callable, Iterable, Mapping from packaging.requirements import Requirement from packaging.utils import canonicalize_name @@ -348,6 +348,7 @@ def dependency_checks( *, source: VideoSource | None = None, include_runtime_checks: bool = True, + on_runtime_check_start: Callable[[str, str], None] | None = None, ) -> tuple[CapabilityDependencyCheck, ...]: selected = self.validate_names(names) checks = [] @@ -366,6 +367,11 @@ def dependency_checks( selected, source=source, ): + if on_runtime_check_start is not None: + on_runtime_check_start( + binding.capability, + binding.check.label, + ) result = binding.check.inspect() checks.append( CapabilityDependencyCheck( diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py index 962dec2..2a0fe0d 100644 --- a/src/vidxp/cli_commands/runtime.py +++ b/src/vidxp/cli_commands/runtime.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime from typing import Annotated import typer @@ -40,19 +41,30 @@ def doctor( """Validate selected indexing dependencies without downloading models.""" state = state_from_context(ctx) - available = tuple( - capability.name for capability in state.service.list_capabilities() - ) + capabilities = tuple(state.service.list_capabilities()) + available = tuple(capability.name for capability in capabilities) selected = ( available if modalities is None else parse_modalities(",".join(modalities), available) ) + output_format = effective_output_format(state, json_output) + + def show_check_start(capability: str, name: str) -> None: + timestamp = datetime.now().astimezone().strftime("%H:%M:%S") + typer.secho( + f"[{timestamp}] Checking [{capability}] {name}...", + fg=typer.colors.BLUE, + ) + result = state.service.check_dependencies( - DependencyCheckCommand(modalities=selected) + DependencyCheckCommand(modalities=selected), + on_runtime_check_start=( + show_check_start if output_format == OutputFormat.rich else None + ), ) payload = result.model_dump(mode="json") - if effective_output_format(state, json_output) == OutputFormat.json: + if output_format == OutputFormat.json: emit_json(payload) else: for check in payload["checks"]: @@ -72,9 +84,50 @@ def doctor( f"FAILED [{owner}] {check['name']}: {check['error']}", fg=typer.colors.RED, ) + python_failures = tuple( + check + for check in result.checks + if not check.ok and check.capability != "media" + ) + if python_failures: + selected_capabilities = { + capability.name: capability for capability in capabilities + } + builtin_extras = tuple( + dict.fromkeys( + selected_capabilities[name].install_extra + for name in selected + if selected_capabilities[name].provenance is None + ) + ) + external_distributions = tuple( + dict.fromkeys( + selected_capabilities[name].provenance.distribution + for name in selected + if selected_capabilities[name].provenance is not None + ) + ) + if builtin_extras: + typer.secho( + "REMEDY: In a source checkout, rerun uv sync with all " + "profiles you use and include --extra local-worker.", + fg=typer.colors.YELLOW, + ) + typer.secho( + 'Published package: pip install "vidxp[' + + ",".join(builtin_extras) + + ']"', + fg=typer.colors.YELLOW, + ) + if external_distributions: + typer.secho( + "External capabilities: pip install " + + " ".join(external_distributions), + fg=typer.colors.YELLOW, + ) if not result.ok: raise typer.Exit(1) - if effective_output_format(state, json_output) == OutputFormat.rich: + if output_format == OutputFormat.rich: typer.secho( "Selected VidXP dependencies are available.", fg=typer.colors.GREEN, diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 0c26b7f..4a9578e 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -389,6 +389,12 @@ def plugin(name): self.assertIs(raised.exception.__cause__, failure) def test_external_runtime_check_errors_are_sanitized_with_provenance(self): + events = [] + + def fail_runtime_check(): + events.append("inspect") + raise RuntimeError("token=do-not-leak") + provenance = CapabilityProvenance( distribution="acme-capabilities", entry_point="ocr", @@ -416,19 +422,21 @@ def test_external_runtime_check_errors_are_sanitized_with_provenance(self): runtime_checks=( RuntimeCheck( label="ocr runtime", - check=Mock( - side_effect=RuntimeError( - "token=do-not-leak" - ) - ), + check=fail_runtime_check, ), ), ), provenance=provenance, ) - check = CapabilityRegistry((plugin,)).dependency_checks(("ocr",))[0] + check = CapabilityRegistry((plugin,)).dependency_checks( + ("ocr",), + on_runtime_check_start=lambda capability, name: events.append( + (capability, name) + ), + )[0] + self.assertEqual(events, [("ocr", "ocr runtime"), "inspect"]) self.assertEqual(check.error, "runtime check failed") self.assertNotIn("do-not-leak", check.model_dump_json()) self.assertEqual(check.provenance, provenance) diff --git a/tests/test_cli.py b/tests/test_cli.py index b949fa9..243e86d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,8 +12,10 @@ from vidxp.composition import LocalApplicationContext, settings_for_repository from vidxp.settings import ApplicationMode from vidxp.application_models import ( + CapabilityDependencyCheck, CreateIndexCommand, DependencyCheckResult, + DependencyKind, FusedSearchResult, FusionProvenance, IndexJobResult, @@ -366,6 +368,53 @@ def test_doctor_accepts_repeated_modality_options(self): command = self.service.check_dependencies.call_args.args[0] self.assertEqual(command.modalities, ("dialogue", "scene")) + def test_doctor_streams_timestamped_runtime_check_progress(self): + def check_dependencies(_command, *, on_runtime_check_start): + on_runtime_check_start("scene", "Torch import") + return DependencyCheckResult( + ok=True, + modalities=("scene",), + checks=(), + ) + + self.service.check_dependencies.side_effect = check_dependencies + + result = self.invoke(["doctor", "--modalities", "scene"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertRegex( + result.output, + r"\[\d{2}:\d{2}:\d{2}\] Checking \[scene\] Torch import\.\.\.", + ) + + def test_doctor_prints_install_remedy_for_python_failures(self): + self.service.check_dependencies.return_value = DependencyCheckResult( + ok=False, + modalities=("scene",), + checks=( + CapabilityDependencyCheck( + capability="scene", + kind=DependencyKind.distribution, + name="transformers", + requirement="transformers>=5,<6", + ok=False, + error="distribution is not installed", + ), + ), + ) + + result = self.invoke(["doctor", "--modalities", "scene"]) + + self.assertEqual(result.exit_code, 1, result.output) + self.assertIn( + "include --extra local-worker", + result.output, + ) + self.assertIn( + 'pip install "vidxp[scene]"', + result.output, + ) + def test_invalid_capability_is_a_cli_parameter_error(self): result = self.invoke( ["doctor", "--modalities", "unknown", "--json"] From 4048c10a1395f66bee7ddd386ca744e2a4ad1d22 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 18:35:46 +0500 Subject: [PATCH 19/46] fix: report long-running CLI progress --- changes/publication-foundation.bugfix.md | 2 +- src/vidxp/application.py | 48 +++++++++++------- src/vidxp/capabilities/registry.py | 27 ++++++---- src/vidxp/cli_commands/actors.py | 17 ++++++- src/vidxp/cli_commands/artifacts.py | 17 ++++++- src/vidxp/cli_commands/media.py | 6 ++- src/vidxp/cli_commands/query.py | 14 +++++- src/vidxp/cli_commands/runtime.py | 63 +++++++++++++++++------- src/vidxp/cli_commands/search.py | 14 +++++- src/vidxp/cli_support.py | 23 +++++++++ tests/test_cli.py | 51 ++++++++++++++++++- 11 files changed, 228 insertions(+), 54 deletions(-) diff --git a/changes/publication-foundation.bugfix.md b/changes/publication-foundation.bugfix.md index aaa2feb..9069b52 100644 --- a/changes/publication-foundation.bugfix.md +++ b/changes/publication-foundation.bugfix.md @@ -2,4 +2,4 @@ Simplify the unreleased single-repository schema, harden local and remote Chroma storage contracts, validate FFmpeg dependencies before media work, and make desktop/server release artifacts resolve and publish from the exact validated release commit. -Stream timestamped doctor progress and print actionable dependency remediation. +Stream timestamped CLI progress and print actionable dependency remediation. diff --git a/src/vidxp/application.py b/src/vidxp/application.py index 9b657f0..76a18a3 100644 --- a/src/vidxp/application.py +++ b/src/vidxp/application.py @@ -3,6 +3,7 @@ from contextlib import contextmanager from pathlib import Path from shutil import which +from time import perf_counter from typing import Any, Callable, Iterator, Mapping, cast from pydantic import BaseModel @@ -247,6 +248,9 @@ def check_dependencies( command: DependencyCheckCommand, *, on_runtime_check_start: Callable[[str, str], None] | None = None, + on_runtime_check_complete: ( + Callable[[CapabilityDependencyCheck, float], None] | None + ) = None, ) -> DependencyCheckResult: selected = self.registry.validate_names(command.modalities) checks = ( @@ -254,10 +258,12 @@ def check_dependencies( selected, include_runtime_checks=command.include_runtime_checks, on_runtime_check_start=on_runtime_check_start, + on_runtime_check_complete=on_runtime_check_complete, ), *( self._media_runtime_checks( - on_runtime_check_start=on_runtime_check_start + on_runtime_check_start=on_runtime_check_start, + on_runtime_check_complete=on_runtime_check_complete, ) if command.include_runtime_checks else () @@ -273,6 +279,9 @@ def _media_runtime_checks( self, *, on_runtime_check_start: Callable[[str, str], None] | None = None, + on_runtime_check_complete: ( + Callable[[CapabilityDependencyCheck, float], None] | None + ) = None, ) -> tuple[CapabilityDependencyCheck, ...]: checks = [] for name, executable, setting in ( @@ -289,24 +298,29 @@ def _media_runtime_checks( ): if on_runtime_check_start is not None: on_runtime_check_start("media", name) + started = perf_counter() resolved = which(executable) - checks.append( - CapabilityDependencyCheck( - capability="media", - kind=DependencyKind.runtime, - name=name, - ok=resolved is not None, - error=( - None - if resolved is not None - else ( - f"{executable!r} is unavailable. Install FFmpeg " - "with the operating-system package manager or set " - f"{setting}; VidXP does not install OS packages." - ) - ), - ) + check = CapabilityDependencyCheck( + capability="media", + kind=DependencyKind.runtime, + name=name, + ok=resolved is not None, + error=( + None + if resolved is not None + else ( + f"{executable!r} is unavailable. Install FFmpeg " + "with the operating-system package manager or set " + f"{setting}; VidXP does not install OS packages." + ) + ), ) + checks.append(check) + if on_runtime_check_complete is not None: + on_runtime_check_complete( + check, + perf_counter() - started, + ) return tuple(checks) @application_boundary diff --git a/src/vidxp/capabilities/registry.py b/src/vidxp/capabilities/registry.py index 7dfd646..f75eb50 100644 --- a/src/vidxp/capabilities/registry.py +++ b/src/vidxp/capabilities/registry.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from importlib.metadata import EntryPoint, entry_points from threading import RLock +from time import perf_counter from types import MappingProxyType from typing import Any, Callable, Iterable, Mapping @@ -349,6 +350,9 @@ def dependency_checks( source: VideoSource | None = None, include_runtime_checks: bool = True, on_runtime_check_start: Callable[[str, str], None] | None = None, + on_runtime_check_complete: ( + Callable[[CapabilityDependencyCheck, float], None] | None + ) = None, ) -> tuple[CapabilityDependencyCheck, ...]: selected = self.validate_names(names) checks = [] @@ -372,17 +376,22 @@ def dependency_checks( binding.capability, binding.check.label, ) + started = perf_counter() result = binding.check.inspect() - checks.append( - CapabilityDependencyCheck( - capability=binding.capability, - provenance=binding.provenance, - kind=DependencyKind.runtime, - name=result["name"], - ok=result["ok"], - error=result["error"], - ) + check = CapabilityDependencyCheck( + capability=binding.capability, + provenance=binding.provenance, + kind=DependencyKind.runtime, + name=result["name"], + ok=result["ok"], + error=result["error"], ) + checks.append(check) + if on_runtime_check_complete is not None: + on_runtime_check_complete( + check, + perf_counter() - started, + ) return tuple(checks) def require_dependencies( diff --git a/src/vidxp/cli_commands/actors.py b/src/vidxp/cli_commands/actors.py index 20ace1c..e89a4f8 100644 --- a/src/vidxp/cli_commands/actors.py +++ b/src/vidxp/cli_commands/actors.py @@ -11,7 +11,9 @@ CLIState, OutputFormat, effective_output_format, + emit_job_progress, emit_json, + emit_progress, state_from_context, ) app = typer.Typer( @@ -176,13 +178,24 @@ def actors_render( """Render one actor cluster as a result video.""" state = state_from_context(ctx) + output_format = effective_output_format(state, json_output) + show_progress = ( + not detach + and not state.quiet + and output_format == OutputFormat.rich + ) + if show_progress: + emit_progress("Starting actor-overlay rendering...") job = state.jobs.submit_actor_overlay( CreateActorOverlayCommand(cluster_id=cluster_id) ) if not detach: - job = state.jobs.wait(job.job_id) + job = state.jobs.wait( + job.job_id, + progress=emit_job_progress if show_progress else None, + ) payload = job.model_dump(mode="json") - if effective_output_format(state, json_output) == OutputFormat.json: + if output_format == OutputFormat.json: emit_json(payload) else: typer.secho( diff --git a/src/vidxp/cli_commands/artifacts.py b/src/vidxp/cli_commands/artifacts.py index d95aef2..6d23539 100644 --- a/src/vidxp/cli_commands/artifacts.py +++ b/src/vidxp/cli_commands/artifacts.py @@ -8,7 +8,9 @@ from vidxp.cli_support import ( OutputFormat, effective_output_format, + emit_job_progress, emit_json, + emit_progress, state_from_context, ) @@ -55,6 +57,14 @@ def create_snippet( param_hint="end_seconds", ) state = state_from_context(ctx) + output_format = effective_output_format(state, json_output) + show_progress = ( + not detach + and not state.quiet + and output_format == OutputFormat.rich + ) + if show_progress: + emit_progress("Starting snippet rendering...") job = state.jobs.submit_snippet( CreateSnippetCommand( media_id=media_id, @@ -64,9 +74,12 @@ def create_snippet( ) ) if not detach: - job = state.jobs.wait(job.job_id) + job = state.jobs.wait( + job.job_id, + progress=emit_job_progress if show_progress else None, + ) payload = job.model_dump(mode="json") - if effective_output_format(state, json_output) == OutputFormat.json: + if output_format == OutputFormat.json: emit_json(payload) else: typer.secho( diff --git a/src/vidxp/cli_commands/media.py b/src/vidxp/cli_commands/media.py index 248e1b6..c237603 100644 --- a/src/vidxp/cli_commands/media.py +++ b/src/vidxp/cli_commands/media.py @@ -12,6 +12,7 @@ OutputFormat, effective_output_format, emit_json, + emit_progress, state_from_context, ) @@ -40,9 +41,12 @@ def import_media( """Validate and register a local video, returning its stable media ID.""" state = state_from_context(ctx) + output_format = effective_output_format(state, json_output) + if not state.quiet and output_format == OutputFormat.rich: + emit_progress(f"Importing {path.name} into managed storage...") result = state.service.import_media(ImportMediaCommand(path=path)) payload = result.model_dump(mode="json") - if effective_output_format(state, json_output) == OutputFormat.json: + if output_format == OutputFormat.json: emit_json(payload) else: typer.secho( diff --git a/src/vidxp/cli_commands/query.py b/src/vidxp/cli_commands/query.py index 0499f81..631cc0b 100644 --- a/src/vidxp/cli_commands/query.py +++ b/src/vidxp/cli_commands/query.py @@ -11,7 +11,10 @@ ) from vidxp.cli_support import ( CLIState, + OutputFormat, effective_output_format, + emit_job_progress, + emit_progress, emit_query, state_from_context, ) @@ -26,6 +29,10 @@ def run_query( top_k: int, json_output: bool, ) -> QueryAnswer: + output_format = effective_output_format(state, json_output) + show_progress = not state.quiet and output_format == OutputFormat.rich + if show_progress: + emit_progress("Starting grounded video query...") job = state.jobs.submit_query( QueryVideoCommand( question=question, @@ -34,13 +41,16 @@ def run_query( top_k=top_k, ) ) - completed = state.jobs.wait(job.job_id) + completed = state.jobs.wait( + job.job_id, + progress=emit_job_progress if show_progress else None, + ) if not isinstance(completed.result, QueryJobResult): raise RuntimeError("The completed query job has no query result.") result = completed.result.result emit_query( result, - output_format=effective_output_format(state, json_output), + output_format=output_format, ) return result diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py index 2a0fe0d..871f824 100644 --- a/src/vidxp/cli_commands/runtime.py +++ b/src/vidxp/cli_commands/runtime.py @@ -1,12 +1,12 @@ from __future__ import annotations -from datetime import datetime from typing import Annotated import typer from vidxp.application_models import ( ApplicationError, + CapabilityDependencyCheck, DependencyCheckCommand, ErrorCategory, PrepareModelsCommand, @@ -14,7 +14,9 @@ from vidxp.cli_support import ( OutputFormat, effective_output_format, + emit_job_progress, emit_json, + emit_progress, parse_capability_options, parse_modalities, state_from_context, @@ -51,23 +53,44 @@ def doctor( output_format = effective_output_format(state, json_output) def show_check_start(capability: str, name: str) -> None: - timestamp = datetime.now().astimezone().strftime("%H:%M:%S") - typer.secho( - f"[{timestamp}] Checking [{capability}] {name}...", - fg=typer.colors.BLUE, + emit_progress( + f"Checking [{capability}] {name}...", + newline=False, ) + def show_check_complete( + check: CapabilityDependencyCheck, + elapsed_seconds: float, + ) -> None: + if check.ok: + typer.secho( + f" OK ({elapsed_seconds:.1f}s)", + fg=typer.colors.GREEN, + ) + else: + typer.secho( + f" FAILED ({elapsed_seconds:.1f}s): {check.error}", + fg=typer.colors.RED, + ) + result = state.service.check_dependencies( DependencyCheckCommand(modalities=selected), on_runtime_check_start=( show_check_start if output_format == OutputFormat.rich else None ), + on_runtime_check_complete=( + show_check_complete + if output_format == OutputFormat.rich + else None + ), ) payload = result.model_dump(mode="json") if output_format == OutputFormat.json: emit_json(payload) else: for check in payload["checks"]: + if check["kind"] == "runtime": + continue owner = check["capability"] if check["provenance"] is not None: owner = ( @@ -183,6 +206,12 @@ def prepare( if modalities is None else parse_modalities(",".join(modalities), preparable) ) + output_format = effective_output_format(state, json_output) + show_progress = not state.quiet and output_format == OutputFormat.rich + if show_progress: + emit_progress( + "Starting model preparation for " + ", ".join(selected) + "." + ) job = state.jobs.submit_prepare_models( PrepareModelsCommand( modalities=selected, @@ -190,20 +219,11 @@ def prepare( ) ) if not detach: - show_progress = ( - not state.quiet - and effective_output_format(state, json_output) - != OutputFormat.json - ) job = state.jobs.wait( job.job_id, - progress=lambda current: ( - typer.echo(current.progress.message) - if show_progress and current.progress is not None - else None - ), + progress=emit_job_progress if show_progress else None, ) - if effective_output_format(state, json_output) == OutputFormat.json: + if output_format == OutputFormat.json: emit_json(job.model_dump(mode="json")) else: typer.secho( @@ -236,6 +256,11 @@ def ui( """Launch Streamlit with the selected repository configuration.""" state = state_from_context(ctx) + show_progress = ( + not state.quiet and state.output_format == OutputFormat.rich + ) + if show_progress: + emit_progress("Loading the browser interface...") try: from vidxp import frontend except ModuleNotFoundError as exc: @@ -244,7 +269,9 @@ def ui( "frontend_unavailable", ErrorCategory.unavailable, "The browser interface is unavailable. " - 'Install the "frontend" extra.', + "In a source checkout, rerun uv sync with all profiles " + "you use and include --extra frontend. Published package: " + 'pip install "vidxp[frontend]".', ) from exc raise @@ -253,6 +280,8 @@ def ui( streamlit_arguments.append(f"--server.address={host}") if port is not None: streamlit_arguments.append(f"--server.port={port}") + if show_progress: + emit_progress("Starting the browser interface...") frontend.main( streamlit_arguments, settings=state.settings, diff --git a/src/vidxp/cli_commands/search.py b/src/vidxp/cli_commands/search.py index 9866096..9cfdb21 100644 --- a/src/vidxp/cli_commands/search.py +++ b/src/vidxp/cli_commands/search.py @@ -8,7 +8,10 @@ from vidxp.application_models import FusedSearchResult from vidxp.cli_support import ( CLIState, + OutputFormat, effective_output_format, + emit_job_progress, + emit_progress, emit_search, state_from_context, ) @@ -22,6 +25,10 @@ def run_search( top_k: int, json_output: bool, ) -> FusedSearchResult: + output_format = effective_output_format(state, json_output) + show_progress = not state.quiet and output_format == OutputFormat.rich + if show_progress: + emit_progress(f"Starting {capability} search...") job = state.jobs.submit_search( SearchCommand( modalities=(capability,), @@ -29,13 +36,16 @@ def run_search( top_k=top_k, ) ) - completed = state.jobs.wait(job.job_id) + completed = state.jobs.wait( + job.job_id, + progress=emit_job_progress if show_progress else None, + ) if not isinstance(completed.result, SearchJobResult): raise RuntimeError("The completed search job has no search result.") result = completed.result.result emit_search( result, - output_format=effective_output_format(state, json_output), + output_format=output_format, ) return result diff --git a/src/vidxp/cli_support.py b/src/vidxp/cli_support.py index 6f325e8..cf1d7c1 100644 --- a/src/vidxp/cli_support.py +++ b/src/vidxp/cli_support.py @@ -2,6 +2,7 @@ import json from dataclasses import dataclass +from datetime import datetime from enum import Enum from typing import Any, Iterable @@ -78,6 +79,28 @@ def emit_json(payload: Any) -> None: ) +def emit_progress( + message: str, + *, + updated_at: datetime | None = None, + newline: bool = True, +) -> None: + timestamp = (updated_at or datetime.now().astimezone()).astimezone() + typer.secho( + f"[{timestamp:%H:%M:%S}] {message}", + fg=typer.colors.BLUE, + nl=newline, + ) + + +def emit_job_progress(job: Any) -> None: + if job.progress is not None: + emit_progress( + job.progress.message, + updated_at=job.progress.updated_at, + ) + + def emit_search( result: FusedSearchResult, *, diff --git a/tests/test_cli.py b/tests/test_cli.py index 243e86d..4fdb2e3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -368,9 +368,56 @@ def test_doctor_accepts_repeated_modality_options(self): command = self.service.check_dependencies.call_args.args[0] self.assertEqual(command.modalities, ("dialogue", "scene")) + def test_prepare_announces_start_and_subscribes_to_job_progress(self): + prepared = PrepareModelsResult( + prepared=("scene-model",), + modalities=("scene",), + runtime={ + "requested": "cpu", + "torch_device": "cpu", + "transcription_device": "cpu", + }, + ) + self.jobs.submit_prepare_models.return_value = Job( + job_id=JOB_ID, + kind=JobKind.prepare_models, + state=JobState.queued, + queue=JobQueue.cpu, + ) + self.jobs.wait.return_value = Job( + job_id=JOB_ID, + kind=JobKind.prepare_models, + state=JobState.succeeded, + queue=JobQueue.cpu, + result=PrepareModelsJobResult(result=prepared), + ) + + result = self.invoke(["prepare", "--modalities", "scene"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertRegex( + result.output, + r"\[\d{2}:\d{2}:\d{2}\] Starting model preparation for scene\.", + ) + self.assertTrue(callable(self.jobs.wait.call_args.kwargs["progress"])) + def test_doctor_streams_timestamped_runtime_check_progress(self): - def check_dependencies(_command, *, on_runtime_check_start): + def check_dependencies( + _command, + *, + on_runtime_check_start, + on_runtime_check_complete, + ): on_runtime_check_start("scene", "Torch import") + on_runtime_check_complete( + CapabilityDependencyCheck( + capability="scene", + kind=DependencyKind.runtime, + name="Torch import", + ok=True, + ), + 1.25, + ) return DependencyCheckResult( ok=True, modalities=("scene",), @@ -386,6 +433,8 @@ def check_dependencies(_command, *, on_runtime_check_start): result.output, r"\[\d{2}:\d{2}:\d{2}\] Checking \[scene\] Torch import\.\.\.", ) + self.assertIn("OK (1.2s)", result.output) + self.assertNotIn("OK [scene] Torch import", result.output) def test_doctor_prints_install_remedy_for_python_failures(self): self.service.check_dependencies.return_value = DependencyCheckResult( From 009863d1982e8ff17e619b198556d476938e82ad Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 18:50:19 +0500 Subject: [PATCH 20/46] fix: simplify dependency check output --- pyproject.toml | 2 +- src/vidxp/__main__.py | 2 +- src/vidxp/application.py | 28 ++++++----- src/vidxp/capabilities/registry.py | 37 +++++++++----- src/vidxp/cli_commands/runtime.py | 40 ++++++--------- src/vidxp/entrypoint.py | 81 ++++++++++++++++++++++++++++++ tests/test_capabilities.py | 2 +- tests/test_cli.py | 58 ++++++++++++++++++--- tests/test_packaging.py | 4 ++ 9 files changed, 196 insertions(+), 58 deletions(-) create mode 100644 src/vidxp/entrypoint.py diff --git a/pyproject.toml b/pyproject.toml index f60949d..592c052 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ ] [project.scripts] -vidxp = "vidxp.cli:main" +vidxp = "vidxp.entrypoint:main" vidxp-api = "vidxp.api_cli:main" vidxp-worker = "vidxp.workflow_worker:main" vidxp-database = "vidxp.database_cli:main" diff --git a/src/vidxp/__main__.py b/src/vidxp/__main__.py index 2f05ddc..d952b81 100644 --- a/src/vidxp/__main__.py +++ b/src/vidxp/__main__.py @@ -1,4 +1,4 @@ -from .cli import main +from .entrypoint import main if __name__ == "__main__": diff --git a/src/vidxp/application.py b/src/vidxp/application.py index 76a18a3..0857abb 100644 --- a/src/vidxp/application.py +++ b/src/vidxp/application.py @@ -247,8 +247,10 @@ def check_dependencies( self, command: DependencyCheckCommand, *, - on_runtime_check_start: Callable[[str, str], None] | None = None, - on_runtime_check_complete: ( + on_check_start: ( + Callable[[str, DependencyKind, str], None] | None + ) = None, + on_check_complete: ( Callable[[CapabilityDependencyCheck, float], None] | None ) = None, ) -> DependencyCheckResult: @@ -257,13 +259,13 @@ def check_dependencies( *self.registry.dependency_checks( selected, include_runtime_checks=command.include_runtime_checks, - on_runtime_check_start=on_runtime_check_start, - on_runtime_check_complete=on_runtime_check_complete, + on_check_start=on_check_start, + on_check_complete=on_check_complete, ), *( self._media_runtime_checks( - on_runtime_check_start=on_runtime_check_start, - on_runtime_check_complete=on_runtime_check_complete, + on_check_start=on_check_start, + on_check_complete=on_check_complete, ) if command.include_runtime_checks else () @@ -278,8 +280,10 @@ def check_dependencies( def _media_runtime_checks( self, *, - on_runtime_check_start: Callable[[str, str], None] | None = None, - on_runtime_check_complete: ( + on_check_start: ( + Callable[[str, DependencyKind, str], None] | None + ) = None, + on_check_complete: ( Callable[[CapabilityDependencyCheck, float], None] | None ) = None, ) -> tuple[CapabilityDependencyCheck, ...]: @@ -296,8 +300,8 @@ def _media_runtime_checks( "VIDXP_FFPROBE_EXECUTABLE", ), ): - if on_runtime_check_start is not None: - on_runtime_check_start("media", name) + if on_check_start is not None: + on_check_start("media", DependencyKind.runtime, name) started = perf_counter() resolved = which(executable) check = CapabilityDependencyCheck( @@ -316,8 +320,8 @@ def _media_runtime_checks( ), ) checks.append(check) - if on_runtime_check_complete is not None: - on_runtime_check_complete( + if on_check_complete is not None: + on_check_complete( check, perf_counter() - started, ) diff --git a/src/vidxp/capabilities/registry.py b/src/vidxp/capabilities/registry.py index f75eb50..1e9e433 100644 --- a/src/vidxp/capabilities/registry.py +++ b/src/vidxp/capabilities/registry.py @@ -349,31 +349,42 @@ def dependency_checks( *, source: VideoSource | None = None, include_runtime_checks: bool = True, - on_runtime_check_start: Callable[[str, str], None] | None = None, - on_runtime_check_complete: ( + on_check_start: ( + Callable[[str, DependencyKind, str], None] | None + ) = None, + on_check_complete: ( Callable[[CapabilityDependencyCheck, float], None] | None ) = None, ) -> tuple[CapabilityDependencyCheck, ...]: selected = self.validate_names(names) checks = [] for binding in self._requirement_bindings(selected, source=source): - result = inspect_requirement(binding.requirement) - checks.append( - CapabilityDependencyCheck( - capability=binding.capability, - provenance=binding.provenance, - kind=DependencyKind.distribution, - **result, + if on_check_start is not None: + on_check_start( + binding.capability, + DependencyKind.distribution, + binding.requirement.name, ) + started = perf_counter() + result = inspect_requirement(binding.requirement) + check = CapabilityDependencyCheck( + capability=binding.capability, + provenance=binding.provenance, + kind=DependencyKind.distribution, + **result, ) + checks.append(check) + if on_check_complete is not None: + on_check_complete(check, perf_counter() - started) if include_runtime_checks: for binding in self._runtime_check_bindings( selected, source=source, ): - if on_runtime_check_start is not None: - on_runtime_check_start( + if on_check_start is not None: + on_check_start( binding.capability, + DependencyKind.runtime, binding.check.label, ) started = perf_counter() @@ -387,8 +398,8 @@ def dependency_checks( error=result["error"], ) checks.append(check) - if on_runtime_check_complete is not None: - on_runtime_check_complete( + if on_check_complete is not None: + on_check_complete( check, perf_counter() - started, ) diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py index 871f824..f2c428f 100644 --- a/src/vidxp/cli_commands/runtime.py +++ b/src/vidxp/cli_commands/runtime.py @@ -8,6 +8,7 @@ ApplicationError, CapabilityDependencyCheck, DependencyCheckCommand, + DependencyKind, ErrorCategory, PrepareModelsCommand, ) @@ -52,9 +53,14 @@ def doctor( ) output_format = effective_output_format(state, json_output) - def show_check_start(capability: str, name: str) -> None: + def show_check_start( + capability: str, + kind: DependencyKind, + name: str, + ) -> None: + label = f"package {name}" if kind == DependencyKind.distribution else name emit_progress( - f"Checking [{capability}] {name}...", + f"Checking [{capability}] {label}...", newline=False, ) @@ -63,8 +69,13 @@ def show_check_complete( elapsed_seconds: float, ) -> None: if check.ok: + detail = ( + f"version {check.installed_version}" + if check.kind == DependencyKind.distribution + else f"{elapsed_seconds:.1f}s" + ) typer.secho( - f" OK ({elapsed_seconds:.1f}s)", + f" OK ({detail})", fg=typer.colors.GREEN, ) else: @@ -75,10 +86,10 @@ def show_check_complete( result = state.service.check_dependencies( DependencyCheckCommand(modalities=selected), - on_runtime_check_start=( + on_check_start=( show_check_start if output_format == OutputFormat.rich else None ), - on_runtime_check_complete=( + on_check_complete=( show_check_complete if output_format == OutputFormat.rich else None @@ -88,25 +99,6 @@ def show_check_complete( if output_format == OutputFormat.json: emit_json(payload) else: - for check in payload["checks"]: - if check["kind"] == "runtime": - continue - owner = check["capability"] - if check["provenance"] is not None: - owner = ( - f"{check['provenance']['distribution']}:" - f"{check['provenance']['entry_point']}" - ) - if check["ok"]: - typer.secho( - f"OK [{owner}] {check['name']}", - fg=typer.colors.GREEN, - ) - else: - typer.secho( - f"FAILED [{owner}] {check['name']}: {check['error']}", - fg=typer.colors.RED, - ) python_failures = tuple( check for check in result.checks diff --git a/src/vidxp/entrypoint.py b/src/vidxp/entrypoint.py new file mode 100644 index 0000000..72ea4db --- /dev/null +++ b/src/vidxp/entrypoint.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import os +import sys +from datetime import datetime + + +def _wants_json(arguments: list[str]) -> bool: + if "--json" in arguments: + return True + for index, value in enumerate(arguments): + if value == "--format" and index + 1 < len(arguments): + return arguments[index + 1].lower() == "json" + if value.startswith("--format="): + return value.split("=", 1)[1].lower() == "json" + return os.environ.get("VIDXP_OUTPUT_FORMAT", "").lower() == "json" + + +def startup_command(arguments: list[str]) -> str | None: + if ( + _wants_json(arguments) + or any( + value in arguments + for value in ("--quiet", "-q", "--help", "--version", "-V") + ) + ): + return None + root_values = { + "--repository", + "-r", + "--config", + "--index-dir", + "--device", + "--format", + } + command = [] + skip_next = False + for value in arguments: + if skip_next: + skip_next = False + continue + if not command and value in root_values: + skip_next = True + continue + if not command and value.startswith("-"): + continue + command.append(value) + if len(command) == 2: + break + path = tuple(command) + if path and path[0] in { + "benchmark", + "doctor", + "prepare", + "query", + "search", + "ui", + }: + return path[0] + if path in { + ("actors", "render"), + ("artifacts", "snippet"), + ("index", "create"), + ("media", "import"), + }: + return " ".join(path) + return None + + +def main() -> None: + command = startup_command(sys.argv[1:]) + if command is not None: + timestamp = datetime.now().astimezone() + print( + f"[{timestamp:%H:%M:%S}] Starting VidXP {command}...", + file=sys.stderr, + flush=True, + ) + from vidxp.cli import main as cli_main + + cli_main() diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 4a9578e..8edd23d 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -431,7 +431,7 @@ def fail_runtime_check(): check = CapabilityRegistry((plugin,)).dependency_checks( ("ocr",), - on_runtime_check_start=lambda capability, name: events.append( + on_check_start=lambda capability, _kind, name: events.append( (capability, name) ), )[0] diff --git a/tests/test_cli.py b/tests/test_cli.py index 4fdb2e3..2ccb7ee 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,6 +9,7 @@ from typer.testing import CliRunner from vidxp import cli +from vidxp.entrypoint import startup_command from vidxp.composition import LocalApplicationContext, settings_for_repository from vidxp.settings import ApplicationMode from vidxp.application_models import ( @@ -99,6 +100,16 @@ def test_grouped_commands_are_exposed(self): ): self.assertIn(command, result.output) + def test_startup_notice_targets_long_interactive_commands(self): + self.assertEqual(startup_command(["doctor"]), "doctor") + self.assertEqual( + startup_command(["media", "import", "video.mp4"]), + "media import", + ) + self.assertIsNone(startup_command(["doctor", "--json"])) + self.assertIsNone(startup_command(["--quiet", "prepare"])) + self.assertIsNone(startup_command(["repositories", "list"])) + def test_snippet_rejects_an_inverted_time_range_before_submission(self): result = self.invoke( ["artifacts", "snippet", MEDIA_ID, "3", "2"] @@ -405,11 +416,31 @@ def test_doctor_streams_timestamped_runtime_check_progress(self): def check_dependencies( _command, *, - on_runtime_check_start, - on_runtime_check_complete, + on_check_start, + on_check_complete, ): - on_runtime_check_start("scene", "Torch import") - on_runtime_check_complete( + on_check_start( + "scene", + DependencyKind.distribution, + "torch", + ) + on_check_complete( + CapabilityDependencyCheck( + capability="scene", + kind=DependencyKind.distribution, + name="torch", + requirement="torch==2.13.0", + installed_version="2.13.0", + ok=True, + ), + 0.01, + ) + on_check_start( + "scene", + DependencyKind.runtime, + "Torch import", + ) + on_check_complete( CapabilityDependencyCheck( capability="scene", kind=DependencyKind.runtime, @@ -421,7 +452,22 @@ def check_dependencies( return DependencyCheckResult( ok=True, modalities=("scene",), - checks=(), + checks=( + CapabilityDependencyCheck( + capability="scene", + kind=DependencyKind.distribution, + name="torch", + requirement="torch==2.13.0", + installed_version="2.13.0", + ok=True, + ), + CapabilityDependencyCheck( + capability="scene", + kind=DependencyKind.runtime, + name="Torch import", + ok=True, + ), + ), ) self.service.check_dependencies.side_effect = check_dependencies @@ -434,7 +480,7 @@ def check_dependencies( r"\[\d{2}:\d{2}:\d{2}\] Checking \[scene\] Torch import\.\.\.", ) self.assertIn("OK (1.2s)", result.output) - self.assertNotIn("OK [scene] Torch import", result.output) + self.assertEqual(result.output.count("package torch"), 1) def test_doctor_prints_install_remedy_for_python_failures(self): self.service.check_dependencies.return_value = DependencyCheckResult( diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 04e8356..2c68656 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -187,6 +187,10 @@ def test_desktop_manifest_matches_published_package_contract(self): project = tomllib.loads( (ROOT / "pyproject.toml").read_text(encoding="utf-8") ) + self.assertEqual( + project["project"]["scripts"]["vidxp"], + "vidxp.entrypoint:main", + ) manifest = json.loads( (ROOT / "desktop" / "runtime-manifest.json").read_text( encoding="utf-8" From 0cbbe3cd4a1472996f45935e7097aa76289ad099 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 18:53:44 +0500 Subject: [PATCH 21/46] fix: standardize dependency check details --- src/vidxp/cli_commands/runtime.py | 2 +- tests/test_cli.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py index f2c428f..f435b12 100644 --- a/src/vidxp/cli_commands/runtime.py +++ b/src/vidxp/cli_commands/runtime.py @@ -70,7 +70,7 @@ def show_check_complete( ) -> None: if check.ok: detail = ( - f"version {check.installed_version}" + f"version {check.installed_version}, {elapsed_seconds:.1f}s" if check.kind == DependencyKind.distribution else f"{elapsed_seconds:.1f}s" ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2ccb7ee..be4a85f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -480,6 +480,7 @@ def check_dependencies( r"\[\d{2}:\d{2}:\d{2}\] Checking \[scene\] Torch import\.\.\.", ) self.assertIn("OK (1.2s)", result.output) + self.assertIn("OK (version 2.13.0, 0.0s)", result.output) self.assertEqual(result.output.count("package torch"), 1) def test_doctor_prints_install_remedy_for_python_failures(self): From 6856082d233e657d93f2c5b3b67ab30e8fcde1f7 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 19:43:39 +0500 Subject: [PATCH 22/46] feat: normalize scene sampling cadence --- INSTALLATION_GUIDE.md | 4 +- README.md | 16 ++-- changes/scene-sampling.feature.md | 2 + docs/architecture/platform.md | 13 +++ docs/benchmarking/core_contract.md | 22 +++-- docs/benchmarking_research.md | 10 +-- src/vidxp/application.py | 10 ++- src/vidxp/application_models.py | 65 +++++++++++++- src/vidxp/benchmarks/cli.py | 11 ++- src/vidxp/benchmarks/didemo.py | 6 +- src/vidxp/capabilities/actor/indexing.py | 4 + src/vidxp/capabilities/scene/config.py | 1 + src/vidxp/capabilities/scene/indexing.py | 19 +++- src/vidxp/capabilities/visual.py | 61 +++++++++++-- src/vidxp/cli_commands/index.py | 19 +++- src/vidxp/core/contracts.py | 2 +- src/vidxp/core/video.py | 81 +++++++++++++++-- src/vidxp/frontend.py | 49 +++++++++- tests/test_api.py | 2 + tests/test_application.py | 2 + tests/test_benchmark_cli.py | 3 + tests/test_capabilities.py | 8 +- tests/test_cli.py | 3 + tests/test_frontend.py | 64 +++++++++++++ tests/test_indexing.py | 109 ++++++++++++++++++++++- tests/test_mcp.py | 2 + tests/test_public_path_contracts.py | 47 ++++++++++ tests/test_video.py | 58 +++++++++++- 28 files changed, 640 insertions(+), 53 deletions(-) create mode 100644 changes/scene-sampling.feature.md diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md index e8deb62..be9cccc 100644 --- a/INSTALLATION_GUIDE.md +++ b/INSTALLATION_GUIDE.md @@ -150,12 +150,12 @@ vidxp index create samplevideo.mp4 vidxp search scene "a yellow taxi on a city street" ``` -Use fewer capabilities or a larger visual stride when appropriate: +Use fewer capabilities or adjust the scene sampling rate when appropriate: ```bash vidxp index create samplevideo.mp4 \ --modality scene \ - --frame-stride 5 + --scene-sample-fps 1 ``` Indexes and manifests live under the configured repository root. Model identity, diff --git a/README.md b/README.md index 6087d27..63441ff 100644 --- a/README.md +++ b/README.md @@ -148,13 +148,18 @@ vidxp artifacts snippet 30 45 --json vidxp artifacts show ``` -Index only selected capabilities or sample fewer visual frames: +Index only selected capabilities or adjust the time-based scene sampling rate: ```bash -vidxp index create --modality scene --frame-stride 5 +vidxp index create --modality scene --scene-sample-fps 1 ``` Repeat `--modality` to combine `dialogue`, `scene`, and `actor`. +Scene sampling is normalized to the source frame rate; lower-FPS videos use +every available frame without duplicating frames. `--frame-stride` remains the +independent actor/legacy visual sampling control. +Scene indexes created before time-based sampling must be rebuilt before adding +or replacing media under the new sampling profile. Indexing, artifact rendering, and model preparation run as durable background jobs. Commands wait by default; add `--detach` to return after queueing, then inspect or control the job separately: @@ -227,7 +232,7 @@ curl -F "upload=@samplevideo.mp4" \ curl -X POST http://127.0.0.1:8000/api/v1/jobs/index \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 12345678-1234-4234-8123-123456789abc" \ - -d '{"media_id":"","modalities":["scene"]}' + -d '{"media_id":"","modalities":["scene"],"scene_sample_fps":1.0}' ``` Non-loopback and server-mode deployments must configure static bearer or OIDC @@ -268,7 +273,8 @@ OpenAPI or call the HTTP API internally. Remote video bytes still use HTTP/tus. Upload and resume the video, wait for a `media_id`, and then pass that ID to `start_indexing`. MCP never carries video -bytes, base64 video, or server file paths. +bytes, base64 video, or server file paths. The `start_indexing` command accepts +the same `scene_sample_fps` field as the HTTP API. Static deployments use the configured bearer token as an MCP request header. OIDC deployments must also set the canonical resource URL: @@ -319,7 +325,7 @@ config = IndexConfig( split="local", run_id="demo", enabled_modalities=("scene",), - frame_stride=5, + capability_options={"scene": {"sample_fps": 1.0}}, ) run_index( diff --git a/changes/scene-sampling.feature.md b/changes/scene-sampling.feature.md new file mode 100644 index 0000000..fe50dee --- /dev/null +++ b/changes/scene-sampling.feature.md @@ -0,0 +1,2 @@ +Sample scenes at a media-normalized rate with CLI, API, MCP, and UI controls +while keeping actor cadence independent and rejecting incompatible old indexes. diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md index d745867..e4606f5 100644 --- a/docs/architecture/platform.md +++ b/docs/architecture/platform.md @@ -514,6 +514,19 @@ handlers do not each load models independently. The scheduler applies backpressure to CPU threads, RAM and VRAM. API request threadpools are not the GPU scheduler. +### 12.1 Scene sampling delivery boundary + +The current scene-indexing deliverable uses deterministic time-based sampling at +a configured interval. This is the minimal sampling path required to make scene +indexing predictable and runnable through the complete application; it does not +claim content-aware or shot-aware frame selection. + +Content-aware or shot-boundary sampling is deferred. Before it is considered for +the default path, benchmark it against time-based sampling for retrieval quality, +indexing time, materialized frames, and memory use. Add sampling-specific +backpressure only if those measurements show that bounded model/write batches and +the existing scheduler do not adequately control resource use. + ## 13. Cross-platform CPU and acceleration CPU completion is the first platform gate. The supported local targets are: diff --git a/docs/benchmarking/core_contract.md b/docs/benchmarking/core_contract.md index e7581e8..f2446d0 100644 --- a/docs/benchmarking/core_contract.md +++ b/docs/benchmarking/core_contract.md @@ -46,10 +46,11 @@ from vidxp.core.runner import run_index config = IndexConfig( dataset="example", split="test", - run_id="clip-stride-5", + run_id="scene-1fps", enabled_modalities=("scene",), - frame_stride=5, - capability_options={"scene": {"batch_size": 32}}, + capability_options={ + "scene": {"batch_size": 32, "sample_fps": 1.0}, + }, storage_batch_size=256, ) @@ -98,12 +99,15 @@ to independently sized scene and actor batches. Actor detections are written incrementally; clusters below `actor_min_detections` are removed after clustering instead of retaining every detection in process memory. -`frame_stride` controls which frames are retrieved into Python and sent through -the models. Skipped frames are advanced with the video backend without being -materialized. Inter-frame codecs may still require backend-internal decoding, so -stride is not claimed to reduce codec work in direct proportion to the stride. -Manifests distinguish `source_frames_advanced`, unique `sampled_frames`, and the -sum of per-modality `frame_operations`. +Scene `sample_fps` is converted from the probed source FPS into a deterministic +frame cadence. A source below the requested rate uses every available frame +without duplication. `frame_stride` independently controls actor and legacy +visual capability sampling. When scene and actor are both enabled, the decoder +materializes the union of their required frames once and routes each capability +only its own cadence. Inter-frame codecs may still require backend-internal +decoding, so sampling is not claimed to reduce codec work in direct proportion +to the cadence. Manifests distinguish `source_frames_advanced`, unique +`sampled_frames`, and the sum of per-modality `frame_operations`. ## Stored identity and metadata diff --git a/docs/benchmarking_research.md b/docs/benchmarking_research.md index e6769e5..d960bd2 100644 --- a/docs/benchmarking_research.md +++ b/docs/benchmarking_research.md @@ -63,7 +63,7 @@ The `face_recognition` project states **99.38% accuracy on Labeled Faces in the Use a separate “published context” table, followed by the paper's own “same-harness baselines” table. The latter should contain only methods rerun on the identical frozen VidXP test set, for example: - dialogue: random, exact lexical/BM25, reference-transcript oracle, and full WhisperX pipeline; -- scene: random, CLIP with each pre-registered frame stride, and any temporal aggregation variant; +- scene: random, CLIP with each pre-registered time-based sample rate, and any temporal aggregation variant; - actors: all-singleton, all-in-one, fixed 0.55 threshold, and development-tuned threshold. State underneath the published-context table: **“Values are reproduced from their source papers and are not directly comparable to VidXP because datasets, languages, retrieval units, supervision, relevance definitions, and hardware differ.”** This prevents a literature-review table from being mistaken for an empirical leaderboard. @@ -74,7 +74,7 @@ Use an immutable, versioned corpus and publish a manifest containing video IDs, Use three disjoint splits: -- **Development:** select face threshold, five-word phrase length, frame stride, temporal de-duplication, similarity settings, and any rejection threshold. +- **Development:** select face threshold, five-word phrase length, scene sample rate, temporal de-duplication, similarity settings, and any rejection threshold. - **Test:** run once after the protocol and parameters are frozen. - **Optional training:** only if a component is trained or fine-tuned. VidXP's present models are otherwise evaluated zero-shot. @@ -244,7 +244,7 @@ Record peak: PyTorch's `max_memory_allocated()` returns the peak tensor memory since program start and documents resetting peak statistics between stages ([official API](https://docs.pytorch.org/docs/stable/generated/torch.cuda.memory.max_memory_allocated.html)). Python exposes maximum RSS through `resource.getrusage` ([official documentation](https://docs.python.org/3/library/resource.html)); NVIDIA documents sampled GPU utilization and frame-buffer memory semantics in `nvidia-smi` ([official documentation](https://docs.nvidia.com/deploy/nvidia-smi/index.html)). -For every result table, report CPU model/core count, RAM, storage, GPU and VRAM, OS, accelerator driver/runtime, Python and dependency versions, model identifiers/hashes, precision/compute type, batch size, thread counts, input resolution/fps, frame stride, database settings, and Git commit. Fix seeds where randomness exists, keep the machine otherwise idle, randomize condition order, and publish raw per-run measurements and the benchmark script. +For every result table, report CPU model/core count, RAM, storage, GPU and VRAM, OS, accelerator driver/runtime, Python and dependency versions, model identifiers/hashes, precision/compute type, batch size, thread counts, input resolution/fps, effective scene sample rate, actor frame stride, database settings, and Git commit. Fix seeds where randomness exists, keep the machine otherwise idle, randomize condition order, and publish raw per-run measurements and the benchmark script. ## 6. Aggregation, uncertainty, and comparisons @@ -253,13 +253,13 @@ For every result table, report CPU model/core count, RAM, storage, GPU and VRAM, - Report 95% confidence intervals by resampling the highest independent unit—source title/video, not adjacent frames or queries from the same event. Use the same resampled units for paired system comparisons. - Publish per-query/per-video scores so alternative aggregation is possible. - Compare systems on the identical frozen queries and judgments. -- Present quality and efficiency together: e.g. \(R@1\) versus RTF for Whisper model/compute type, scene frame stride, and face threshold/history choices. +- Present quality and efficiency together: e.g. \(R@1\) versus RTF for Whisper model/compute type, scene sample rate, and face threshold/history choices. ## 7. Minimum paper table set 1. Corpus and annotation statistics by split and query stratum. 2. Dialogue ASR/alignment results, then end-to-end retrieval with reference-transcript oracle and lexical baseline. -3. Scene retrieval by query type, within-video versus corpus-wide, with frame-stride ablation. +3. Scene retrieval by query type, within-video versus corpus-wide, with sample-rate ablation. 4. Actor detection coverage plus B-cubed P/R/F1, pairwise P/R/F1, ARI, and cluster-count diagnostics. 5. Indexing RTF/stage breakdown, query cold/warm p50/p95 and QPS, peak RAM/accelerator memory, and index size. 6. Paired 95% confidence intervals and qualitative failure examples selected by a fixed rule (for example, worst five test queries per module), not hand-picked successes. diff --git a/src/vidxp/application.py b/src/vidxp/application.py index 0857abb..1878bec 100644 --- a/src/vidxp/application.py +++ b/src/vidxp/application.py @@ -211,6 +211,14 @@ def create_index( media = self.media.require_record(command.media_id) content = self.media.content(command.media_id) self.layout.ensure_local_directories() + capability_options = { + name: dict(options) + for name, options in command.capability_options.items() + } + if command.scene_sample_fps is not None: + capability_options.setdefault("scene", {})["sample_fps"] = ( + command.scene_sample_fps + ) config = IndexConfig.local( video_id=command.media_id, enabled_modalities=selected, @@ -219,7 +227,7 @@ def create_index( collection_names=self.registry.collection_names(selected), capability_options=self.registry.validate_options( selected, - command.capability_options, + capability_options, ), device=self.device, ) diff --git a/src/vidxp/application_models.py b/src/vidxp/application_models.py index aa92a79..2830e2a 100644 --- a/src/vidxp/application_models.py +++ b/src/vidxp/application_models.py @@ -345,11 +345,74 @@ class UploadIntent(ApplicationModel): class CreateIndexCommand(ApplicationModel): media_id: MediaId modalities: tuple[str, ...] - frame_stride: int = Field(default=1, gt=0) + frame_stride: int = Field( + default=1, + gt=0, + description=( + "Materialize every Nth frame for actor and legacy visual indexing." + ), + ) + scene_sample_fps: float | None = Field( + default=None, + gt=0, + description=( + "Target scene samples per second. Sources below this rate use " + "every available frame without duplication." + ), + ) capability_options: Mapping[str, Mapping[str, JsonValue]] = Field( default_factory=dict ) + @model_validator(mode="before") + @classmethod + def _canonicalize_scene_sampling(cls, value: Any) -> Any: + if not isinstance(value, Mapping): + return value + payload = dict(value) + raw_options = payload.get("capability_options") + if not isinstance(raw_options, Mapping): + return payload + raw_scene = raw_options.get("scene") + if not isinstance(raw_scene, Mapping) or "sample_fps" not in raw_scene: + return payload + + nested_value = raw_scene["sample_fps"] + explicit_value = payload.get("scene_sample_fps") + if explicit_value is not None: + try: + conflicts = float(explicit_value) != float(nested_value) + except (TypeError, ValueError): + conflicts = True + if conflicts: + raise ValueError( + "scene_sample_fps conflicts with " + "capability_options.scene.sample_fps." + ) + else: + payload["scene_sample_fps"] = nested_value + + options = dict(raw_options) + scene = dict(raw_scene) + scene.pop("sample_fps") + if scene: + options["scene"] = scene + else: + options.pop("scene", None) + payload["capability_options"] = options + return payload + + @model_validator(mode="after") + def _scene_sampling_requires_scene(self) -> "CreateIndexCommand": + if ( + self.scene_sample_fps is not None + and "scene" not in self.modalities + ): + raise ValueError( + "scene_sample_fps requires the scene modality." + ) + return self + class IndexResult(ApplicationModel): media_id: MediaId diff --git a/src/vidxp/benchmarks/cli.py b/src/vidxp/benchmarks/cli.py index b05c884..132cbd3 100644 --- a/src/vidxp/benchmarks/cli.py +++ b/src/vidxp/benchmarks/cli.py @@ -140,7 +140,14 @@ def didemo_command( help="Pool sampled frame scores within each five-second chunk." ), ] = "max", - frame_stride: Annotated[int, typer.Option(min=1)] = 1, + scene_sample_fps: Annotated[ + float, + typer.Option( + "--scene-sample-fps", + min=0.01, + help="Target time-based scene samples per second.", + ), + ] = 1.0, reset: Annotated[bool, typer.Option()] = False, json_output: Annotated[ bool, @@ -158,7 +165,7 @@ def didemo_command( run_id=run_id, output_root=output_root, annotation_indices=_annotation_indices(annotation_indices), - frame_stride=frame_stride, + scene_sample_fps=scene_sample_fps, split=split, chunk_pooling=chunk_pooling, reset=reset, diff --git a/src/vidxp/benchmarks/didemo.py b/src/vidxp/benchmarks/didemo.py index a07012b..d4cb021 100644 --- a/src/vidxp/benchmarks/didemo.py +++ b/src/vidxp/benchmarks/didemo.py @@ -363,7 +363,7 @@ def run_didemo( output_root: str | Path = "benchmark_runs", annotation_indices: Sequence[int] | None = None, media_overrides: Mapping[str, str | Path] | None = None, - frame_stride: int = 1, + scene_sample_fps: float = 1.0, device: str = "cpu", split: Literal["validation", "test"] = "test", chunk_pooling: Literal["max", "mean"] = "max", @@ -387,7 +387,9 @@ def run_didemo( split=split, run_id=run_id, enabled_modalities=("scene",), - frame_stride=frame_stride, + capability_options={ + "scene": {"sample_fps": scene_sample_fps}, + }, device=device, output_root=output_root, ) diff --git a/src/vidxp/capabilities/actor/indexing.py b/src/vidxp/capabilities/actor/indexing.py index c8fc3a7..35da06c 100644 --- a/src/vidxp/capabilities/actor/indexing.py +++ b/src/vidxp/capabilities/actor/indexing.py @@ -13,6 +13,7 @@ stable_source_id, ) from vidxp.core.indexing_common import ProgressCallback +from vidxp.core.video import FrameSampling from vidxp.ports import IndexStore, ModelRuntimePort @@ -259,6 +260,9 @@ def finalize_actor_index( class ActorVisualProcessor: + def sampling(self, config: IndexConfig, info) -> FrameSampling: + return FrameSampling(frame_stride=config.frame_stride) + def batch_size(self, config: IndexConfig) -> int: return actor_config(config).batch_size diff --git a/src/vidxp/capabilities/scene/config.py b/src/vidxp/capabilities/scene/config.py index e8749f5..d52501b 100644 --- a/src/vidxp/capabilities/scene/config.py +++ b/src/vidxp/capabilities/scene/config.py @@ -8,6 +8,7 @@ class SceneConfig(CapabilityConfig): batch_size: int = Field(default=32, gt=0) + sample_fps: float = Field(default=1.0, gt=0) def scene_config(config: IndexConfig) -> SceneConfig: diff --git a/src/vidxp/capabilities/scene/indexing.py b/src/vidxp/capabilities/scene/indexing.py index d735189..5f721e6 100644 --- a/src/vidxp/capabilities/scene/indexing.py +++ b/src/vidxp/capabilities/scene/indexing.py @@ -14,6 +14,7 @@ stable_source_id, ) from vidxp.core.indexing_common import ProgressCallback, report_progress +from vidxp.core.video import FrameSampling from vidxp.ports import IndexStore, ModelRuntimePort @@ -23,6 +24,13 @@ class SceneIndexState: stored_frames: int = 0 +def scene_sampling(config: IndexConfig, info) -> FrameSampling: + return FrameSampling( + source_fps=info.fps, + target_fps=scene_config(config).sample_fps, + ) + + def encode_scene_batch(samples, provider: SceneModel): import torch from PIL import Image @@ -45,10 +53,12 @@ def scene_records( config: IndexConfig, ) -> list[StorageRecord]: records = [] + sampling = scene_sampling(config, info) for sample, vector in zip(samples, vectors): - end = min( - info.duration, - sample.timestamp + config.frame_stride / info.fps, + end = sampling.next_sample_timestamp( + sample.frame_index, + frame_count=info.frame_count, + duration=info.duration, ) if end <= sample.timestamp: end = sample.timestamp + 1 / info.fps @@ -102,6 +112,9 @@ def process_scene_samples( class SceneVisualProcessor: + def sampling(self, config: IndexConfig, info) -> FrameSampling: + return scene_sampling(config, info) + def batch_size(self, config: IndexConfig) -> int: return scene_config(config).batch_size diff --git a/src/vidxp/capabilities/visual.py b/src/vidxp/capabilities/visual.py index 6cf3daf..a49e2dd 100644 --- a/src/vidxp/capabilities/visual.py +++ b/src/vidxp/capabilities/visual.py @@ -15,6 +15,7 @@ from vidxp.ports import IndexStore, ModelRuntimePort from vidxp.core.video import ( FrameSample, + FrameSampling, FrameStreamStats, iter_frame_batches, probe_video, @@ -22,6 +23,8 @@ class VisualProcessor(Protocol): + def sampling(self, config: IndexConfig, info: Any) -> FrameSampling: ... + def batch_size(self, config: IndexConfig) -> int: ... def prepare( @@ -56,6 +59,7 @@ class _Participant: name: str processor: VisualProcessor state: Any + sampling: FrameSampling def _rgb_samples(samples) -> list[FrameSample]: @@ -74,6 +78,7 @@ def _rgb_samples(samples) -> list[FrameSample]: def _participants( names: Sequence[str], *, + info: Any, config: IndexConfig, registry: CapabilityRegistry, runtime: ModelRuntimePort, @@ -90,10 +95,46 @@ def _participants( started = perf_counter() state = processor.prepare(config, runtime, progress) timings[name] = perf_counter() - started - participants.append(_Participant(name, processor, state)) + sampling_factory = getattr(type(processor), "sampling", None) + sampling = ( + FrameSampling(frame_stride=config.frame_stride) + if sampling_factory is None + else sampling_factory(processor, config, info) + ) + if not isinstance(sampling, FrameSampling): + raise ValueError( + f"Capability {name!r} returned invalid frame sampling." + ) + participants.append(_Participant(name, processor, state, sampling)) return participants +def _is_participant_sample( + sample: FrameSample, + participant: _Participant, +) -> bool: + return participant.sampling.includes( + sample.frame_index, + sample.timestamp, + ) + + +def _expected_sample_count( + info: Any, + participants: Sequence[_Participant], +) -> int: + return sum( + any( + participant.sampling.includes( + frame_index, + frame_index / info.fps, + ) + for participant in participants + ) + for frame_index in range(max(0, info.frame_count)) + ) + + def _consume_visual_stream( source: VideoSource, *, @@ -110,7 +151,9 @@ def _consume_visual_stream( stream = iter( iter_frame_batches( source.path, - frame_stride=config.frame_stride, + samplings=tuple( + participant.sampling for participant in participants + ), batch_size=max( participant.processor.batch_size(config) for participant in participants @@ -130,9 +173,16 @@ def _consume_visual_stream( timings["frame_stream"] += perf_counter() - stream_started for participant in participants: + participant_samples = [ + sample + for sample in rgb_samples + if _is_participant_sample(sample, participant) + ] + if not participant_samples: + continue processor_started = perf_counter() participant.processor.process( - rgb_samples, + participant_samples, state=participant.state, info=info, config=config, @@ -205,20 +255,19 @@ def index_visuals( started = perf_counter() info = probe_video(source.path) - expected = ( - info.frame_count + config.frame_stride - 1 - ) // config.frame_stride timings = { "frame_stream": 0.0, } participants = _participants( selected, + info=info, config=config, registry=registry, runtime=runtime, progress=progress, timings=timings, ) + expected = _expected_sample_count(info, participants) report_progress( progress, "visual_indexing", diff --git a/src/vidxp/cli_commands/index.py b/src/vidxp/cli_commands/index.py index 7cbf60a..e0d275c 100644 --- a/src/vidxp/cli_commands/index.py +++ b/src/vidxp/cli_commands/index.py @@ -27,6 +27,7 @@ def create_index( *, modalities: Iterable[str], frame_stride: int, + scene_sample_fps: float | None, capability_options: dict[str, dict], detach: bool = False, ) -> dict: @@ -39,6 +40,7 @@ def create_index( media_id=media_id, modalities=tuple(modalities), frame_stride=frame_stride, + scene_sample_fps=scene_sample_fps, capability_options=capability_options, ), ) @@ -89,9 +91,23 @@ def index_create( typer.Option( "--frame-stride", min=1, - help="Materialize every Nth frame for visual modalities.", + help=( + "Materialize every Nth frame for actor and legacy visual " + "indexing." + ), ), ] = 1, + scene_sample_fps: Annotated[ + float | None, + typer.Option( + "--scene-sample-fps", + min=0.01, + help=( + "Target scene samples per second; lower-FPS media uses every " + "available frame." + ), + ), + ] = None, capability_options: Annotated[ list[str] | None, typer.Option( @@ -126,6 +142,7 @@ def index_create( indexable, ), frame_stride=frame_stride, + scene_sample_fps=scene_sample_fps, capability_options=parse_capability_options(capability_options), detach=detach, ) diff --git a/src/vidxp/core/contracts.py b/src/vidxp/core/contracts.py index 5bb6471..406337f 100644 --- a/src/vidxp/core/contracts.py +++ b/src/vidxp/core/contracts.py @@ -11,7 +11,7 @@ from urllib.parse import quote -INDEX_SCHEMA_VERSION = 5 +INDEX_SCHEMA_VERSION = 6 MANIFEST_SCHEMA_VERSION = 2 diff --git a/src/vidxp/core/video.py b/src/vidxp/core/video.py index 171f2d5..538cba4 100644 --- a/src/vidxp/core/video.py +++ b/src/vidxp/core/video.py @@ -1,8 +1,9 @@ from __future__ import annotations from dataclasses import dataclass +from math import floor from pathlib import Path -from typing import Iterator +from typing import Iterator, Sequence from vidxp.core.contracts import CancellationToken from vidxp.core.indexing_common import ProgressCallback @@ -30,6 +31,59 @@ class FrameSample: frame: object +@dataclass(frozen=True) +class FrameSampling: + frame_stride: int | None = None + source_fps: float | None = None + target_fps: float | None = None + + def __post_init__(self) -> None: + frame_based = self.frame_stride is not None + time_based = self.source_fps is not None or self.target_fps is not None + if frame_based == time_based: + raise ValueError( + "Frame sampling requires either a stride or a source/target FPS pair." + ) + if frame_based and self.frame_stride <= 0: + raise ValueError("frame_stride must be greater than zero.") + if time_based and ( + self.source_fps is None + or self.target_fps is None + or self.source_fps <= 0 + or self.target_fps <= 0 + ): + raise ValueError("source_fps and target_fps must be greater than zero.") + + def includes(self, frame_index: int, timestamp: float) -> bool: + if self.frame_stride is not None: + return frame_index % self.frame_stride == 0 + assert self.source_fps is not None + assert self.target_fps is not None + if self.source_fps <= self.target_fps or frame_index == 0: + return True + previous_timestamp = (frame_index - 1) / self.source_fps + return floor(timestamp * self.target_fps) > floor( + previous_timestamp * self.target_fps + ) + + def next_sample_timestamp( + self, + frame_index: int, + *, + frame_count: int, + duration: float, + ) -> float: + if self.source_fps is None: + raise ValueError( + "Next sample timestamps require time-based sampling." + ) + for next_index in range(frame_index + 1, max(0, frame_count)): + timestamp = next_index / self.source_fps + if self.includes(next_index, timestamp): + return min(duration, timestamp) + return duration + + def probe_video(path: str | Path) -> VideoInfo: import cv2 @@ -53,15 +107,20 @@ def probe_video(path: str | Path) -> VideoInfo: def iter_frame_batches( path: str | Path, *, - frame_stride: int, + frame_stride: int | None = None, + frame_strides: Sequence[int] | None = None, + samplings: Sequence[FrameSampling] | None = None, batch_size: int, cancellation: CancellationToken, stats: FrameStreamStats | None = None, ) -> Iterator[list[FrameSample]]: import cv2 - if frame_stride <= 0: - raise ValueError("frame_stride must be greater than zero.") + strides = tuple(dict.fromkeys(frame_strides or ())) + if frame_stride is not None: + strides = tuple(dict.fromkeys((frame_stride, *strides))) + if any(stride <= 0 for stride in strides): + raise ValueError("frame strides must be greater than zero.") if batch_size <= 0: raise ValueError("batch_size must be greater than zero.") @@ -70,13 +129,23 @@ def iter_frame_batches( if fps <= 0: video.release() raise ValueError("The selected video has an invalid frame rate.") + active_samplings = tuple(samplings or ()) + tuple( + FrameSampling(frame_stride=stride) for stride in strides + ) + if not active_samplings: + video.release() + raise ValueError("At least one frame sampling schedule is required.") stream_stats = stats or FrameStreamStats() batch: list[FrameSample] = [] frame_index = 0 try: while True: - sampled = frame_index % frame_stride == 0 + timestamp = frame_index / fps + sampled = any( + sampling.includes(frame_index, timestamp) + for sampling in active_samplings + ) if sampled: retrieved, frame = video.read() else: @@ -90,7 +159,7 @@ def iter_frame_batches( batch.append( FrameSample( frame_index=frame_index, - timestamp=frame_index / fps, + timestamp=timestamp, frame=frame, ) ) diff --git a/src/vidxp/frontend.py b/src/vidxp/frontend.py index 0ee8c80..623d936 100644 --- a/src/vidxp/frontend.py +++ b/src/vidxp/frontend.py @@ -69,6 +69,13 @@ def _settings_from_arguments( INDEX_JOB_QUERY_PARAM = "index_job" SEARCH_JOB_QUERY_PARAM = "search_job" SEARCH_TYPE_QUERY_PARAM = "search_type" +SCENE_SAMPLE_FPS_DEFAULT = 1.0 +SCENE_DETAIL_PRESETS = (0.5, SCENE_SAMPLE_FPS_DEFAULT, 2.0) +SCENE_DETAIL_LABELS = { + 0.5: "Faster — every 2 seconds", + 1.0: "Balanced — every second", + 2.0: "Detailed — twice per second", +} def _remember_job( @@ -265,7 +272,13 @@ def _finish_index_job(job) -> None: st.session_state.pop(INDEX_JOB_ID_KEY, None) -def _run_indexing(uploaded_video, status, modalities): +def _run_indexing( + uploaded_video, + status, + modalities, + *, + scene_sample_fps: float | None = None, +): service = _configured_service() temporary_path = None try: @@ -300,6 +313,7 @@ def _run_indexing(uploaded_video, status, modalities): CreateIndexCommand( media_id=media_id, modalities=modalities, + scene_sample_fps=scene_sample_fps, ) ) st.session_state[INDEX_JOB_ID_KEY] = job.job_id @@ -323,6 +337,28 @@ def _run_indexing(uploaded_video, status, modalities): st.rerun() +def _scene_sample_fps_control( + modalities: tuple[str, ...], + *, + disabled: bool, +) -> float | None: + if "scene" not in modalities: + return None + return float( + st.selectbox( + "Scene detail", + SCENE_DETAIL_PRESETS, + index=1, + format_func=SCENE_DETAIL_LABELS.__getitem__, + disabled=disabled, + help=( + "More scene detail can improve coverage but takes longer " + "to index and uses more storage." + ), + ) + ) + + def _run_search(search_type, query, media_id=None): service = _configured_service() try: @@ -786,6 +822,10 @@ def run(): help="Install another capability extra to make it available here.", ) ) + scene_sample_fps = _scene_sample_fps_control( + selected_modalities, + disabled=busy, + ) if not installed_modalities: st.warning( "No indexing capabilities are installed. " @@ -898,7 +938,12 @@ def poll_index_status(): _render_search_result(st.session_state.get(SEARCH_RESULT_KEY)) if requested: - _run_indexing(uploaded_video, status, selected_modalities) + _run_indexing( + uploaded_video, + status, + selected_modalities, + scene_sample_fps=scene_sample_fps, + ) def main( diff --git a/tests/test_api.py b/tests/test_api.py index d736cc6..14f3193 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -354,6 +354,7 @@ def test_job_submission_is_thin_idempotent_delegation(self): "media_id": MEDIA_ID, "modalities": ["scene"], "frame_stride": 1, + "scene_sample_fps": 0.5, "capability_options": {}, }, ) @@ -363,6 +364,7 @@ def test_job_submission_is_thin_idempotent_delegation(self): command = context.jobs.submit_index.call_args.args[0] self.assertEqual(command.media_id, MEDIA_ID) self.assertEqual(command.modalities, ("scene",)) + self.assertEqual(command.scene_sample_fps, 0.5) expected_job_id = scoped_job_id( context, context.authenticator.authenticate(None), diff --git a/tests/test_application.py b/tests/test_application.py index 6580f49..e09976e 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -444,6 +444,7 @@ def test_create_index_builds_one_central_config(self): media_id=MEDIA_ID, modalities=("scene",), frame_stride=5, + scene_sample_fps=2.0, ) ) @@ -460,6 +461,7 @@ def test_create_index_builds_one_central_config(self): config = backend.create.call_args.kwargs["config"] self.assertEqual(config.enabled_modalities, ("scene",)) self.assertEqual(config.frame_stride, 5) + self.assertEqual(config.options_for("scene")["sample_fps"], 2.0) self.assertEqual(config.device, "cpu") def test_operation_definition_is_metadata_and_executor_owns_handler(self): diff --git a/tests/test_benchmark_cli.py b/tests/test_benchmark_cli.py index 2d72e20..f1fc655 100644 --- a/tests/test_benchmark_cli.py +++ b/tests/test_benchmark_cli.py @@ -100,12 +100,15 @@ def test_benchmark_uses_global_device_and_json_output(self): str(media), "--run-id", "run-1", + "--scene-sample-fps", + "2", ], ) self.assertEqual(response.exit_code, 0, response.output) self.assertEqual(json.loads(response.stdout)["rank_at_1"], 0.5) self.assertEqual(run.call_args.kwargs["device"], "cuda") + self.assertEqual(run.call_args.kwargs["scene_sample_fps"], 2.0) def test_missing_adapter_dependencies_fail_with_exact_extra_hint(self): registry = Mock() diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 8edd23d..8a9d8b5 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -124,10 +124,16 @@ def test_built_in_settings_are_owned_and_validated(self): options = self.registry.validate_options( ("scene",), - {"scene": {"batch_size": 4}}, + {"scene": {"batch_size": 4, "sample_fps": 0.5}}, ) self.assertEqual(options["scene"]["batch_size"], 4) + self.assertEqual(options["scene"]["sample_fps"], 0.5) self.assertNotIn("model", options["scene"]) + with self.assertRaises(ValidationError): + self.registry.validate_options( + ("scene",), + {"scene": {"sample_fps": 0}}, + ) with self.assertRaises(ValidationError): self.registry.validate_options( ("scene",), diff --git a/tests/test_cli.py b/tests/test_cli.py index be4a85f..914479c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -245,6 +245,8 @@ def test_index_constructs_shared_command(self): "scene", "--frame-stride", "5", + "--scene-sample-fps", + "2", ] ) @@ -253,6 +255,7 @@ def test_index_constructs_shared_command(self): self.assertIsInstance(command, CreateIndexCommand) self.assertEqual(command.modalities, ("scene",)) self.assertEqual(command.frame_stride, 5) + self.assertEqual(command.scene_sample_fps, 2.0) self.assertEqual(command.media_id, MEDIA_ID) def test_remove_uses_shared_media_id_command(self): diff --git a/tests/test_frontend.py b/tests/test_frontend.py index f2df720..84dc5dd 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -227,6 +227,70 @@ def check(command): ) ) + def test_scene_detail_control_is_conditional_and_defaults_to_balanced(self): + with patch.object( + frontend.st, + "selectbox", + return_value=2.0, + ) as selectbox: + selected = frontend._scene_sample_fps_control( + ("dialogue", "scene"), + disabled=False, + ) + + self.assertEqual(selected, 2.0) + self.assertEqual( + selectbox.call_args.args[:2], + ("Scene detail", (0.5, 1.0, 2.0)), + ) + self.assertEqual(selectbox.call_args.kwargs["index"], 1) + + with patch.object(frontend.st, "selectbox") as selectbox: + selected = frontend._scene_sample_fps_control( + ("dialogue",), + disabled=False, + ) + + self.assertIsNone(selected) + selectbox.assert_not_called() + + def test_indexing_submits_selected_scene_sample_rate(self): + jobs = Mock() + jobs.submit_index.return_value = SimpleNamespace(job_id="job-1") + session_state = {frontend.MEDIA_ID_KEY: MEDIA_ID} + with ( + patch.object(frontend, "_configured_service", return_value=Mock()), + patch.object(frontend, "_configured_jobs", return_value=jobs), + patch.object(frontend.st, "session_state", session_state), + patch.object(frontend.st, "query_params", {}), + patch.object(frontend.st, "rerun"), + ): + frontend._run_indexing( + None, + {}, + ("scene",), + scene_sample_fps=2.0, + ) + + command = jobs.submit_index.call_args.args[0] + self.assertEqual(command.scene_sample_fps, 2.0) + + def test_indexing_omits_scene_sample_rate_without_scene(self): + jobs = Mock() + jobs.submit_index.return_value = SimpleNamespace(job_id="job-1") + session_state = {frontend.MEDIA_ID_KEY: MEDIA_ID} + with ( + patch.object(frontend, "_configured_service", return_value=Mock()), + patch.object(frontend, "_configured_jobs", return_value=jobs), + patch.object(frontend.st, "session_state", session_state), + patch.object(frontend.st, "query_params", {}), + patch.object(frontend.st, "rerun"), + ): + frontend._run_indexing(None, {}, ("dialogue",)) + + command = jobs.submit_index.call_args.args[0] + self.assertIsNone(command.scene_sample_fps) + def test_search_queues_shared_command_without_loading_models(self): with TemporaryDirectory() as directory: root = Path(directory) diff --git a/tests/test_indexing.py b/tests/test_indexing.py index 2a94aa1..3547c46 100644 --- a/tests/test_indexing.py +++ b/tests/test_indexing.py @@ -18,7 +18,11 @@ build_dialogue_phrases, index_dialogue, ) -from vidxp.capabilities.scene.indexing import encode_scene_batch +from vidxp.capabilities.scene.indexing import ( + encode_scene_batch, + scene_records, + scene_sampling, +) from vidxp.capabilities.scene.models import SceneModel from vidxp.capabilities.registry import ( CapabilityRegistry, @@ -230,7 +234,93 @@ def stream(*_, **options): processor.prepare.assert_called_once() processor.process.assert_called_once() - def test_scene_and_actor_share_one_frame_stream(self): + def test_scene_sampling_uses_target_fps_and_media_timestamps(self): + config = IndexConfig( + video_id="video-1", + enabled_modalities=("scene",), + capability_options={"scene": {"sample_fps": 1.0}}, + ) + + near_below = scene_sampling( + config, + VideoInfo(1.49, 149, 100.0, 2, 2), + ) + near_above = scene_sampling( + config, + VideoInfo(1.5, 150, 100.0, 2, 2), + ) + below_indices = [ + index + for index in range(149) + if near_below.includes(index, index / 1.49) + ] + above_indices = [ + index + for index in range(150) + if near_above.includes(index, index / 1.5) + ] + + self.assertEqual(below_indices[:4], [0, 2, 3, 5]) + self.assertEqual(above_indices[:4], [0, 2, 3, 5]) + self.assertEqual(len(below_indices), 100) + self.assertEqual(len(above_indices), 100) + + low_fps = scene_sampling( + config, + VideoInfo(0.5, 5, 10.0, 2, 2), + ) + self.assertEqual( + [ + index + for index in range(5) + if low_fps.includes(index, index / 0.5) + ], + [0, 1, 2, 3, 4], + ) + + def test_scene_record_duration_uses_scene_cadence_not_actor_stride(self): + config = IndexConfig( + video_id="video-1", + enabled_modalities=("scene",), + frame_stride=3, + capability_options={"scene": {"sample_fps": 2.0}}, + ) + info = VideoInfo(30.0, 300, 10.0, 2, 2) + + record = scene_records( + [FrameSample(15, 0.5, object())], + [[0.1, 0.2]], + info, + config, + )[0] + + self.assertEqual(record.metadata["start"], 0.5) + self.assertEqual(record.metadata["end"], 1.0) + + def test_scene_records_end_at_next_selected_sample_without_gaps(self): + config = IndexConfig( + video_id="video-1", + enabled_modalities=("scene",), + capability_options={"scene": {"sample_fps": 1.0}}, + ) + info = VideoInfo(1.49, 5, 5 / 1.49, 2, 2) + + records = scene_records( + [ + FrameSample(0, 0.0, object()), + FrameSample(2, 2 / 1.49, object()), + FrameSample(3, 3 / 1.49, object()), + ], + [[0.1], [0.2], [0.3]], + info, + config, + ) + + self.assertEqual(records[0].metadata["end"], 2 / 1.49) + self.assertEqual(records[1].metadata["end"], 3 / 1.49) + self.assertEqual(records[2].metadata["end"], info.duration) + + def test_scene_and_actor_share_decode_but_keep_independent_cadences(self): registry = create_capability_registry() scene = registry.executor("scene").index_processor actor = registry.executor("actor").index_processor @@ -240,7 +330,7 @@ def test_scene_and_actor_share_one_frame_stream(self): actor.process = Mock() scene.batch_size = Mock(return_value=2) actor.batch_size = Mock(return_value=1) - scene.finalize = Mock(return_value=({"scene_frames": 2}, 2)) + scene.finalize = Mock(return_value=({"scene_frames": 1}, 1)) actor.finalize = Mock( return_value=( {"actor_frames": 2, "actor_detections": 0, "actor_clusters": 0}, @@ -281,8 +371,19 @@ def stream(*_, **options): ) frame_stream.assert_called_once() + samplings = frame_stream.call_args.kwargs["samplings"] + self.assertEqual(samplings[0].target_fps, 1.0) + self.assertEqual(samplings[1].frame_stride, 1) + self.assertEqual( + [sample.frame_index for sample in scene.process.call_args.args[0]], + [0], + ) + self.assertEqual( + [sample.frame_index for sample in actor.process.call_args.args[0]], + [0, 1], + ) self.assertEqual(result.summary["processed_frames"], 2) - self.assertEqual(result.summary["scene_frames"], 2) + self.assertEqual(result.summary["scene_frames"], 1) self.assertEqual(result.summary["actor_frames"], 2) def test_actor_records_preserve_stable_detection_metadata(self): diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 38dcd5a..c72a979 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -149,6 +149,7 @@ async def test_index_submission_uses_shared_stable_idempotency(self): "command": { "media_id": MEDIA_ID, "modalities": ["scene"], + "scene_sample_fps": 2.0, }, "idempotency_key": "agent-request-0001", } @@ -162,6 +163,7 @@ async def test_index_submission_uses_shared_stable_idempotency(self): ) calls = context.jobs.submit_index.call_args_list self.assertEqual(len(calls), 2) + self.assertEqual(calls[0].args[0].scene_sample_fps, 2.0) self.assertEqual( calls[0].kwargs["job_id"], calls[1].kwargs["job_id"], diff --git a/tests/test_public_path_contracts.py b/tests/test_public_path_contracts.py index 8c052e8..6623473 100644 --- a/tests/test_public_path_contracts.py +++ b/tests/test_public_path_contracts.py @@ -90,6 +90,53 @@ def test_media_and_artifact_ids_are_not_content_hashes_or_names(self): with self.assertRaises(ValidationError): CreateIndexCommand(media_id="1" * 64, modalities=("scene",)) + def test_scene_sampling_requires_a_positive_scene_request(self): + with self.assertRaises(ValidationError): + CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("dialogue",), + scene_sample_fps=1.0, + ) + with self.assertRaises(ValidationError): + CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + scene_sample_fps=0, + ) + + def test_scene_sampling_has_one_canonical_command_field(self): + nested = CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + capability_options={ + "scene": {"sample_fps": 2.0, "batch_size": 4}, + }, + ) + explicit = CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + scene_sample_fps=2.0, + capability_options={"scene": {"batch_size": 4}}, + ) + + self.assertEqual(nested, explicit) + self.assertEqual(nested.scene_sample_fps, 2.0) + self.assertEqual( + nested.capability_options, + {"scene": {"batch_size": 4}}, + ) + self.assertNotIn( + "sample_fps", + nested.model_dump(mode="json")["capability_options"]["scene"], + ) + with self.assertRaisesRegex(ValidationError, "conflicts"): + CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + scene_sample_fps=1.0, + capability_options={"scene": {"sample_fps": 2.0}}, + ) + def test_search_metadata_rejects_internal_locations(self): with self.assertRaises(ValidationError): SearchHit( diff --git a/tests/test_video.py b/tests/test_video.py index 740ff15..282b4b4 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -7,6 +7,7 @@ from vidxp.core.contracts import CancellationToken from vidxp.core.video import ( + FrameSampling, FrameStreamStats, iter_frame_batches, render_actor_video, @@ -14,15 +15,16 @@ class FakeCapture: - def __init__(self, frames): + def __init__(self, frames, *, fps=10.0): self.frames = list(frames) + self.fps = fps self.position = 0 self.read_calls = 0 self.grab_calls = 0 self.released = False def get(self, _): - return 10.0 + return self.fps def isOpened(self): return True @@ -75,6 +77,58 @@ def test_stride_advances_skipped_frames_without_materializing_them(self): self.assertEqual(stats.frames_materialized, 2) self.assertTrue(capture.released) + def test_multiple_cadences_materialize_their_union_once(self): + capture = FakeCapture([f"f{index}" for index in range(8)]) + fake_cv2 = types.SimpleNamespace( + CAP_PROP_FPS=5, + VideoCapture=lambda _: capture, + ) + + with patch.dict(sys.modules, {"cv2": fake_cv2}): + batches = list( + iter_frame_batches( + "unused.mp4", + frame_strides=(3, 4), + batch_size=8, + cancellation=CancellationToken(), + ) + ) + + self.assertEqual( + [sample.frame_index for sample in batches[0]], + [0, 3, 4, 6], + ) + self.assertEqual(capture.read_calls, 5) + self.assertEqual(capture.grab_calls, 4) + + def test_time_and_frame_cadences_share_one_materialized_union(self): + capture = FakeCapture( + [f"f{index}" for index in range(6)], + fps=1.5, + ) + fake_cv2 = types.SimpleNamespace( + CAP_PROP_FPS=5, + VideoCapture=lambda _: capture, + ) + + with patch.dict(sys.modules, {"cv2": fake_cv2}): + batches = list( + iter_frame_batches( + "unused.mp4", + samplings=( + FrameSampling(source_fps=1.5, target_fps=1.0), + FrameSampling(frame_stride=2), + ), + batch_size=8, + cancellation=CancellationToken(), + ) + ) + + self.assertEqual( + [sample.frame_index for sample in batches[0]], + [0, 2, 3, 4, 5], + ) + def test_actor_renderer_creates_output_directory_and_falls_back_codec(self): with TemporaryDirectory() as directory: input_path = Path(directory) / "input.mp4" From a398cf0d1f17640662c3f80246026b2a507bfebf Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 20:21:46 +0500 Subject: [PATCH 23/46] fix: restore local indexing usability --- changes/local-runtime-usability.bugfix.md | 2 + docs/architecture/platform.md | 6 ++- src/vidxp/application_boundary.py | 10 ---- src/vidxp/frontend.py | 22 ++++----- src/vidxp/infrastructure/local_worker.py | 1 - src/vidxp/ports.py | 4 -- src/vidxp/runtime.py | 22 --------- src/vidxp/settings.py | 2 - tests/test_api.py | 2 - tests/test_application.py | 1 - tests/test_frontend.py | 57 ++++++++++++++++++++++- tests/test_local_worker.py | 8 +++- tests/test_mcp.py | 1 - tests/test_models.py | 22 --------- 14 files changed, 77 insertions(+), 83 deletions(-) create mode 100644 changes/local-runtime-usability.bugfix.md diff --git a/changes/local-runtime-usability.bugfix.md b/changes/local-runtime-usability.bugfix.md new file mode 100644 index 0000000..2d553ee --- /dev/null +++ b/changes/local-runtime-usability.bugfix.md @@ -0,0 +1,2 @@ +Remove the undocumented local free-memory refusal, keep the Windows worker +console hidden, and prevent duplicate video previews during indexing startup. diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md index e4606f5..75593f2 100644 --- a/docs/architecture/platform.md +++ b/docs/architecture/platform.md @@ -511,8 +511,10 @@ cannot drift. Model construction and caching belong to `ModelRuntime`. Adapters and capability handlers do not each load models independently. -The scheduler applies backpressure to CPU threads, RAM and VRAM. API request -threadpools are not the GPU scheduler. +The scheduler bounds concurrent model work. Local execution does not reject work +based on a fixed free-RAM threshold; allocation failures come from the runtime +that attempted the actual operation. API request threadpools are not the model +scheduler. ### 12.1 Scene sampling delivery boundary diff --git a/src/vidxp/application_boundary.py b/src/vidxp/application_boundary.py index ad37393..2965b64 100644 --- a/src/vidxp/application_boundary.py +++ b/src/vidxp/application_boundary.py @@ -43,9 +43,6 @@ MediaIdempotencyConflictError, MediaImportNotAllowedError, ) -from vidxp.ports import ResourceLimitError - - def _validation_details( exc: ValidationError, ) -> list[dict[str, JsonValue]]: @@ -111,13 +108,6 @@ def wrapped(*args: Any, **kwargs: Any) -> Any: ErrorCategory.cancelled, "The operation was cancelled.", ) from exc - except ResourceLimitError as exc: - raise ApplicationError( - "resource_limit", - ErrorCategory.resource_limit, - "The worker does not currently have enough host capacity.", - retryable=True, - ) from exc except ValidationError as exc: raise InvalidRequestError( errors=_validation_details(exc), diff --git a/src/vidxp/frontend.py b/src/vidxp/frontend.py index 623d936..32e150a 100644 --- a/src/vidxp/frontend.py +++ b/src/vidxp/frontend.py @@ -610,7 +610,7 @@ def _select_video(busy, media_id, media_page): else: st.session_state.pop(MEDIA_ID_KEY, None) - if not busy and assets: + if assets: asset_by_id = {asset.media_id: asset for asset in assets} selected_media_id = st.selectbox( "Registered video", @@ -624,6 +624,7 @@ def _select_video(busy, media_id, media_page): f"{asset_by_id[value].original_filename} " f"({asset_by_id[value].duration_seconds:.1f}s)" ), + disabled=busy, ) if selected_media_id != media_id: st.session_state.pop(SEARCH_RESULT_KEY, None) @@ -661,19 +662,14 @@ def _select_video(busy, media_id, media_page): ) st.rerun() - upload_slot = st.empty() - has_session_upload = st.session_state.get("video_upload") is not None - if busy and not has_session_upload and media_id is not None: - uploaded_video = None + uploaded_video = st.file_uploader( + "Upload an MP4, MOV, or AVI video", + type=["mp4", "mov", "avi"], + disabled=busy, + key="video_upload", + ) + if busy and uploaded_video is None and media_id is not None: st.caption("Indexing the registered video.") - else: - with upload_slot: - uploaded_video = st.file_uploader( - "Upload an MP4, MOV, or AVI video", - type=["mp4", "mov", "avi"], - disabled=busy, - key="video_upload", - ) if uploaded_video is not None: st.video(uploaded_video, width=560) diff --git a/src/vidxp/infrastructure/local_worker.py b/src/vidxp/infrastructure/local_worker.py index 5f8eddb..08626b7 100644 --- a/src/vidxp/infrastructure/local_worker.py +++ b/src/vidxp/infrastructure/local_worker.py @@ -116,7 +116,6 @@ def _ensure_running(self) -> None: if sys.platform == "win32": options["creationflags"] = ( subprocess.CREATE_NEW_PROCESS_GROUP - | subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW ) else: diff --git a/src/vidxp/ports.py b/src/vidxp/ports.py index 03bb49c..1f06861 100644 --- a/src/vidxp/ports.py +++ b/src/vidxp/ports.py @@ -44,10 +44,6 @@ from vidxp.model_contracts import ArtifactSpec, ModelKey, ModelSpec -class ResourceLimitError(RuntimeError): - """Raised when configured host capacity cannot admit model work.""" - - class InvalidJobBackendRequestError(ValueError): """Raised when a durable job identifier or cursor is malformed.""" diff --git a/src/vidxp/runtime.py b/src/vidxp/runtime.py index 1061f66..bd6b847 100644 --- a/src/vidxp/runtime.py +++ b/src/vidxp/runtime.py @@ -15,7 +15,6 @@ ModelKey, ModelSpec, ) -from vidxp.ports import ResourceLimitError from vidxp.settings import VidXPSettings @@ -94,33 +93,18 @@ def __init__( *, indexing_slots: int, inference_slots: int, - minimum_available_memory_mb: int, ) -> None: self._indexing = BoundedSemaphore(indexing_slots) self._inference = BoundedSemaphore(inference_slots) - self._minimum_available_memory_mb = minimum_available_memory_mb - - def _check_memory(self) -> None: - try: - import psutil - except ModuleNotFoundError: - return - available = int(psutil.virtual_memory().available / (1024 * 1024)) - if available < self._minimum_available_memory_mb: - raise ResourceLimitError( - "The configured host-memory admission floor is not available." - ) @contextmanager def indexing(self) -> Iterator[None]: with self._indexing: - self._check_memory() yield @contextmanager def inference(self) -> Iterator[None]: with self._inference: - self._check_memory() yield @@ -139,9 +123,6 @@ def __init__( self.scheduler = ResourceScheduler( indexing_slots=settings.max_concurrent_indexing, inference_slots=settings.max_concurrent_inference, - minimum_available_memory_mb=( - settings.minimum_available_memory_mb - ), ) self._resources: OrderedDict[ModelKey, Any] = OrderedDict() self._resolved_models: dict[str, dict[str, Any]] = {} @@ -311,9 +292,6 @@ def describe(self) -> dict[str, Any]: self.settings.max_concurrent_inference ), "cpu_thread_budget": self.settings.cpu_thread_budget, - "minimum_available_memory_mb": ( - self.settings.minimum_available_memory_mb - ), }, "resolved_models": dict(self._resolved_models), "compute_precision": dict(self._compute_precision), diff --git a/src/vidxp/settings.py b/src/vidxp/settings.py index 84a3e20..22a838b 100644 --- a/src/vidxp/settings.py +++ b/src/vidxp/settings.py @@ -61,7 +61,6 @@ class VidXPSettings(BaseSettings): gt=0, le=256, ) - minimum_available_memory_mb: int = Field(default=1024, ge=0) max_local_import_bytes: int = Field( default=50 * 1024 * 1024 * 1024, gt=0, @@ -599,7 +598,6 @@ class LocalExecutionSettings(BaseModel): max_concurrent_inference: int workflow_poll_interval_seconds: float cpu_thread_budget: int - minimum_available_memory_mb: int max_local_import_bytes: int max_snippet_duration_seconds: float trusted_local_import_roots: tuple[Path, ...] diff --git a/tests/test_api.py b/tests/test_api.py index 14f3193..c8ffaa3 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -100,7 +100,6 @@ def context( settings = VidXPSettings( repository_root=root, runtime_backend="cpu", - minimum_available_memory_mb=0, http_auth_mode=auth, http_static_bearer_token=TOKEN if auth == HttpAuthMode.static else None, http_oidc_issuer=( @@ -846,7 +845,6 @@ def test_http_composition_does_not_construct_model_runtime(self): settings = VidXPSettings( repository_root=Path(directory), runtime_backend="cpu", - minimum_available_memory_mb=0, ) with patch( "vidxp.composition.ModelRuntime", diff --git a/tests/test_application.py b/tests/test_application.py index e09976e..f3bb2d4 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -77,7 +77,6 @@ def application( settings = VidXPSettings( repository_root=Path(root), runtime_backend="cpu", - minimum_available_memory_mb=0, ) runtime = ModelRuntime(settings) active_backend = backend or Mock() diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 84dc5dd..7bd8af7 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -1,4 +1,5 @@ import unittest +from contextlib import nullcontext from types import SimpleNamespace from pathlib import Path from tempfile import TemporaryDirectory @@ -124,7 +125,6 @@ def test_service_is_composed_lazily_and_cached(self): max_concurrent_indexing=2, max_concurrent_inference=3, cpu_thread_budget=6, - minimum_available_memory_mb=2048, external_capabilities=True, capability_allowlist=("acme:ocr",), ) @@ -187,6 +187,61 @@ def test_multiple_registered_media_preserves_only_valid_selection(self): ) self.assertIsNone(frontend._default_media_id("missing", assets)) + def test_busy_video_layout_keeps_controls_and_preview_stable(self): + service = Mock() + service.open_media_content.return_value = SimpleNamespace( + path=Path("video.mp4") + ) + media_page = SimpleNamespace( + items=( + SimpleNamespace( + media_id=MEDIA_ID, + original_filename="video.mp4", + duration_seconds=27.2, + ), + ), + next_cursor=None, + ) + with ( + patch.object( + frontend, + "_configured_service", + return_value=service, + ), + patch.object(frontend.st, "session_state", {}), + patch.object(frontend.st, "subheader"), + patch.object( + frontend.st, + "selectbox", + return_value=MEDIA_ID, + ) as selectbox, + patch.object( + frontend.st, + "expander", + return_value=nullcontext(), + ), + patch.object(frontend.st, "caption"), + patch.object(frontend.st, "text_input", return_value=""), + patch.object(frontend.st, "button", return_value=False), + patch.object( + frontend.st, + "file_uploader", + return_value=None, + ) as uploader, + patch.object(frontend.st, "video") as video, + ): + uploaded, media_id = frontend._select_video( + True, + MEDIA_ID, + media_page, + ) + + self.assertIsNone(uploaded) + self.assertEqual(media_id, MEDIA_ID) + self.assertTrue(selectbox.call_args.kwargs["disabled"]) + self.assertTrue(uploader.call_args.kwargs["disabled"]) + video.assert_called_once_with("video.mp4", width=560) + def test_local_path_import_uses_the_shared_application_command(self): service = Mock() service.import_media.return_value = SimpleNamespace(media_id=MEDIA_ID) diff --git a/tests/test_local_worker.py b/tests/test_local_worker.py index e631677..9971436 100644 --- a/tests/test_local_worker.py +++ b/tests/test_local_worker.py @@ -134,8 +134,12 @@ def spawn(command, **options): self.assertEqual(worker_settings.http_auth_mode, "none") wait.assert_called_once() if sys.platform == "win32": - self.assertTrue( - options["creationflags"] & subprocess.DETACHED_PROCESS + self.assertEqual( + options["creationflags"], + ( + subprocess.CREATE_NEW_PROCESS_GROUP + | subprocess.CREATE_NO_WINDOW + ), ) else: self.assertTrue(options["start_new_session"]) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index c72a979..dbd816a 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -70,7 +70,6 @@ def context( settings = VidXPSettings( repository_root=root, runtime_backend="cpu", - minimum_available_memory_mb=0, http_auth_mode="static" if static_token is not None else "none", http_static_bearer_token=static_token, http_trusted_hosts=http_trusted_hosts, diff --git a/tests/test_models.py b/tests/test_models.py index 86a1422..2537bd0 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -36,7 +36,6 @@ SERVER_INDEX_RUNTIME_CHECKS, ) from vidxp.model_contracts import ModelArtifactUnavailableError, ModelKey -from vidxp.ports import ResourceLimitError from vidxp.runtime import ModelRuntime, resolve_backends from vidxp.settings import VidXPSettings @@ -89,27 +88,6 @@ def test_model_runtime_reuses_one_provider_instance(self): "bfloat16", ) - def test_scheduler_rejects_work_below_memory_admission_floor(self): - with TemporaryDirectory() as directory: - runtime = ModelRuntime( - VidXPSettings( - repository_root=directory, - runtime_backend="cpu", - minimum_available_memory_mb=1024, - ) - ) - fake_psutil = types.SimpleNamespace( - virtual_memory=lambda: types.SimpleNamespace( - available=512 * 1024 * 1024 - ) - ) - with ( - patch.dict(sys.modules, {"psutil": fake_psutil}), - self.assertRaises(ResourceLimitError), - runtime.scheduler.inference(), - ): - pass - def test_unrelated_model_loads_do_not_hold_the_global_cache_lock(self): runtime = self.runtime("unused") barrier = Barrier(2) From 8aaca35549fe0a4f59a381ba5c67abb6f61f8991 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 21:14:58 +0500 Subject: [PATCH 24/46] fix: make model preparation explicit --- INSTALLATION_GUIDE.md | 14 +- README.md | 9 +- changes/explicit-model-readiness.bugfix.md | 3 + docs/architecture/platform.md | 6 + src/vidxp/api_routes/jobs.py | 1 + src/vidxp/application.py | 16 +- src/vidxp/application_models.py | 9 +- src/vidxp/capabilities/actor/definition.py | 2 +- src/vidxp/capabilities/actor/models.py | 29 +++- src/vidxp/capabilities/dialogue/definition.py | 4 +- src/vidxp/capabilities/dialogue/indexing.py | 11 +- src/vidxp/capabilities/dialogue/models.py | 40 ++++- src/vidxp/capabilities/registry.py | 41 +++++ src/vidxp/capabilities/scene/definition.py | 2 +- src/vidxp/capabilities/scene/models.py | 19 ++- src/vidxp/cli.py | 4 +- src/vidxp/cli_commands/index.py | 4 +- src/vidxp/cli_commands/runtime.py | 32 +++- src/vidxp/cli_support.py | 16 +- src/vidxp/composition.py | 1 + src/vidxp/control_plane.py | 37 ++++- src/vidxp/core/runner.py | 5 +- src/vidxp/frontend.py | 133 +++++++++++++++- src/vidxp/mcp.py | 66 +++++++- src/vidxp/model_contracts.py | 24 +++ src/vidxp/ports.py | 16 +- src/vidxp/runtime.py | 147 +++++++++++++++--- tests/test_api.py | 31 ++++ tests/test_cli.py | 27 ++++ tests/test_frontend.py | 8 +- tests/test_indexing.py | 40 +++++ tests/test_mcp.py | 48 +++++- tests/test_models.py | 82 +++++++++- 33 files changed, 854 insertions(+), 73 deletions(-) create mode 100644 changes/explicit-model-readiness.bugfix.md diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md index be9cccc..91ba833 100644 --- a/INSTALLATION_GUIDE.md +++ b/INSTALLATION_GUIDE.md @@ -98,7 +98,7 @@ source .venv/bin/activate uv pip install --torch-backend cpu "vidxp[local-worker,frontend]" ``` -## Verify providers without downloading models +## Verify providers and model readiness without downloading ```bash vidxp doctor @@ -111,8 +111,10 @@ vidxp doctor --modalities scene vidxp doctor --modalities dialogue,actor ``` -The doctor command checks installed distributions and imports each selected -native provider without constructing models or downloading model weights. +The doctor command checks installed distributions, imports each selected native +provider, and reports every pinned model artifact as cached or missing. It never +constructs models or downloads weights. Missing selected models make the command +exit unsuccessfully with the exact `vidxp prepare --modalities ...` remedy. ## Prepare pinned models @@ -127,12 +129,16 @@ Model weights are cached separately from the Python environment: | Actor recognition | OpenCV Zoo SFace | Every model revision and weight checksum is pinned in its capability spec. -Prepare all selected models before indexing: +Explicitly prepare all selected models before indexing: ```bash vidxp prepare ``` +Indexing, API jobs, and MCP tools never download missing models implicitly. +Preparation is a durable job and reports download bytes plus model-loading +stages through CLI output or normal job polling. + Prepare a subset when disk or network capacity is limited: ```bash diff --git a/README.md b/README.md index 63441ff..e76e52c 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,8 @@ Linux and Windows published installs require a staged CPU Torch install; plain Linux Torch build. The exact commands are in the [installation guide](INSTALLATION_GUIDE.md#install-a-published-local-worker). -Confirm the package and prepare the pinned models: +Confirm package and model readiness, then explicitly prepare any missing pinned +models: ```bash vidxp --version @@ -123,6 +124,12 @@ vidxp doctor vidxp prepare ``` +`doctor` does not download anything. It reports missing model artifacts and +exits unsuccessfully until the selected capabilities are prepared. Indexing, +API jobs, and MCP tools do not turn a first request into an implicit model +download; model downloads happen only through `prepare` or the model-preparation +job. + ## Index and search Build an index containing dialogue, scene, and actor information: diff --git a/changes/explicit-model-readiness.bugfix.md b/changes/explicit-model-readiness.bugfix.md new file mode 100644 index 0000000..d8565b8 --- /dev/null +++ b/changes/explicit-model-readiness.bugfix.md @@ -0,0 +1,3 @@ +Report missing model artifacts in doctor and runtime readiness, restrict +downloads to explicit preparation jobs with live progress, and skip dialogue +cleanly for videos without audio. diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md index 75593f2..bbd374d 100644 --- a/docs/architecture/platform.md +++ b/docs/architecture/platform.md @@ -511,6 +511,12 @@ cannot drift. Model construction and caching belong to `ModelRuntime`. Adapters and capability handlers do not each load models independently. +Model downloads are explicit preparation jobs. Doctor/readiness checks inspect +the pinned cache without constructing models or downloading, and ordinary +indexing or query work fails fast with `model_unavailable` when required +artifacts are absent. Preparation publishes byte progress and model-loading +stages through the shared durable job contract. + The scheduler bounds concurrent model work. Local execution does not reject work based on a fixed free-RAM threshold; allocation failures come from the runtime that attempted the actual operation. API request threadpools are not the model diff --git a/src/vidxp/api_routes/jobs.py b/src/vidxp/api_routes/jobs.py index fb4484c..89e7e6b 100644 --- a/src/vidxp/api_routes/jobs.py +++ b/src/vidxp/api_routes/jobs.py @@ -45,6 +45,7 @@ def submit_index( actor: Annotated[Principal, Depends(write_principal)], idempotency_key: HttpIdempotencyKey, ) -> Job: + service.application.require_models(command.modalities) return accepted( response, service.jobs.submit_index( diff --git a/src/vidxp/application.py b/src/vidxp/application.py index 1878bec..fcbd1cf 100644 --- a/src/vidxp/application.py +++ b/src/vidxp/application.py @@ -99,6 +99,7 @@ def __init__( media=media, artifacts=artifacts, index_status=index_status, + model_cache=settings.model_cache, ) self.settings = settings @@ -163,7 +164,10 @@ def runtime_readiness(self) -> RuntimeReadiness: self._index_storage_readiness(), ) dependencies = self.check_dependencies( - DependencyCheckCommand(modalities=self.registry.names()) + DependencyCheckCommand( + modalities=self.registry.names(), + include_models=True, + ) ) return RuntimeReadiness( ready=( @@ -270,6 +274,16 @@ def check_dependencies( on_check_start=on_check_start, on_check_complete=on_check_complete, ), + *( + self.registry.model_checks( + selected, + cache=self.settings.model_cache, + on_check_start=on_check_start, + on_check_complete=on_check_complete, + ) + if command.include_models + else () + ), *( self._media_runtime_checks( on_check_start=on_check_start, diff --git a/src/vidxp/application_models.py b/src/vidxp/application_models.py index 2830e2a..b9121a4 100644 --- a/src/vidxp/application_models.py +++ b/src/vidxp/application_models.py @@ -183,16 +183,17 @@ def __init__( class ModelUnavailableError(ApplicationError): def __init__(self, capability: str) -> None: + modality = capability.split(".", 1)[0] super().__init__( "model_unavailable", ErrorCategory.unavailable, f"Model artifacts for the {capability} capability are not " - "available locally.", + "available locally. Run " + f"`vidxp prepare --modalities {modality}` before retrying.", details={ "capability": capability, "remediation": ( - "Prepare the capability models before retrying or " - "enable model downloads." + f"vidxp prepare --modalities {modality}" ), }, ) @@ -231,6 +232,7 @@ class CapabilityProvenance(ApplicationModel): class DependencyKind(StrEnum): distribution = "distribution" runtime = "runtime" + model = "model" class CapabilityDependencyCheck(ApplicationModel): @@ -814,6 +816,7 @@ class PrepareModelsCommand(ApplicationModel): class DependencyCheckCommand(ApplicationModel): modalities: tuple[str, ...] include_runtime_checks: bool = True + include_models: bool = False class DependencyCheckResult(ApplicationModel): diff --git a/src/vidxp/capabilities/actor/definition.py b/src/vidxp/capabilities/actor/definition.py index f467b5c..f8e1e1c 100644 --- a/src/vidxp/capabilities/actor/definition.py +++ b/src/vidxp/capabilities/actor/definition.py @@ -46,7 +46,7 @@ def prepare_models( "message": "Preparing OpenCV Zoo YuNet and SFace models.", } ) - get_actor_models(context.runtime) + get_actor_models(context.runtime, download=True, progress=progress) return (YUNET_MODEL.filename, SFACE_MODEL.filename) diff --git a/src/vidxp/capabilities/actor/models.py b/src/vidxp/capabilities/actor/models.py index 552e98f..3673fd8 100644 --- a/src/vidxp/capabilities/actor/models.py +++ b/src/vidxp/capabilities/actor/models.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import Any, Callable from vidxp.ports import ModelRuntimePort from vidxp.capabilities.actor.specs import ( @@ -17,14 +17,35 @@ class ActorModels: recognizer: Any -def get_actor_models(runtime: ModelRuntimePort) -> ActorModels: +def get_actor_models( + runtime: ModelRuntimePort, + *, + download: bool = False, + progress: Callable[[dict[str, Any]], None] | None = None, +) -> ActorModels: key = actor_model_key(runtime.device_for("actor")) def load() -> ActorModels: import cv2 - detector_path = runtime.resolve_artifact(YUNET_MODEL) - recognizer_path = runtime.resolve_artifact(SFACE_MODEL) + detector_path = runtime.resolve_artifact( + YUNET_MODEL, + download=download, + progress=progress, + ) + recognizer_path = runtime.resolve_artifact( + SFACE_MODEL, + download=download, + progress=progress, + ) + if progress is not None: + progress( + { + "state": "preparing", + "stage": "loading_model", + "message": "Loading OpenCV Zoo YuNet and SFace models.", + } + ) models = ActorModels( detector=cv2.FaceDetectorYN.create( str(detector_path), diff --git a/src/vidxp/capabilities/dialogue/definition.py b/src/vidxp/capabilities/dialogue/definition.py index 2fda3e0..2d6b1c7 100644 --- a/src/vidxp/capabilities/dialogue/definition.py +++ b/src/vidxp/capabilities/dialogue/definition.py @@ -61,14 +61,14 @@ def report(stage: str, message: str) -> None: "dialogue_model", f"Preparing dialogue model: {QWEN3_EMBEDDING_MODEL.model_id}", ) - get_embedder(context.runtime) + get_embedder(context.runtime, download=True, progress=progress) prepared.append(QWEN3_EMBEDDING_MODEL.model_id) report( "transcription_model", "Preparing transcription model: faster-whisper " f"{FASTER_WHISPER_MODEL.model_id}", ) - get_whisper_model(context.runtime) + get_whisper_model(context.runtime, download=True, progress=progress) prepared.append(FASTER_WHISPER_MODEL.model_id) return tuple(prepared) diff --git a/src/vidxp/capabilities/dialogue/indexing.py b/src/vidxp/capabilities/dialogue/indexing.py index 59c9067..f90919a 100644 --- a/src/vidxp/capabilities/dialogue/indexing.py +++ b/src/vidxp/capabilities/dialogue/indexing.py @@ -119,11 +119,20 @@ def transcribe_video( cancellation: CancellationToken, runtime: ModelRuntimePort, progress: ProgressCallback | None, -) -> tuple[list[Mapping[str, Any]], str]: +) -> tuple[list[Mapping[str, Any]], str | None]: + import av from faster_whisper import BatchedInferencePipeline settings = dialogue_config(config) cancellation.raise_if_cancelled() + with av.open(str(input_path)) as container: + if not container.streams.audio: + report_progress( + progress, + "dialogue_skipped", + "No audio stream was found; dialogue indexing was skipped.", + ) + return [], None report_progress( progress, "preparing_transcription_model", diff --git a/src/vidxp/capabilities/dialogue/models.py b/src/vidxp/capabilities/dialogue/models.py index b3a739f..7a671ea 100644 --- a/src/vidxp/capabilities/dialogue/models.py +++ b/src/vidxp/capabilities/dialogue/models.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any +from typing import Any, Callable from vidxp.ports import ModelRuntimePort from vidxp.model_contracts import loaded_compute_precision @@ -13,6 +13,9 @@ def get_embedder( runtime: ModelRuntimePort, + *, + download: bool = False, + progress: Callable[[dict[str, Any]], None] | None = None, ) -> Any: device = runtime.device_for("dialogue.embedding") key = QWEN3_EMBEDDING_MODEL.key(device) @@ -20,7 +23,21 @@ def get_embedder( def load() -> Any: from sentence_transformers import SentenceTransformer - snapshot = runtime.resolve_model(QWEN3_EMBEDDING_MODEL) + snapshot = runtime.resolve_model( + QWEN3_EMBEDDING_MODEL, + download=download, + progress=progress, + ) + if progress is not None: + progress( + { + "state": "preparing", + "stage": "loading_model", + "message": ( + f"Loading {QWEN3_EMBEDDING_MODEL.model_id}." + ), + } + ) model = SentenceTransformer( str(snapshot), device=device, @@ -41,6 +58,9 @@ def load() -> Any: def get_whisper_model( runtime: ModelRuntimePort, + *, + download: bool = False, + progress: Callable[[dict[str, Any]], None] | None = None, ) -> Any: device = runtime.device_for("dialogue.transcription") compute_type = whisper_compute_type(device) @@ -49,7 +69,21 @@ def get_whisper_model( def load() -> Any: from faster_whisper import WhisperModel - snapshot = runtime.resolve_model(FASTER_WHISPER_MODEL) + snapshot = runtime.resolve_model( + FASTER_WHISPER_MODEL, + download=download, + progress=progress, + ) + if progress is not None: + progress( + { + "state": "preparing", + "stage": "loading_model", + "message": ( + f"Loading {FASTER_WHISPER_MODEL.model_id}." + ), + } + ) model = WhisperModel( str(snapshot), device=device.split(":", 1)[0], diff --git a/src/vidxp/capabilities/registry.py b/src/vidxp/capabilities/registry.py index 1e9e433..ef3172f 100644 --- a/src/vidxp/capabilities/registry.py +++ b/src/vidxp/capabilities/registry.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from importlib.metadata import EntryPoint, entry_points +from pathlib import Path from threading import RLock from time import perf_counter from types import MappingProxyType @@ -34,6 +35,7 @@ packaged_requirements, ) from vidxp.model_contracts import ArtifactSpec, ModelSpec +from vidxp.model_contracts import model_artifact_cached ENTRY_POINT_GROUP = "vidxp.capabilities" @@ -138,6 +140,45 @@ def model_specs( ) ) + def model_checks( + self, + names: Iterable[str], + *, + cache: Path, + on_check_start: ( + Callable[[str, DependencyKind, str], None] | None + ) = None, + on_check_complete: ( + Callable[[CapabilityDependencyCheck, float], None] | None + ) = None, + ) -> tuple[CapabilityDependencyCheck, ...]: + selected = self.validate_names(names) + checks = [] + for name in selected: + for spec in self.get(name).model_specs: + if on_check_start is not None: + on_check_start(name, DependencyKind.model, spec.model_id) + started = perf_counter() + cached = model_artifact_cached(cache, spec) + check = CapabilityDependencyCheck( + capability=name, + kind=DependencyKind.model, + name=spec.model_id, + ok=cached, + error=( + None + if cached + else ( + "model artifacts are not prepared; run " + f"vidxp prepare --modalities {name}" + ) + ), + ) + checks.append(check) + if on_check_complete is not None: + on_check_complete(check, perf_counter() - started) + return tuple(checks) + def get(self, name: str) -> CapabilityDefinition: try: return self._plugins[name].definition diff --git a/src/vidxp/capabilities/scene/definition.py b/src/vidxp/capabilities/scene/definition.py index b7388fc..aee444c 100644 --- a/src/vidxp/capabilities/scene/definition.py +++ b/src/vidxp/capabilities/scene/definition.py @@ -35,7 +35,7 @@ def prepare_models( ), } ) - get_scene_model(context.runtime) + get_scene_model(context.runtime, download=True, progress=progress) return (SIGLIP2_MODEL.model_id,) diff --git a/src/vidxp/capabilities/scene/models.py b/src/vidxp/capabilities/scene/models.py index e8a3fb2..50d8da8 100644 --- a/src/vidxp/capabilities/scene/models.py +++ b/src/vidxp/capabilities/scene/models.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import Any, Callable from vidxp.ports import ModelRuntimePort from vidxp.model_contracts import loaded_compute_precision @@ -17,6 +17,9 @@ class SceneModel: def get_scene_model( runtime: ModelRuntimePort, + *, + download: bool = False, + progress: Callable[[dict[str, Any]], None] | None = None, ) -> SceneModel: device = runtime.device_for("scene") key = SIGLIP2_MODEL.key(device) @@ -24,7 +27,19 @@ def get_scene_model( def load() -> SceneModel: from transformers import AutoModel, AutoProcessor - snapshot = runtime.resolve_model(SIGLIP2_MODEL) + snapshot = runtime.resolve_model( + SIGLIP2_MODEL, + download=download, + progress=progress, + ) + if progress is not None: + progress( + { + "state": "preparing", + "stage": "loading_model", + "message": f"Loading {SIGLIP2_MODEL.model_id}.", + } + ) common = { "cache_dir": str(runtime.model_cache), "local_files_only": True, diff --git a/src/vidxp/cli.py b/src/vidxp/cli.py index 54d8e84..62ae874 100644 --- a/src/vidxp/cli.py +++ b/src/vidxp/cli.py @@ -176,7 +176,9 @@ def _emit_error(exc: Exception, *, json_output: bool) -> None: def main() -> None: try: - app(standalone_mode=False) + exit_code = app(standalone_mode=False) + if isinstance(exit_code, int) and exit_code: + raise SystemExit(exit_code) except typer.Exit as exc: raise SystemExit(exc.exit_code) from None except typer.Abort as exc: diff --git a/src/vidxp/cli_commands/index.py b/src/vidxp/cli_commands/index.py index e0d275c..d5f83fd 100644 --- a/src/vidxp/cli_commands/index.py +++ b/src/vidxp/cli_commands/index.py @@ -34,11 +34,13 @@ def create_index( show_progress = ( not state.quiet and state.output_format == OutputFormat.rich ) + selected = tuple(modalities) + state.service.require_models(selected) with IndexProgress(show_progress) as progress: job = state.jobs.submit_index( CreateIndexCommand( media_id=media_id, - modalities=tuple(modalities), + modalities=selected, frame_stride=frame_stride, scene_sample_fps=scene_sample_fps, capability_options=capability_options, diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py index f435b12..eda8815 100644 --- a/src/vidxp/cli_commands/runtime.py +++ b/src/vidxp/cli_commands/runtime.py @@ -58,7 +58,10 @@ def show_check_start( kind: DependencyKind, name: str, ) -> None: - label = f"package {name}" if kind == DependencyKind.distribution else name + label = { + DependencyKind.distribution: f"package {name}", + DependencyKind.model: f"model {name}", + }.get(kind, name) emit_progress( f"Checking [{capability}] {label}...", newline=False, @@ -85,7 +88,10 @@ def show_check_complete( ) result = state.service.check_dependencies( - DependencyCheckCommand(modalities=selected), + DependencyCheckCommand( + modalities=selected, + include_models=True, + ), on_check_start=( show_check_start if output_format == OutputFormat.rich else None ), @@ -102,7 +108,11 @@ def show_check_complete( python_failures = tuple( check for check in result.checks - if not check.ok and check.capability != "media" + if ( + not check.ok + and check.capability != "media" + and check.kind != DependencyKind.model + ) ) if python_failures: selected_capabilities = { @@ -140,11 +150,25 @@ def show_check_complete( + " ".join(external_distributions), fg=typer.colors.YELLOW, ) + missing_models = tuple( + dict.fromkeys( + check.capability + for check in result.checks + if not check.ok and check.kind == DependencyKind.model + ) + ) + if missing_models: + command = "vidxp prepare --modalities " + ",".join(missing_models) + typer.secho( + "REMEDY: Download the selected model artifacts explicitly:", + fg=typer.colors.YELLOW, + ) + typer.secho(command, fg=typer.colors.YELLOW) if not result.ok: raise typer.Exit(1) if output_format == OutputFormat.rich: typer.secho( - "Selected VidXP dependencies are available.", + "Selected VidXP dependencies and model artifacts are available.", fg=typer.colors.GREEN, bold=True, ) diff --git a/src/vidxp/cli_support.py b/src/vidxp/cli_support.py index cf1d7c1..0dc215d 100644 --- a/src/vidxp/cli_support.py +++ b/src/vidxp/cli_support.py @@ -95,8 +95,22 @@ def emit_progress( def emit_job_progress(job: Any) -> None: if job.progress is not None: + message = job.progress.message + if ( + job.progress.stage == "downloading_model" + and job.progress.current is not None + and job.progress.total + ): + gib = 1024**3 + mib = 1024**2 + unit = gib if job.progress.total >= gib else mib + suffix = "GiB" if unit == gib else "MiB" + message += ( + f" {job.progress.current / unit:.1f} of " + f"{job.progress.total / unit:.1f} {suffix}" + ) emit_progress( - job.progress.message, + message, updated_at=job.progress.updated_at, ) diff --git a/src/vidxp/composition.py b/src/vidxp/composition.py index 545a226..ccbd979 100644 --- a/src/vidxp/composition.py +++ b/src/vidxp/composition.py @@ -367,6 +367,7 @@ def create_control_plane_application( store=components.artifact_store, ), index_status=components.snapshots.status, + model_cache=active_settings.model_cache, ) jobs = create_job_service( active_settings, diff --git a/src/vidxp/control_plane.py b/src/vidxp/control_plane.py index d1e2526..991f48c 100644 --- a/src/vidxp/control_plane.py +++ b/src/vidxp/control_plane.py @@ -9,11 +9,13 @@ CapabilityInfo, CapabilitySummary, ComponentReadiness, + DependencyCheckResult, IndexStatus, InvalidRequestError, ListMediaCommand, MediaAsset, MediaPage, + ModelUnavailableError, ResourceNotFoundError, RuntimeReadiness, ) @@ -38,12 +40,14 @@ def __init__( media: MediaService, artifacts: ArtifactQueryService, index_status: Callable[[], dict | None], + model_cache: Path, ) -> None: self.layout = layout self.capabilities = capabilities self.media = media self.artifacts = artifacts self._read_index_status = index_status + self.model_cache = model_cache @application_boundary def import_uploaded_media( @@ -153,11 +157,40 @@ def control_plane_readiness(self) -> tuple[ComponentReadiness, ...]: ) return tuple(components) + def model_readiness( + self, + modalities: tuple[str, ...] | None = None, + ) -> DependencyCheckResult: + selected = ( + self.capabilities.registry.preparable_names() + if modalities is None + else self.capabilities.registry.validate_names(modalities) + ) + checks = self.capabilities.registry.model_checks( + selected, + cache=self.model_cache, + ) + return DependencyCheckResult( + ok=all(check.ok for check in checks), + modalities=selected, + checks=checks, + ) + + def require_models(self, modalities: tuple[str, ...]) -> None: + readiness = self.model_readiness(modalities) + missing = next((check for check in readiness.checks if not check.ok), None) + if missing is not None: + raise ModelUnavailableError(missing.capability) + def runtime_readiness(self) -> RuntimeReadiness: components = self.control_plane_readiness() + models = self.model_readiness() return RuntimeReadiness( - ready=all(component.ready for component in components), + ready=( + all(component.ready for component in components) + and models.ok + ), runtime=None, components=components, - dependencies=None, + dependencies=models, ) diff --git a/src/vidxp/core/runner.py b/src/vidxp/core/runner.py index 81937f3..0531726 100644 --- a/src/vidxp/core/runner.py +++ b/src/vidxp/core/runner.py @@ -487,10 +487,7 @@ def report(event: dict[str, Any]) -> None: { "state": "indexing", "stage": "initializing", - "message": ( - "Preparing the selected indexing modalities. " - "Missing model weights will download before their first use." - ), + "message": "Preparing the selected indexing modalities.", } ) try: diff --git a/src/vidxp/frontend.py b/src/vidxp/frontend.py index 32e150a..d2c93a4 100644 --- a/src/vidxp/frontend.py +++ b/src/vidxp/frontend.py @@ -19,6 +19,7 @@ ImportMediaCommand, JobState, ListMediaCommand, + PrepareModelsCommand, QueryVideoCommand, SearchCommand, ) @@ -61,12 +62,14 @@ def _settings_from_arguments( INDEX_REQUESTED_KEY = "_vidxp_index_requested" INDEX_ERROR_KEY = "_vidxp_index_error" INDEX_JOB_ID_KEY = "_vidxp_index_job_id" +PREPARE_JOB_ID_KEY = "_vidxp_prepare_job_id" SEARCH_RESULT_KEY = "_vidxp_search_result" CANCEL_REQUESTED_KEY = "_vidxp_cancel_requested" MEDIA_ID_KEY = "_vidxp_media_id" UPLOAD_TOKEN_KEY = "_vidxp_upload_token" MEDIA_NOTICE_KEY = "_vidxp_media_notice" INDEX_JOB_QUERY_PARAM = "index_job" +PREPARE_JOB_QUERY_PARAM = "prepare_job" SEARCH_JOB_QUERY_PARAM = "search_job" SEARCH_TYPE_QUERY_PARAM = "search_type" SCENE_SAMPLE_FPS_DEFAULT = 1.0 @@ -99,6 +102,9 @@ def _restore_durable_jobs() -> None: if INDEX_JOB_ID_KEY not in st.session_state: if job_id := st.query_params.get(INDEX_JOB_QUERY_PARAM): st.session_state[INDEX_JOB_ID_KEY] = str(job_id) + if PREPARE_JOB_ID_KEY not in st.session_state: + if job_id := st.query_params.get(PREPARE_JOB_QUERY_PARAM): + st.session_state[PREPARE_JOB_ID_KEY] = str(job_id) if SEARCH_RESULT_KEY not in st.session_state: if job_id := st.query_params.get(SEARCH_JOB_QUERY_PARAM): st.session_state[SEARCH_RESULT_KEY] = { @@ -146,9 +152,17 @@ def _render_progress(event): st.markdown(f"⏳ {event['message']}") current, total = event.get("current"), event.get("total") if current is not None and total: + if event.get("stage") == "downloading_model": + gib = 1024**3 + mib = 1024**2 + unit = gib if total >= gib else mib + suffix = "GiB" if unit == gib else "MiB" + text = f"{current / unit:.1f} of {total / unit:.1f} {suffix}" + else: + text = f"{current:,} of {total:,}" st.progress( min(current / total, 1.0), - text=f"{current:,} of {total:,}", + text=text, ) @@ -182,7 +196,7 @@ def _render_index_status(status, active, media_id, request_error=None): } _render_progress(event) elif not status or status.get("state") == "missing": - st.caption("First indexing may download missing runtime model weights.") + st.caption("Prepare the selected models, then start indexing.") elif status["state"] == "ready": if _is_search_ready(status, media_id): st.success(status.get("message", "The video index is ready.")) @@ -309,6 +323,7 @@ def _run_indexing( media_id = media_ids[0] if len(media_ids) == 1 else None if media_id is None: raise ValueError("Select or import media before indexing.") + service.require_models(modalities) job = _configured_jobs().submit_index( CreateIndexCommand( media_id=media_id, @@ -767,10 +782,51 @@ def run(): ) ) ) + prepare_job_id = st.session_state.get(PREPARE_JOB_ID_KEY) + prepare_error = None + try: + prepare_job = _get_job(jobs, prepare_job_id) + except ApplicationError as exc: + prepare_job = None + prepare_error = exc + if prepare_job_id is not None and prepare_job is None: + if prepare_error is None: + st.session_state.pop(PREPARE_JOB_ID_KEY, None) + _forget_job(PREPARE_JOB_QUERY_PARAM) + prepare_job_id = None + elif ( + prepare_job is not None + and prepare_job.state not in {JobState.queued, JobState.running} + ): + st.session_state.pop(PREPARE_JOB_ID_KEY, None) + _forget_job(PREPARE_JOB_QUERY_PARAM) + prepare_job_id = None + if prepare_job.state == JobState.succeeded: + st.session_state[MEDIA_NOTICE_KEY] = ( + "Selected model artifacts are prepared." + ) + elif prepare_job.error is not None: + prepare_error = ApplicationError( + prepare_job.error.code, + prepare_job.error.category, + prepare_job.error.message, + details=prepare_job.error.details, + retryable=prepare_job.error.retryable, + ) + preparing = ( + prepare_job_id is not None + and ( + prepare_error is not None + or ( + prepare_job is not None + and prepare_job.state in {JobState.queued, JobState.running} + ) + ) + ) if not active: st.session_state.pop(CANCEL_REQUESTED_KEY, None) requested = st.session_state.get(INDEX_REQUESTED_KEY, False) - busy = active or requested + busy = active or requested or preparing status = service.index_status().model_dump(mode="json") media_id = st.session_state.get(MEDIA_ID_KEY) if media_id is None: @@ -822,21 +878,88 @@ def run(): selected_modalities, disabled=busy, ) + model_readiness = ( + service.model_readiness(selected_modalities) + if selected_modalities + else None + ) + missing_models = ( + tuple( + check.name + for check in model_readiness.checks + if not check.ok + ) + if model_readiness is not None + else () + ) if not installed_modalities: st.warning( "No indexing capabilities are installed. " 'Install one, for example: pip install "vidxp[scene]"' ) + if missing_models and not preparing: + st.warning( + "Model artifacts are not prepared: " + + ", ".join(missing_models) + + "." + ) + if st.button( + "Prepare selected models", + disabled=busy, + help="Download and validate these models before indexing.", + ): + preparation = jobs.submit_prepare_models( + PrepareModelsCommand(modalities=selected_modalities) + ) + st.session_state[PREPARE_JOB_ID_KEY] = preparation.job_id + _remember_job( + job_id=preparation.job_id, + query_param=PREPARE_JOB_QUERY_PARAM, + ) + st.rerun() + if preparing: + + @st.fragment(run_every="1s") + def poll_model_preparation(): + try: + latest = _get_job(jobs, prepare_job_id) + except ApplicationError as exc: + st.warning( + "Model preparation status is temporarily unavailable. " + f"Retrying: {exc}" + ) + return + if latest is None: + st.error("The model preparation job is unavailable.") + return + if latest.state not in {JobState.queued, JobState.running}: + st.rerun() + return + if latest.progress is not None: + _render_progress( + latest.progress.model_dump(mode="json") + ) + else: + st.markdown("⏳ Model preparation is queued.") + + poll_model_preparation() + elif prepare_error is not None: + st.error(f"Model preparation failed: {prepare_error}") st.button( "Index video", type="primary", disabled=busy or not selected_modalities + or bool(missing_models) or (uploaded_video is None and media_id is None), help=( "Indexing is already running." - if busy - else "Build or replace the index. First use may download model weights." + if active or requested + else "Model preparation is running." + if preparing + else "Prepare the selected models before indexing." + if missing_models + else "Build or replace the index." ), on_click=_request_indexing, ) diff --git a/src/vidxp/mcp.py b/src/vidxp/mcp.py index b069de9..7da06eb 100644 --- a/src/vidxp/mcp.py +++ b/src/vidxp/mcp.py @@ -39,7 +39,9 @@ MediaId, MediaPage, Principal, + PrepareModelsCommand, QueryVideoCommand, + RuntimeReadiness, SearchCommand, ) from vidxp.authentication import ( @@ -179,7 +181,13 @@ def _application_error(exc: ApplicationError) -> ToolError: } if detail.correlation_id is not None: data["correlation_id"] = detail.correlation_id - for key in ("errors", "required_scope"): + for key in ( + "capability", + "errors", + "install_hint", + "remediation", + "required_scope", + ): if key in detail.details: data[key] = detail.details[key] return ToolError( @@ -321,10 +329,12 @@ def create_mcp_server( title="VidXP", description="Index and search registered video media.", instructions=( - "Discover registered media with list_media. Register and upload " - "new video through the HTTP/tus API, then use its media_id with " - "the durable indexing and query tools. Use list_jobs to recover " - "job IDs across agent sessions." + "Call get_runtime_readiness before indexing. If selected model " + "artifacts are missing, submit prepare_models and poll get_job " + "until it completes. Discover registered media with list_media. " + "Register and upload new video through the HTTP/tus API, then use " + "its media_id with the durable indexing and query tools. Use " + "list_jobs to recover job IDs across agent sessions." ), version=__version__, token_verifier=token_verifier, @@ -359,6 +369,21 @@ async def get_capability(name: Identifier) -> CapabilityInfo: operation=lambda _actor: context.application.get_capability(name), ) + @server.tool( + description=( + "Check runtime components and whether model artifacts are prepared." + ), + annotations=_READ_ONLY, + structured_output=True, + ) + async def get_runtime_readiness() -> RuntimeReadiness: + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=lambda _actor: context.application.runtime_readiness(), + ) + @server.tool( description="List registered video metadata without transferring bytes.", annotations=_READ_ONLY, @@ -416,6 +441,7 @@ async def start_indexing( idempotency_key: IdempotencyKey, ) -> Job: def submit(actor: Principal) -> Job: + context.application.require_models(command.modalities) return context.jobs.submit_index( command, job_id=scoped_job_id( @@ -433,6 +459,36 @@ def submit(actor: Principal) -> Job: operation=submit, ) + @server.tool( + description=( + "Explicitly download and validate selected model artifacts. Poll " + "get_job for byte progress and completion before indexing." + ), + annotations=_SUBMIT, + structured_output=True, + ) + async def prepare_models( + command: PrepareModelsCommand, + idempotency_key: IdempotencyKey, + ) -> Job: + def submit(actor: Principal) -> Job: + return context.jobs.submit_prepare_models( + command, + job_id=scoped_job_id( + principal=actor, + transport="mcp", + operation="prepare-models", + idempotency_key=idempotency_key, + ), + ) + + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.write, + operation=submit, + ) + @server.tool( description="Submit a durable search over the active index.", annotations=_SUBMIT, diff --git a/src/vidxp/model_contracts.py b/src/vidxp/model_contracts.py index b862749..032366b 100644 --- a/src/vidxp/model_contracts.py +++ b/src/vidxp/model_contracts.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from pathlib import Path import re from typing import Any @@ -120,3 +121,26 @@ def identity(self, *, cached: bool | None = None) -> dict[str, Any]: if cached is not None: identity["cached"] = cached return identity + + +def model_artifact_path( + cache: Path, + spec: ModelSpec | ArtifactSpec, +) -> Path: + if isinstance(spec, ModelSpec): + repository = "models--" + spec.model_id.replace("/", "--") + return ( + cache + / repository + / "snapshots" + / spec.revision + / spec.weights_file + ) + return cache / spec.provider / spec.filename + + +def model_artifact_cached( + cache: Path, + spec: ModelSpec | ArtifactSpec, +) -> bool: + return model_artifact_path(cache, spec).is_file() diff --git a/src/vidxp/ports.py b/src/vidxp/ports.py index 1f06861..4365800 100644 --- a/src/vidxp/ports.py +++ b/src/vidxp/ports.py @@ -247,9 +247,21 @@ def get_or_load( loader: Callable[[], Any], ) -> Any: ... - def resolve_model(self, spec: ModelSpec) -> Path: ... + def resolve_model( + self, + spec: ModelSpec, + *, + download: bool = False, + progress: Callable[[dict[str, Any]], None] | None = None, + ) -> Path: ... - def resolve_artifact(self, spec: ArtifactSpec) -> Path: ... + def resolve_artifact( + self, + spec: ArtifactSpec, + *, + download: bool = False, + progress: Callable[[dict[str, Any]], None] | None = None, + ) -> Path: ... def record_compute_precision( self, diff --git a/src/vidxp/runtime.py b/src/vidxp/runtime.py index bd6b847..1609abf 100644 --- a/src/vidxp/runtime.py +++ b/src/vidxp/runtime.py @@ -3,8 +3,10 @@ import platform import hashlib from collections import OrderedDict +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout from contextlib import contextmanager from threading import BoundedSemaphore, Lock, RLock +from time import monotonic from typing import Any, Callable, Iterator from pathlib import Path @@ -159,20 +161,115 @@ def device_for(self, capability: str) -> str: return self.backends.actor_device return self.backends.torch_device - def resolve_model(self, spec: ModelSpec) -> Path: + @staticmethod + def _download_snapshot( + spec: ModelSpec, + *, + cache: Path, + progress: Callable[[dict[str, Any]], None] | None, + ) -> Path: + from huggingface_hub import snapshot_download + from tqdm.auto import tqdm + + state_lock = Lock() + state: dict[str, Any] = { + "current": None, + "total": None, + "message": f"Connecting to download {spec.model_id}.", + } + + class ReportingTqdm(tqdm): + def display(self, msg=None, pos=None) -> None: + return None + + def update(self, n=1): + result = super().update(n) + if self.unit == "B": + with state_lock: + state.update( + { + "current": int(self.n), + "total": ( + int(self.total) + if self.total + else None + ), + "message": f"Downloading {spec.model_id}.", + } + ) + return result + + def download() -> str: + return snapshot_download( + repo_id=spec.model_id, + revision=spec.revision, + cache_dir=str(cache), + local_files_only=False, + tqdm_class=ReportingTqdm, + ) + + reported_at = 0.0 + with ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(download) + while True: + try: + snapshot = future.result(timeout=0.5) + if progress is not None: + with state_lock: + event = dict(state) + progress( + { + "state": "preparing", + "stage": "downloading_model", + **event, + } + ) + break + except FutureTimeout: + now = monotonic() + if progress is None or now - reported_at < 1: + continue + with state_lock: + event = dict(state) + progress( + { + "state": "preparing", + "stage": "downloading_model", + **event, + } + ) + reported_at = now + return Path(snapshot) + + def resolve_model( + self, + spec: ModelSpec, + *, + download: bool = False, + progress: Callable[[dict[str, Any]], None] | None = None, + ) -> Path: if spec not in self._allowed_specs: raise ModelArtifactUnavailableError(spec.capability) from huggingface_hub import snapshot_download try: - snapshot = Path( - snapshot_download( - repo_id=spec.model_id, - revision=spec.revision, - cache_dir=str(self.settings.model_cache), - local_files_only=not self.settings.allow_model_downloads, + try: + snapshot = Path( + snapshot_download( + repo_id=spec.model_id, + revision=spec.revision, + cache_dir=str(self.settings.model_cache), + local_files_only=True, + ) + ) + except Exception: + if not download or not self.settings.allow_model_downloads: + raise ModelArtifactUnavailableError(spec.capability) + snapshot = self._download_snapshot( + spec, + cache=self.settings.model_cache, + progress=progress, ) - ) weights = snapshot / spec.weights_file if not weights.is_file() or _sha256(weights) != spec.weights_sha256: raise ModelArtifactUnavailableError(spec.capability) @@ -184,20 +281,29 @@ def resolve_model(self, spec: ModelSpec) -> Path: self._resolved_models[spec.capability] = spec.identity(cached=True) return snapshot - def resolve_artifact(self, spec: ArtifactSpec) -> Path: + def resolve_artifact( + self, + spec: ArtifactSpec, + *, + download: bool = False, + progress: Callable[[dict[str, Any]], None] | None = None, + ) -> Path: if spec not in self._allowed_specs: raise ModelArtifactUnavailableError(spec.capability) try: destination = self.settings.model_cache / spec.provider path = destination / spec.filename - if not self.settings.allow_model_downloads: - if ( - not path.is_file() - or _sha256(path) != spec.sha256 - ): + if not path.is_file() or _sha256(path) != spec.sha256: + if not download or not self.settings.allow_model_downloads: raise ModelArtifactUnavailableError(spec.capability) - resolved = path - else: + if progress is not None: + progress( + { + "state": "preparing", + "stage": "downloading_model", + "message": f"Downloading {spec.model_id}.", + } + ) import pooch resolved = Path( @@ -209,11 +315,10 @@ def resolve_artifact(self, spec: ArtifactSpec) -> Path: progressbar=False, ) ) - if ( - not resolved.is_file() - or _sha256(resolved) != spec.sha256 - ): - raise ModelArtifactUnavailableError(spec.capability) + else: + resolved = path + if not resolved.is_file() or _sha256(resolved) != spec.sha256: + raise ModelArtifactUnavailableError(spec.capability) except ModelArtifactUnavailableError: raise except Exception as exc: diff --git a/tests/test_api.py b/tests/test_api.py index c8ffaa3..f9917fe 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -364,6 +364,7 @@ def test_job_submission_is_thin_idempotent_delegation(self): self.assertEqual(command.media_id, MEDIA_ID) self.assertEqual(command.modalities, ("scene",)) self.assertEqual(command.scene_sample_fps, 0.5) + context.application.require_models.assert_called_once_with(("scene",)) expected_job_id = scoped_job_id( context, context.authenticator.authenticate(None), @@ -394,6 +395,36 @@ def test_job_submission_is_thin_idempotent_delegation(self): ), ) + def test_missing_models_fail_before_job_submission(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.application.require_models.side_effect = ApplicationError( + "model_unavailable", + ErrorCategory.unavailable, + "Run vidxp prepare --modalities scene.", + details={ + "capability": "scene", + "remediation": "vidxp prepare --modalities scene", + }, + ) + with TestClient(create_app(context=context)) as client: + response = client.post( + "/api/v1/jobs/index", + headers={"Idempotency-Key": IDEMPOTENCY_KEY}, + json={ + "media_id": MEDIA_ID, + "modalities": ["scene"], + }, + ) + + self.assertEqual(response.status_code, 503) + self.assertEqual(response.json()["error"]["code"], "model_unavailable") + self.assertEqual( + response.json()["error"]["details"]["remediation"], + "vidxp prepare --modalities scene", + ) + context.jobs.submit_index.assert_not_called() + def test_job_submission_requires_an_idempotency_key(self): with TemporaryDirectory() as directory: context = self.context(Path(directory)) diff --git a/tests/test_cli.py b/tests/test_cli.py index 914479c..0888e39 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -356,6 +356,9 @@ def test_doctor_and_prepare_use_shared_models(self): self.service.check_dependencies.call_args.args[0].modalities, ("scene",), ) + self.assertTrue( + self.service.check_dependencies.call_args.args[0].include_models + ) self.assertEqual( self.jobs.submit_prepare_models.call_args.args[0].modalities, ("scene",), @@ -514,6 +517,30 @@ def test_doctor_prints_install_remedy_for_python_failures(self): result.output, ) + def test_doctor_reports_missing_models_and_prepare_command(self): + self.service.check_dependencies.return_value = DependencyCheckResult( + ok=False, + modalities=("scene",), + checks=( + CapabilityDependencyCheck( + capability="scene", + kind=DependencyKind.model, + name="google/siglip2-base-patch16-224", + ok=False, + error="model artifacts are not prepared", + ), + ), + ) + + result = self.invoke(["doctor", "--modalities", "scene"]) + + self.assertEqual(result.exit_code, 1, result.output) + self.assertIn( + "vidxp prepare --modalities scene", + result.output, + ) + self.assertNotIn("pip install", result.output) + def test_invalid_capability_is_a_cli_parameter_error(self): result = self.invoke( ["doctor", "--modalities", "unknown", "--json"] diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 7bd8af7..025d481 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -312,9 +312,14 @@ def test_scene_detail_control_is_conditional_and_defaults_to_balanced(self): def test_indexing_submits_selected_scene_sample_rate(self): jobs = Mock() jobs.submit_index.return_value = SimpleNamespace(job_id="job-1") + service = Mock() session_state = {frontend.MEDIA_ID_KEY: MEDIA_ID} with ( - patch.object(frontend, "_configured_service", return_value=Mock()), + patch.object( + frontend, + "_configured_service", + return_value=service, + ), patch.object(frontend, "_configured_jobs", return_value=jobs), patch.object(frontend.st, "session_state", session_state), patch.object(frontend.st, "query_params", {}), @@ -329,6 +334,7 @@ def test_indexing_submits_selected_scene_sample_rate(self): command = jobs.submit_index.call_args.args[0] self.assertEqual(command.scene_sample_fps, 2.0) + service.require_models.assert_called_once_with(("scene",)) def test_indexing_omits_scene_sample_rate_without_scene(self): jobs = Mock() diff --git a/tests/test_indexing.py b/tests/test_indexing.py index 3547c46..91766c1 100644 --- a/tests/test_indexing.py +++ b/tests/test_indexing.py @@ -1,4 +1,6 @@ import unittest +from contextlib import nullcontext +import sys from types import SimpleNamespace from unittest.mock import Mock, patch @@ -17,6 +19,7 @@ from vidxp.capabilities.dialogue.indexing import ( build_dialogue_phrases, index_dialogue, + transcribe_video, ) from vidxp.capabilities.scene.indexing import ( encode_scene_batch, @@ -174,6 +177,43 @@ def test_transcript_indexing_batches_without_transcription(self): self.assertEqual([len(batch) for batch in encoder.batches], [2, 1]) self.assertEqual(stats["dialogue_phrases"], 3) + def test_silent_video_skips_dialogue_before_loading_whisper(self): + events = [] + fake_av = SimpleNamespace( + open=Mock( + return_value=nullcontext( + SimpleNamespace( + streams=SimpleNamespace(audio=()), + ) + ) + ) + ) + pipeline = Mock(side_effect=AssertionError("whisper was loaded")) + fake_whisper = SimpleNamespace(BatchedInferencePipeline=pipeline) + + with patch.dict( + sys.modules, + { + "av": fake_av, + "faster_whisper": fake_whisper, + }, + ): + segments, language = transcribe_video( + "silent.mp4", + config=IndexConfig( + video_id="video-1", + enabled_modalities=("dialogue",), + ), + cancellation=CancellationToken(), + runtime=self.runtime(), + progress=events.append, + ) + + self.assertEqual(segments, []) + self.assertIsNone(language) + self.assertEqual(events[-1]["stage"], "dialogue_skipped") + pipeline.assert_not_called() + def test_visual_stream_uses_registered_processor_without_name_switches(self): processor = Mock() processor.batch_size.return_value = 1 diff --git a/tests/test_mcp.py b/tests/test_mcp.py index dbd816a..50d66d2 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -115,10 +115,12 @@ async def test_curated_tools_have_structured_output_schemas(self): [ "list_capabilities", "get_capability", + "get_runtime_readiness", "list_media", "get_media", "get_index_status", "start_indexing", + "prepare_models", "search_moments", "query_video", "list_jobs", @@ -167,6 +169,48 @@ async def test_index_submission_uses_shared_stable_idempotency(self): calls[0].kwargs["job_id"], calls[1].kwargs["job_id"], ) + self.assertEqual( + context.application.require_models.call_args.args[0], + ("scene",), + ) + + async def test_missing_models_fail_before_index_submission(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.application.require_models.side_effect = ApplicationError( + "model_unavailable", + ErrorCategory.unavailable, + "Run vidxp prepare --modalities scene.", + details={ + "capability": "scene", + "remediation": "vidxp prepare --modalities scene", + }, + ) + server = create_mcp_server( + context, + default_principal=Principal( + subject="agent", + scopes=frozenset({"vidxp.write"}), + ), + ) + async with Client(server) as client: + result = await client.call_tool( + "start_indexing", + { + "command": { + "media_id": MEDIA_ID, + "modalities": ["scene"], + }, + "idempotency_key": "agent-request-0002", + }, + ) + + self.assertTrue(result.is_error) + self.assertIn( + '"remediation":"vidxp prepare --modalities scene"', + result.content[0].text, + ) + context.jobs.submit_index.assert_not_called() async def test_query_video_submits_the_shared_durable_command(self): with TemporaryDirectory() as directory: @@ -374,10 +418,12 @@ async def test_stdio_entrypoint_serves_the_same_curated_surface(self): [ "list_capabilities", "get_capability", + "get_runtime_readiness", "list_media", "get_media", "get_index_status", "start_indexing", + "prepare_models", "search_moments", "query_video", "list_jobs", @@ -437,7 +483,7 @@ async def test_streamable_http_works_with_the_official_remote_client(self): server.should_exit = True await serving - self.assertEqual(len(discovered.tools), 12) + self.assertEqual(len(discovered.tools), 14) self.assertEqual(result.structured_content, {"items": []}) async def test_oidc_verifier_projects_the_shared_validated_token(self): diff --git a/tests/test_models.py b/tests/test_models.py index 2537bd0..cf200f3 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -9,6 +9,7 @@ from pathlib import Path from tempfile import TemporaryDirectory from threading import Barrier +from time import sleep from unittest.mock import Mock, call, patch from packaging.requirements import Requirement @@ -35,7 +36,11 @@ LOCAL_INDEX_RUNTIME_CHECKS, SERVER_INDEX_RUNTIME_CHECKS, ) -from vidxp.model_contracts import ModelArtifactUnavailableError, ModelKey +from vidxp.model_contracts import ( + ModelArtifactUnavailableError, + ModelKey, + model_artifact_path, +) from vidxp.runtime import ModelRuntime, resolve_backends from vidxp.settings import VidXPSettings @@ -121,7 +126,7 @@ def test_actor_download_uses_pinned_media_object_not_lfs_pointer(self): retrieve = Mock(return_value=str(downloaded)) fake_pooch = types.SimpleNamespace(retrieve=retrieve) with patch.dict(sys.modules, {"pooch": fake_pooch}): - result = runtime.resolve_artifact(spec) + result = runtime.resolve_artifact(spec, download=True) self.assertEqual(result, downloaded) url = retrieve.call_args.kwargs["url"] @@ -137,6 +142,79 @@ def test_actor_download_uses_pinned_media_object_not_lfs_pointer(self): "yunet", ) + def test_normal_model_resolution_never_downloads_implicitly(self): + with TemporaryDirectory() as directory: + spec = replace(YUNET_MODEL, filename="missing.onnx") + runtime = self.runtime(directory, allowed_specs=(spec,)) + retrieve = Mock(side_effect=AssertionError("downloaded")) + with ( + patch.dict( + sys.modules, + {"pooch": types.SimpleNamespace(retrieve=retrieve)}, + ), + self.assertRaises(ModelArtifactUnavailableError), + ): + runtime.resolve_artifact(spec) + + retrieve.assert_not_called() + + def test_model_readiness_checks_the_pinned_cache_without_loading(self): + with TemporaryDirectory() as directory: + cache = Path(directory) / "models" + path = model_artifact_path(cache, YUNET_MODEL) + path.parent.mkdir(parents=True) + path.write_bytes(b"present") + checks = create_capability_registry().model_checks( + ("actor",), + cache=cache, + ) + + self.assertEqual( + [(check.name, check.ok) for check in checks], + [ + (YUNET_MODEL.model_id, True), + (SFACE_MODEL.model_id, False), + ], + ) + + def test_explicit_snapshot_download_reports_bytes(self): + with TemporaryDirectory() as directory: + snapshot = Path(directory) / "snapshot" + events = [] + + def download(**options): + progress = options["tqdm_class"]( + desc="Downloading bytes", + total=1024, + unit="B", + ) + progress.update(512) + sleep(0.6) + progress.update(512) + sleep(0.6) + progress.close() + return str(snapshot) + + with patch( + "huggingface_hub.snapshot_download", + side_effect=download, + ): + resolved = ModelRuntime._download_snapshot( + FASTER_WHISPER_MODEL, + cache=Path(directory), + progress=events.append, + ) + + self.assertEqual(resolved, snapshot) + self.assertTrue( + any( + event["stage"] == "downloading_model" + and event["current"] == 1024 + and event["total"] == 1024 + for event in events + ) + ) + def test_runtime_rejects_specs_not_declared_by_enabled_capabilities(self): with TemporaryDirectory() as directory: runtime = self.runtime(directory) From 61c5e2ce3d91ec74948e3ab0cc1bc002e6da1247 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 22:17:36 +0500 Subject: [PATCH 25/46] fix: align local storage and model readiness --- INSTALLATION_GUIDE.md | 43 +++++++- README.md | 62 ++++++++--- changes/explicit-model-readiness.bugfix.md | 4 + changes/standard-local-storage.bugfix.md | 5 + desktop/src-tauri/src/lib.rs | 6 +- docs/CONTRIBUTING.md | 14 +-- docs/architecture/platform.md | 36 +++++-- docs/benchmarking/core_contract.md | 6 +- docs/desktop.md | 19 ++++ src/vidxp/api_cli.py | 18 +++- src/vidxp/app_paths.py | 43 ++++++++ src/vidxp/application_models.py | 1 + src/vidxp/capabilities/actor/specs.py | 2 + src/vidxp/capabilities/dialogue/specs.py | 2 + src/vidxp/capabilities/registry.py | 1 + src/vidxp/capabilities/scene/specs.py | 1 + src/vidxp/cli.py | 12 +++ src/vidxp/cli_commands/runtime.py | 118 +++++++++++++++++++-- src/vidxp/composition.py | 27 ++++- src/vidxp/entrypoint.py | 1 + src/vidxp/frontend.py | 95 ++++++++++++++--- src/vidxp/mcp_cli.py | 7 ++ src/vidxp/model_contracts.py | 8 ++ src/vidxp/repositories.py | 53 ++++++--- src/vidxp/runtime.py | 10 +- src/vidxp/settings.py | 34 +++++- tests/test_api.py | 9 ++ tests/test_cli.py | 90 +++++++++++++++- tests/test_frontend.py | 4 + tests/test_models.py | 23 +++- tests/test_repositories.py | 53 ++++++++- tests/test_settings.py | 89 +++++++++++++++- 32 files changed, 801 insertions(+), 95 deletions(-) create mode 100644 changes/standard-local-storage.bugfix.md create mode 100644 src/vidxp/app_paths.py diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md index 91ba833..7107c05 100644 --- a/INSTALLATION_GUIDE.md +++ b/INSTALLATION_GUIDE.md @@ -98,6 +98,37 @@ source .venv/bin/activate uv pip install --torch-backend cpu "vidxp[local-worker,frontend]" ``` +## Local data location + +Local installs do not use the directory where `vidxp` was launched as their +storage root. The CLI, browser UI, local MCP process, and locally launched +server use the operating system's per-user VidXP data directory: + +| Platform | Default data root | +|---|---| +| Windows | `%LOCALAPPDATA%\VidXP` | +| macOS | `~/Library/Application Support/VidXP` | +| Linux | `${XDG_DATA_HOME:-~/.local/share}/VidXP` | + +The default repository is under `repositories/default/` and prepared models are +under `models/` within that root. Named-repository configuration stays in the +operating system's standard user configuration directory, such as +`%APPDATA%\VidXP\repositories.json` on Windows. None of these paths are inside +the package environment or source checkout unless explicitly requested. + +Use the global option when invoking the `vidxp` command: + +```bash +vidxp --data-dir /path/to/vidxp-data doctor +vidxp --data-dir /path/to/vidxp-data ui +``` + +Set `VIDXP_DATA_DIR` instead when every local entry point, including +`vidxp-api` or `vidxp-mcp`, should use the same alternate root. +`VIDXP_INDEX_DIR` and `VIDXP_MODEL_CACHE` remain advanced per-location +overrides. Docker and Compose are separate deployment profiles and use their +explicitly configured volumes. + ## Verify providers and model readiness without downloading ```bash @@ -137,17 +168,23 @@ vidxp prepare Indexing, API jobs, and MCP tools never download missing models implicitly. Preparation is a durable job and reports download bytes plus model-loading -stages through CLI output or normal job polling. +stages through CLI output or normal job polling. The CLI and UI show every +missing model's pinned size, the maximum additional cache space, and the cache +location before requiring confirmation. Non-interactive CLI preparation +requires `--yes`. Prepare a subset when disk or network capacity is limited: ```bash vidxp prepare --modalities scene vidxp prepare --modalities dialogue,actor +vidxp prepare --modalities scene --yes # non-interactive confirmation ``` -Set `VIDXP_MODEL_CACHE` to choose the cache directory. Set -`VIDXP_ALLOW_MODEL_DOWNLOADS=false` for an offline worker after preparation. +Prepared models normally use the `models/` directory beneath the VidXP data +root described above. Set `VIDXP_MODEL_CACHE` only to override that one +location. Set `VIDXP_ALLOW_MODEL_DOWNLOADS=false` for an offline worker after +preparation. ## First indexing run diff --git a/README.md b/README.md index e76e52c..ec8a882 100644 --- a/README.md +++ b/README.md @@ -125,10 +125,17 @@ vidxp prepare ``` `doctor` does not download anything. It reports missing model artifacts and -exits unsuccessfully until the selected capabilities are prepared. Indexing, -API jobs, and MCP tools do not turn a first request into an implicit model -download; model downloads happen only through `prepare` or the model-preparation -job. +their pinned sizes, then exits unsuccessfully until the selected capabilities +are prepared. `prepare` shows the missing models, maximum additional +download/cache space, and cache location before requiring confirmation +(`--yes` for non-interactive use). Indexing, API jobs, and MCP tools do not turn +a first request into an implicit model download; model downloads happen only +through `prepare` or the model-preparation job. + +Local commands do not store data in the directory where they were launched. +The CLI, browser UI, local MCP process, and locally launched server use the +operating system's per-user VidXP data directory. See +[Models and local data](#models-and-local-data) for the layout and overrides. ## Index and search @@ -381,17 +388,42 @@ VidXP is an evolving beta. We'd love to hear your feedback and where you'd like | Actor detection | OpenCV Zoo YuNet | | Actor recognition | OpenCV Zoo SFace | -VidXP maintains the standard local CLI/UI repository in `chroma_data/`. Each -local import is streamed into managed storage, validated with `ffprobe`, and -published through the repository catalog before it can be indexed. Each successful -indexing run creates an immutable generation and atomically publishes a multi-media -snapshot. Actor overlay videos are immutable cataloged artifacts. Re-indexing -replaces only that media item's active generation; removing or clearing media -publishes a new snapshot without deleting retained generations or media. Failed -and cancelled runs do not replace the active snapshot. Model caches normally live -outside this directory and outside the virtual environment. Provider revisions -and weight checksums are pinned in capability specs and recorded in each generation -manifest. +Local installs use the operating system's per-user VidXP data directory, +independent of the shell's current directory and the Python virtual environment: + +```text +VidXP/ + repositories/ + default/ + models/ +``` + +On Windows the default root is `%LOCALAPPDATA%\VidXP`; on macOS it is +`~/Library/Application Support/VidXP`; and on Linux it is +`${XDG_DATA_HOME:-~/.local/share}/VidXP`. Named-repository configuration is a +small separate file in the operating system's standard user configuration +directory (for example, `%APPDATA%\VidXP\repositories.json` on Windows). + +Use the global `--data-dir` option for a `vidxp` invocation, or set +`VIDXP_DATA_DIR` for the CLI, browser UI, local MCP, and locally launched server: + +```bash +vidxp --data-dir /path/to/vidxp-data ui +``` + +`VIDXP_INDEX_DIR` and `VIDXP_MODEL_CACHE` remain advanced overrides for those +individual locations. Docker and Compose use their explicitly configured +volumes instead of the host's per-user directory. + +Each local import is streamed into managed repository storage, validated with +`ffprobe`, and published through the repository catalog before it can be +indexed. Each successful indexing run creates an immutable generation and +atomically publishes a multi-media snapshot. Actor overlay videos are immutable +cataloged artifacts. Re-indexing replaces only that media item's active +generation; removing or clearing media publishes a new snapshot without +deleting retained generations or media. Failed and cancelled runs do not +replace the active snapshot. Provider revisions and weight checksums are pinned +in capability specs and recorded in each generation manifest. ## Documentation and project links diff --git a/changes/explicit-model-readiness.bugfix.md b/changes/explicit-model-readiness.bugfix.md index d8565b8..d05e85e 100644 --- a/changes/explicit-model-readiness.bugfix.md +++ b/changes/explicit-model-readiness.bugfix.md @@ -1,3 +1,7 @@ Report missing model artifacts in doctor and runtime readiness, restrict downloads to explicit preparation jobs with live progress, and skip dialogue cleanly for videos without audio. + +Disclose pinned model download/cache sizes and require UI or CLI confirmation +before preparation. Use bounded HTTP downloads instead of Xet transfers that +can remain parked at zero bytes. diff --git a/changes/standard-local-storage.bugfix.md b/changes/standard-local-storage.bugfix.md new file mode 100644 index 0000000..7dbfdac --- /dev/null +++ b/changes/standard-local-storage.bugfix.md @@ -0,0 +1,5 @@ +Store the default local repository and prepared models beneath the operating +system's per-user VidXP data directory instead of the shell's current directory +across CLI, browser UI, local MCP/server processes, and desktop operation. +Stop the owned local worker when those long-running local interfaces shut down +so closed UI/API/MCP processes do not leave repository workers behind. diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index b0ed451..18d1959 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -146,7 +146,7 @@ fn desktop_paths(app: &AppHandle) -> Result { runtimes: data.join("runtimes"), python: data.join("python"), active_runtime: data.join("active-runtime.json"), - models: cache.join("models"), + models: data.join("models"), data, cache, }) @@ -284,6 +284,10 @@ fn clean_environment(paths: &DesktopPaths) -> Vec<(String, String)> { }) .collect(); environment.extend([ + ( + "VIDXP_DATA_DIR".into(), + paths.data.to_string_lossy().into_owned(), + ), ( "VIDXP_MODEL_CACHE".into(), paths.models.to_string_lossy().into_owned(), diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 293869a..8805bff 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -15,9 +15,8 @@ Thanks for contributing to VidXP (Video eXPlain). | `src/vidxp/benchmarks/` | Benchmark-specific loaders, prediction adapters, and evaluator calls | | `pyproject.toml` | Package metadata and Python dependencies | | `docs/` | Installation-linked guidance, benchmark research, and contribution notes | -| `chroma_data/` | Local repository with immutable index generations and snapshots (generated; do not commit) | | `benchmark_runs/` | Isolated programmatic and benchmark runs (generated; do not commit) | -| Model caches | Managed by the shared model runtime outside the repository | +| Per-user VidXP data root | Generated local repository and prepared models; outside the checkout by default | The full local CLI/UI index uses up to three collections: `dialogue`, `scene`, and `actor`. Runs containing @@ -42,9 +41,12 @@ vidxp doctor CLI: `vidxp --help` UI: `vidxp ui` -Models default to CPU and download into their libraries' standard caches on -first use. If a model identifier changes, update the setup documentation and -record the exact identifier in benchmark results. +Models default to CPU and are downloaded only by explicit `vidxp prepare` work. +The default repository and model directory are siblings beneath the operating +system's per-user VidXP data root. For deliberately checkout-local development, +pass `--data-dir ./.vidxp` before the command. If a model identifier changes, +update the setup documentation and record the exact identifier in benchmark +results. ## Where to put work @@ -62,7 +64,7 @@ record the exact identifier in benchmark results. - Product direction: the roadmap in the main [README](../README.md). Prefer small, focused pull requests. If you change how embeddings or metadata -are stored, state whether an existing `chroma_data` index must be rebuilt. +are stored, state whether an existing VidXP repository must be rebuilt. ## Before you open a PR diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md index bbd374d..b715298 100644 --- a/docs/architecture/platform.md +++ b/docs/architecture/platform.md @@ -99,6 +99,7 @@ Use one immutable `VidXPSettings` model built with `pydantic-settings`. It owns: +- the per-user application data root - repository root and active local repository selection - trusted local import roots - upload limits and retention @@ -239,7 +240,24 @@ remain outside the Pydantic success schema. ## 8. Repository layout -One `RepositoryLayout` defines all persistent paths: +Native local interfaces share an operating-system per-user application data +root rather than deriving storage from the process working directory: + +```text +VidXP/ + repositories/ + default/ + ... + models/ +``` + +The CLI's global `--data-dir` option and `VIDXP_DATA_DIR` replace this root. +`VIDXP_INDEX_DIR` and `VIDXP_MODEL_CACHE` remain narrower advanced overrides. +Named-repository configuration uses the operating system's standard roaming +user-configuration directory. Container deployments do not inherit these host +defaults; Compose supplies explicit repository and model-cache volumes. + +Within a repository, one `RepositoryLayout` defines all persistent paths: ```text repository/ @@ -259,8 +277,9 @@ repository/ local-workflows/ ``` -Deployment-wide model caches do not live inside repositories unless explicit -isolation is requested. +Model caches do not live inside repositories. For native local operation they +are a sibling under the shared application data root; server deployments mount +their cache explicitly. Remote API/MCP responses never expose these internal paths. @@ -515,7 +534,10 @@ Model downloads are explicit preparation jobs. Doctor/readiness checks inspect the pinned cache without constructing models or downloading, and ordinary indexing or query work fails fast with `model_unavailable` when required artifacts are absent. Preparation publishes byte progress and model-loading -stages through the shared durable job contract. +stages through the shared durable job contract. Pinned snapshot and artifact +byte sizes are part of the model contract. Interactive preparation displays +the missing-model sizes, maximum additional cache space, and cache path before +requiring confirmation. The scheduler bounds concurrent model work. Local execution does not reject work based on a fixed free-RAM threshold; allocation failures come from the runtime @@ -966,9 +988,9 @@ It loses: - hardcoded capability branching - model/storage access -The desktop application uses the same application commands and a local DBOS SQLite -workflow store, but heavy jobs run in a supervised separate worker process. It -chooses a platform app-data repository root, manages client/worker lifecycle, +The desktop application uses the same application commands, per-user data-root +layout, and a local DBOS SQLite workflow store, but heavy jobs run in a +supervised separate worker process. It manages client/worker lifecycle, displays model preparation progress, and detaches from durable work or requests cooperative cancellation during shutdown. Closing the UI never waits on an uncancellable model thread inside the UI process. diff --git a/docs/benchmarking/core_contract.md b/docs/benchmarking/core_contract.md index f2446d0..c405e07 100644 --- a/docs/benchmarking/core_contract.md +++ b/docs/benchmarking/core_contract.md @@ -33,8 +33,10 @@ does not change merely because the same run is relocated to another output or storage directory. Checkpoint filenames are hashes of video IDs, so official IDs cannot accidentally become platform-specific paths. -The CLI and Streamlit interface use `chroma_data/` as their local run. Indexes -created with an older schema must be rebuilt; VidXP does not invent missing end +The CLI and Streamlit interface use `repositories/default/` beneath the +operating system's per-user VidXP data root. This local application repository +is separate from the explicit `benchmark_runs/` output above. Indexes created +with an older schema must be rebuilt; VidXP does not invent missing end timestamps, video IDs, or source IDs. ## Indexing API diff --git a/docs/desktop.md b/docs/desktop.md index 3d0f847..92e4cfd 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -11,6 +11,25 @@ does not contain a second VidXP implementation: - the existing DBOS worker remains the durable execution boundary; and - closing the desktop process stops both Streamlit and the repository worker. +The desktop uses the operating system-provided app-local-data root rather than +the installation directory or current working directory. Its relevant layout +matches other local installs: + +```text +app-local-data/ + repositories/ + default/ + models/ + runtimes/ + python/ + active-runtime.json +``` + +The last three entries are desktop bootstrap state. The desktop's uv download +cache uses the operating system-provided app-cache directory. Docker and +Compose storage remains explicitly volume-backed and does not inherit this +desktop layout. + The first release is an online bootstrap. It bundles `uv`, installs the exact Python and VidXP versions in `desktop/runtime-manifest.json`, and activates a new runtime only after `vidxp doctor` passes. A failed setup never replaces the diff --git a/src/vidxp/api_cli.py b/src/vidxp/api_cli.py index a67c315..4b49c32 100644 --- a/src/vidxp/api_cli.py +++ b/src/vidxp/api_cli.py @@ -2,27 +2,37 @@ import argparse from collections.abc import Sequence +from pathlib import Path -def _arguments(values: Sequence[str] | None = None) -> None: +def _arguments(values: Sequence[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( description=( "Run the VidXP HTTP API and remote MCP server. " "Configure the service with VIDXP_* environment variables." ) ) - parser.parse_args(values) + parser.add_argument( + "--data-dir", + type=Path, + help="Store VidXP models and the default repository here.", + ) + return parser.parse_args(values) def main(arguments: Sequence[str] | None = None) -> None: - _arguments(arguments) + options = _arguments(arguments) import uvicorn from vidxp.api import create_app from vidxp.settings import VidXPSettings - settings = VidXPSettings() + settings = ( + VidXPSettings(data_dir=options.data_dir) + if options.data_dir is not None + else VidXPSettings() + ) settings.validate_http_server() uvicorn.run( create_app(settings), diff --git a/src/vidxp/app_paths.py b/src/vidxp/app_paths.py new file mode 100644 index 0000000..ca12d63 --- /dev/null +++ b/src/vidxp/app_paths.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +from platformdirs import user_config_path, user_data_path + + +APP_NAME = "VidXP" + + +def default_data_directory() -> Path: + configured = os.environ.get("VIDXP_DATA_DIR") + if configured: + return Path(configured).expanduser() + return user_data_path(APP_NAME, appauthor=False) + + +def default_repository_directory(data_directory: Path | None = None) -> Path: + root = data_directory or default_data_directory() + return root / "repositories" / "default" + + +def default_model_directory(data_directory: Path | None = None) -> Path: + root = data_directory or default_data_directory() + return root / "models" + + +def default_config_directory() -> Path: + return user_config_path(APP_NAME, appauthor=False, roaming=True) + + +def available_storage_bytes(directory: Path) -> int | None: + """Return free bytes on the volume that will contain ``directory``.""" + + candidate = directory.expanduser().resolve(strict=False) + while not candidate.exists() and candidate != candidate.parent: + candidate = candidate.parent + try: + return shutil.disk_usage(candidate).free + except OSError: + return None diff --git a/src/vidxp/application_models.py b/src/vidxp/application_models.py index b9121a4..271d206 100644 --- a/src/vidxp/application_models.py +++ b/src/vidxp/application_models.py @@ -242,6 +242,7 @@ class CapabilityDependencyCheck(ApplicationModel): name: str = Field(min_length=1) requirement: str | None = None installed_version: str | None = None + download_size_bytes: int | None = Field(default=None, ge=1) ok: bool error: str | None = None diff --git a/src/vidxp/capabilities/actor/specs.py b/src/vidxp/capabilities/actor/specs.py index a105e14..e42b773 100644 --- a/src/vidxp/capabilities/actor/specs.py +++ b/src/vidxp/capabilities/actor/specs.py @@ -12,6 +12,7 @@ provider="opencv-zoo", model_id="yunet", revision=OPENCV_ZOO_REVISION, + download_size_bytes=229_738, url=f"{_MODEL_ROOT}/face_detection_yunet/" "face_detection_yunet_2026may.onnx", filename="face_detection_yunet_2026may.onnx", @@ -25,6 +26,7 @@ provider="opencv-zoo", model_id="sface", revision=OPENCV_ZOO_REVISION, + download_size_bytes=38_696_353, url=f"{_MODEL_ROOT}/face_recognition_sface/" "face_recognition_sface_2021dec.onnx", filename="face_recognition_sface_2021dec.onnx", diff --git a/src/vidxp/capabilities/dialogue/specs.py b/src/vidxp/capabilities/dialogue/specs.py index ced668e..ac889c8 100644 --- a/src/vidxp/capabilities/dialogue/specs.py +++ b/src/vidxp/capabilities/dialogue/specs.py @@ -6,6 +6,7 @@ provider="sentence-transformers", model_id="Qwen/Qwen3-Embedding-0.6B", revision="97b0c614be4d77ee51c0cef4e5f07c00f9eb65b3", + download_size_bytes=1_207_489_041, weights_file="model.safetensors", weights_sha256=( "0437e45c94563b09e13cb7a64478fc406947a93cb34a7e05870fc8dcd48e23fd" @@ -19,6 +20,7 @@ provider="faster-whisper", model_id="dropbox-dash/faster-whisper-large-v3-turbo", revision="0a363e9161cbc7ed1431c9597a8ceaf0c4f78fcf", + download_size_bytes=1_621_668_947, weights_file="model.bin", weights_sha256=( "e76620f83d5f5b69efd3d87e3dc180c1bd21df9fbebacfd4335e5e1efcc018da" diff --git a/src/vidxp/capabilities/registry.py b/src/vidxp/capabilities/registry.py index ef3172f..b18d9e9 100644 --- a/src/vidxp/capabilities/registry.py +++ b/src/vidxp/capabilities/registry.py @@ -164,6 +164,7 @@ def model_checks( capability=name, kind=DependencyKind.model, name=spec.model_id, + download_size_bytes=spec.download_size_bytes, ok=cached, error=( None diff --git a/src/vidxp/capabilities/scene/specs.py b/src/vidxp/capabilities/scene/specs.py index c9d8a3c..920abf4 100644 --- a/src/vidxp/capabilities/scene/specs.py +++ b/src/vidxp/capabilities/scene/specs.py @@ -6,6 +6,7 @@ provider="transformers", model_id="google/siglip2-base-patch16-224", revision="75de2d55ec2d0b4efc50b3e9ad70dba96a7b2fa2", + download_size_bytes=1_539_458_338, weights_file="model.safetensors", weights_sha256=( "612923381c76ec5a9bed335d1c48827e3f2e506ac31b044b63b2031fadee6a0b" diff --git a/src/vidxp/cli.py b/src/vidxp/cli.py index 62ae874..22e5ac7 100644 --- a/src/vidxp/cli.py +++ b/src/vidxp/cli.py @@ -87,6 +87,17 @@ def app_options( help="Override the selected repository index directory.", ), ] = None, + data_directory: Annotated[ + Path | None, + typer.Option( + "--data-dir", + file_okay=False, + help=( + "Store VidXP models and the default repository beneath this " + "directory." + ), + ), + ] = None, device: Annotated[ str | None, typer.Option( @@ -111,6 +122,7 @@ def app_options( registry_path=config_file, repository_name=repository_name, index_directory=index_directory, + data_directory=data_directory, device=device, ) ctx.call_on_close(local.close) diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py index eda8815..374fa59 100644 --- a/src/vidxp/cli_commands/runtime.py +++ b/src/vidxp/cli_commands/runtime.py @@ -4,6 +4,7 @@ import typer +from vidxp.app_paths import available_storage_bytes from vidxp.application_models import ( ApplicationError, CapabilityDependencyCheck, @@ -23,6 +24,15 @@ state_from_context, ) + +def _format_bytes(size: int) -> str: + gib = 1024**3 + mib = 1024**2 + if size >= gib: + return f"{size / gib:.2f} GiB" + return f"{size / mib:.1f} MiB" + + def doctor( ctx: typer.Context, modalities: Annotated[ @@ -150,14 +160,37 @@ def show_check_complete( + " ".join(external_distributions), fg=typer.colors.YELLOW, ) + missing_model_checks = tuple( + check + for check in result.checks + if not check.ok and check.kind == DependencyKind.model + ) missing_models = tuple( - dict.fromkeys( - check.capability - for check in result.checks - if not check.ok and check.kind == DependencyKind.model - ) + dict.fromkeys(check.capability for check in missing_model_checks) ) if missing_models: + typer.secho("Missing model downloads:", bold=True) + for check in missing_model_checks: + typer.echo( + f" {check.name}: " + f"{_format_bytes(check.download_size_bytes or 0)}" + ) + required_bytes = sum( + check.download_size_bytes or 0 + for check in missing_model_checks + ) + typer.secho( + "Maximum additional download and cache space: " + f"{_format_bytes(required_bytes)}", + bold=True, + ) + typer.echo(f"Model cache: {state.service.model_cache}") + free_bytes = available_storage_bytes(state.service.model_cache) + if free_bytes is not None: + typer.echo( + "Free space at model cache: " + f"{_format_bytes(free_bytes)}" + ) command = "vidxp prepare --modalities " + ",".join(missing_models) typer.secho( "REMEDY: Download the selected model artifacts explicitly:", @@ -208,6 +241,14 @@ def prepare( help="Return after the durable job is queued.", ), ] = False, + yes: Annotated[ + bool, + typer.Option( + "--yes", + "-y", + help="Confirm the displayed model download and cache size.", + ), + ] = False, ) -> None: """Download and cache selected runtime models before indexing.""" @@ -223,6 +264,55 @@ def prepare( else parse_modalities(",".join(modalities), preparable) ) output_format = effective_output_format(state, json_output) + readiness = state.service.model_readiness(selected) + missing = tuple(check for check in readiness.checks if not check.ok) + required_bytes = sum( + check.download_size_bytes or 0 + for check in missing + ) + free_bytes = ( + available_storage_bytes(state.service.model_cache) + if missing + else None + ) + if missing and output_format == OutputFormat.rich: + typer.secho("Models to download:", bold=True) + for check in missing: + typer.echo( + f" {check.name}: " + f"{_format_bytes(check.download_size_bytes or 0)}" + ) + typer.secho( + "Maximum additional download and cache space: " + f"{_format_bytes(required_bytes)}", + bold=True, + ) + typer.echo(f"Model cache: {state.service.model_cache}") + if free_bytes is not None: + typer.echo( + "Free space at model cache: " + f"{_format_bytes(free_bytes)}" + ) + if ( + missing + and free_bytes is not None + and free_bytes < required_bytes + ): + raise typer.BadParameter( + "The model cache does not have enough free space for the " + f"selected downloads ({_format_bytes(required_bytes)} required, " + f"{_format_bytes(free_bytes)} free at " + f"{state.service.model_cache}). Choose another VidXP data " + "location with the global --data-dir option or VIDXP_DATA_DIR." + ) + if missing and not yes: + if output_format == OutputFormat.json: + raise typer.BadParameter( + "Model downloads require explicit confirmation; review " + "`vidxp doctor --modalities ... --json`, then rerun prepare " + "with --yes." + ) + typer.confirm("Download these models?", abort=True) show_progress = not state.quiet and output_format == OutputFormat.rich if show_progress: emit_progress( @@ -298,7 +388,17 @@ def ui( streamlit_arguments.append(f"--server.port={port}") if show_progress: emit_progress("Starting the browser interface...") - frontend.main( - streamlit_arguments, - settings=state.settings, - ) + try: + frontend.main( + streamlit_arguments, + settings=state.settings, + ) + finally: + try: + state.jobs.stop_worker() + except ApplicationError as exc: + typer.secho( + f"WARNING: The local background worker did not stop: {exc}", + fg=typer.colors.YELLOW, + err=True, + ) diff --git a/src/vidxp/composition.py b/src/vidxp/composition.py index ccbd979..f0c514b 100644 --- a/src/vidxp/composition.py +++ b/src/vidxp/composition.py @@ -112,9 +112,15 @@ class ControlPlaneContext: uploads: RemoteUploadService | None = None def close(self) -> None: - self.jobs.close() - if self.catalog is not None: - self.catalog.close() + try: + if self.settings.mode != ApplicationMode.server: + self.jobs.stop_worker() + finally: + try: + self.jobs.close() + finally: + if self.catalog is not None: + self.catalog.close() @dataclass(frozen=True, kw_only=True) @@ -216,11 +222,17 @@ def _create_control_plane_components( ) -def settings_for_repository(repository: RepositoryConfig) -> VidXPSettings: +def settings_for_repository( + repository: RepositoryConfig, + *, + data_directory: str | Path | None = None, +) -> VidXPSettings: values = { "mode": ApplicationMode.local, "repository_root": repository.index_directory, } + if data_directory is not None: + values["data_dir"] = Path(data_directory) if repository.device is not None: values["runtime_backend"] = repository.device return VidXPSettings(**values) @@ -458,6 +470,7 @@ def create_local_application( registry_path: str | Path | None = None, repository_name: str | None = None, index_directory: str | Path | None = None, + data_directory: str | Path | None = None, device: str | None = None, ) -> LocalApplicationContext: try: @@ -465,9 +478,13 @@ def create_local_application( registry_path=registry_path, name=repository_name, index_directory=index_directory, + data_directory=data_directory, device=device, ) - settings = settings_for_repository(repository) + settings = settings_for_repository( + repository, + data_directory=data_directory, + ) except (RepositoryConfigError, ValidationError) as exc: raise ApplicationError( "configuration_invalid", diff --git a/src/vidxp/entrypoint.py b/src/vidxp/entrypoint.py index 72ea4db..9906ca7 100644 --- a/src/vidxp/entrypoint.py +++ b/src/vidxp/entrypoint.py @@ -30,6 +30,7 @@ def startup_command(arguments: list[str]) -> str | None: "-r", "--config", "--index-dir", + "--data-dir", "--device", "--format", } diff --git a/src/vidxp/frontend.py b/src/vidxp/frontend.py index d2c93a4..1fefad0 100644 --- a/src/vidxp/frontend.py +++ b/src/vidxp/frontend.py @@ -9,6 +9,7 @@ import streamlit as st +from vidxp.app_paths import available_storage_bytes from vidxp.application import VidXPApplication from vidxp.application_models import ( ApplicationError, @@ -63,6 +64,7 @@ def _settings_from_arguments( INDEX_ERROR_KEY = "_vidxp_index_error" INDEX_JOB_ID_KEY = "_vidxp_index_job_id" PREPARE_JOB_ID_KEY = "_vidxp_prepare_job_id" +PREPARE_CANCEL_REQUESTED_KEY = "_vidxp_prepare_cancel_requested" SEARCH_RESULT_KEY = "_vidxp_search_result" CANCEL_REQUESTED_KEY = "_vidxp_cancel_requested" MEDIA_ID_KEY = "_vidxp_media_id" @@ -81,6 +83,14 @@ def _settings_from_arguments( } +def _format_bytes(size: int) -> str: + gib = 1024**3 + mib = 1024**2 + if size >= gib: + return f"{size / gib:.2f} GiB" + return f"{size / mib:.1f} MiB" + + def _remember_job( *, job_id: str, @@ -153,11 +163,7 @@ def _render_progress(event): current, total = event.get("current"), event.get("total") if current is not None and total: if event.get("stage") == "downloading_model": - gib = 1024**3 - mib = 1024**2 - unit = gib if total >= gib else mib - suffix = "GiB" if unit == gib else "MiB" - text = f"{current / unit:.1f} of {total / unit:.1f} {suffix}" + text = f"{_format_bytes(current)} of {_format_bytes(total)}" else: text = f"{current:,} of {total:,}" st.progress( @@ -236,6 +242,13 @@ def _request_cancellation(): ) +def _request_prepare_cancellation(): + job_id = st.session_state.get(PREPARE_JOB_ID_KEY) + if job_id is not None: + _configured_jobs().cancel(job_id) + st.session_state[PREPARE_CANCEL_REQUESTED_KEY] = True + + def _available_index_modalities() -> tuple[str, ...]: service = _configured_service() return tuple( @@ -823,6 +836,8 @@ def run(): ) ) ) + if not preparing: + st.session_state.pop(PREPARE_CANCEL_REQUESTED_KEY, None) if not active: st.session_state.pop(CANCEL_REQUESTED_KEY, None) requested = st.session_state.get(INDEX_REQUESTED_KEY, False) @@ -883,15 +898,29 @@ def run(): if selected_modalities else None ) - missing_models = ( + missing_checks = ( tuple( - check.name + check for check in model_readiness.checks if not check.ok ) if model_readiness is not None else () ) + missing_models = tuple(check.name for check in missing_checks) + required_download_bytes = sum( + check.download_size_bytes or 0 + for check in missing_checks + ) + model_cache_free_bytes = ( + available_storage_bytes(service.model_cache) + if missing_checks + else None + ) + insufficient_model_space = ( + model_cache_free_bytes is not None + and model_cache_free_bytes < required_download_bytes + ) if not installed_modalities: st.warning( "No indexing capabilities are installed. " @@ -899,14 +928,43 @@ def run(): ) if missing_models and not preparing: st.warning( - "Model artifacts are not prepared: " - + ", ".join(missing_models) - + "." + "The following model artifacts must be downloaded:" + ) + for check in missing_checks: + st.caption( + f"• {check.name} — " + f"{_format_bytes(check.download_size_bytes or 0)}" + ) + st.caption( + "Maximum additional download and cache space: " + f"{_format_bytes(required_download_bytes)}" + ) + st.caption(f"Model cache: {service.model_cache}") + if model_cache_free_bytes is not None: + st.caption( + "Free space at model cache: " + f"{_format_bytes(model_cache_free_bytes)}" + ) + if insufficient_model_space: + st.error( + "There is not enough free space for these model " + "downloads. Free space at the displayed location or " + "restart VidXP with a different global --data-dir." + ) + confirmed = st.checkbox( + "I want to download these models and use this cache space.", + key=( + "_vidxp_confirm_models_" + + "_".join(selected_modalities) + ), + disabled=insufficient_model_space, ) if st.button( - "Prepare selected models", - disabled=busy, - help="Download and validate these models before indexing.", + "Download models", + type="primary", + disabled=busy + or insufficient_model_space + or not confirmed, ): preparation = jobs.submit_prepare_models( PrepareModelsCommand(modalities=selected_modalities) @@ -918,6 +976,17 @@ def run(): ) st.rerun() if preparing: + st.button( + "Cancel model preparation", + on_click=_request_prepare_cancellation, + disabled=st.session_state.get( + PREPARE_CANCEL_REQUESTED_KEY, + False, + ), + ) + if st.session_state.get(PREPARE_CANCEL_REQUESTED_KEY, False): + st.caption("Cancellation requested.") + if preparing: @st.fragment(run_every="1s") def poll_model_preparation(): diff --git a/src/vidxp/mcp_cli.py b/src/vidxp/mcp_cli.py index 9ddb430..1919f6f 100644 --- a/src/vidxp/mcp_cli.py +++ b/src/vidxp/mcp_cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +from pathlib import Path from typing import Sequence from vidxp.application_models import Principal @@ -18,6 +19,11 @@ def main(arguments: Sequence[str] | None = None) -> None: parser.add_argument("--registry") parser.add_argument("--repository") parser.add_argument("--index-directory") + parser.add_argument( + "--data-dir", + type=Path, + help="Store VidXP models and the default repository here.", + ) parser.add_argument("--device") options = parser.parse_args(arguments) @@ -25,6 +31,7 @@ def main(arguments: Sequence[str] | None = None) -> None: registry_path=options.registry, repository_name=options.repository, index_directory=options.index_directory, + data_directory=options.data_dir, device=options.device, ) context = create_control_plane_application(local.settings) diff --git a/src/vidxp/model_contracts.py b/src/vidxp/model_contracts.py index 032366b..6b6788e 100644 --- a/src/vidxp/model_contracts.py +++ b/src/vidxp/model_contracts.py @@ -39,6 +39,7 @@ class ModelSpec: provider: str model_id: str revision: str + download_size_bytes: int weights_file: str weights_sha256: str license: str @@ -47,6 +48,8 @@ class ModelSpec: def __post_init__(self) -> None: if not _IMMUTABLE_REVISION.fullmatch(self.revision): raise ValueError("Model revisions must be immutable commit hashes.") + if self.download_size_bytes <= 0: + raise ValueError("Model download sizes must be positive.") if not _SHA256.fullmatch(self.weights_sha256): raise ValueError("Model weight checksums must be SHA-256 hashes.") @@ -64,6 +67,7 @@ def identity(self, *, cached: bool | None = None) -> dict[str, Any]: "provider": self.provider, "model": self.model_id, "revision": self.revision, + "download_size_bytes": self.download_size_bytes, "weights": { "file": self.weights_file, "sha256": self.weights_sha256, @@ -82,6 +86,7 @@ class ArtifactSpec: provider: str model_id: str revision: str + download_size_bytes: int url: str filename: str sha256: str @@ -91,6 +96,8 @@ class ArtifactSpec: def __post_init__(self) -> None: if not _IMMUTABLE_REVISION.fullmatch(self.revision): raise ValueError("Artifact revisions must be immutable commit hashes.") + if self.download_size_bytes <= 0: + raise ValueError("Artifact download sizes must be positive.") if not _SHA256.fullmatch(self.sha256): raise ValueError("Artifact checksums must be SHA-256 hashes.") if not self.url.startswith("https://"): @@ -110,6 +117,7 @@ def identity(self, *, cached: bool | None = None) -> dict[str, Any]: "provider": self.provider, "model": self.model_id, "revision": self.revision, + "download_size_bytes": self.download_size_bytes, "artifact": { "file": self.filename, "sha256": self.sha256, diff --git a/src/vidxp/repositories.py b/src/vidxp/repositories.py index b0e4df7..db03dcf 100644 --- a/src/vidxp/repositories.py +++ b/src/vidxp/repositories.py @@ -3,17 +3,21 @@ import json import os import re -import sys from dataclasses import dataclass from pathlib import Path from typing import Any, Mapping from filelock import FileLock +from vidxp.app_paths import ( + default_config_directory, + default_data_directory, + default_repository_directory, +) REPOSITORY_SCHEMA_VERSION = 1 DEFAULT_REPOSITORY_NAME = "default" -DEFAULT_INDEX_DIRECTORY = Path("chroma_data") +DEFAULT_INDEX_DIRECTORY = default_repository_directory() _NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") @@ -25,15 +29,7 @@ def default_config_path() -> Path: configured = os.environ.get("VIDXP_CONFIG_FILE") if configured: return Path(configured).expanduser() - if sys.platform == "win32": - root = Path(os.environ.get("APPDATA", Path.home() / "AppData/Roaming")) - elif sys.platform == "darwin": - root = Path.home() / "Library/Application Support" - else: - root = Path( - os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config") - ) - return root / "vidxp" / "repositories.json" + return default_config_directory() / "repositories.json" def _repository_name(value: str) -> str: @@ -74,8 +70,18 @@ def to_dict(self) -> dict[str, Any]: class RepositoryRegistry: - def __init__(self, path: str | Path | None = None) -> None: + def __init__( + self, + path: str | Path | None = None, + *, + default_index_directory: str | Path | None = None, + ) -> None: self.path = Path(path) if path is not None else default_config_path() + self.default_index_directory = Path( + default_index_directory + if default_index_directory is not None + else default_repository_directory() + ).expanduser() def read(self) -> dict[str, Any]: return self._read_unlocked() @@ -115,7 +121,7 @@ def list(self) -> tuple[RepositoryConfig, ...]: return ( RepositoryConfig( DEFAULT_REPOSITORY_NAME, - DEFAULT_INDEX_DIRECTORY, + self.default_index_directory, configured=False, ), *configured, @@ -134,7 +140,7 @@ def resolve(self, name: str | None = None) -> RepositoryConfig: if selected == DEFAULT_REPOSITORY_NAME: return RepositoryConfig( DEFAULT_REPOSITORY_NAME, - DEFAULT_INDEX_DIRECTORY, + self.default_index_directory, configured=False, ) raise RepositoryConfigError( @@ -193,7 +199,7 @@ def use(self, name: str) -> RepositoryConfig: elif selected == DEFAULT_REPOSITORY_NAME: repository = RepositoryConfig( DEFAULT_REPOSITORY_NAME, - DEFAULT_INDEX_DIRECTORY, + self.default_index_directory, configured=False, ) payload["active_repository"] = None @@ -275,9 +281,20 @@ def resolve_repository( registry_path: str | Path | None = None, name: str | None = None, index_directory: str | Path | None = None, + data_directory: str | Path | None = None, device: str | None = None, ) -> tuple[RepositoryRegistry, RepositoryConfig]: - registry = RepositoryRegistry(registry_path) + active_data_directory = Path( + data_directory + if data_directory is not None + else os.environ.get("VIDXP_DATA_DIR") or default_data_directory() + ).expanduser() + registry = RepositoryRegistry( + registry_path, + default_index_directory=default_repository_directory( + active_data_directory + ), + ) selected_name = name or os.environ.get("VIDXP_REPOSITORY") repository = registry.resolve(selected_name) resolved_index = ( @@ -285,8 +302,12 @@ def resolve_repository( if index_directory is not None else Path(os.environ["VIDXP_INDEX_DIR"]) if os.environ.get("VIDXP_INDEX_DIR") + else default_repository_directory(active_data_directory) + if not repository.configured else repository.index_directory ) + if not repository.configured: + registry.default_index_directory = resolved_index.expanduser() resolved_device = ( device if device is not None diff --git a/src/vidxp/runtime.py b/src/vidxp/runtime.py index 1609abf..4fe70d7 100644 --- a/src/vidxp/runtime.py +++ b/src/vidxp/runtime.py @@ -168,13 +168,17 @@ def _download_snapshot( cache: Path, progress: Callable[[dict[str, Any]], None] | None, ) -> Path: - from huggingface_hub import snapshot_download + from huggingface_hub import constants, snapshot_download from tqdm.auto import tqdm + # Xet can remain parked at zero bytes without surfacing an error. + # The regular HTTP path has bounded read timeouts and reports bytes + # through tqdm, which is required for durable preparation progress. + constants.HF_HUB_DISABLE_XET = True state_lock = Lock() state: dict[str, Any] = { - "current": None, - "total": None, + "current": 0, + "total": spec.download_size_bytes, "message": f"Connecting to download {spec.model_id}.", } diff --git a/src/vidxp/settings.py b/src/vidxp/settings.py index 22a838b..a331a29 100644 --- a/src/vidxp/settings.py +++ b/src/vidxp/settings.py @@ -6,7 +6,6 @@ from pathlib import Path from urllib.parse import urlsplit -from platformdirs import user_cache_path from pydantic import ( BaseModel, ConfigDict, @@ -18,6 +17,11 @@ ) from pydantic_settings import BaseSettings, SettingsConfigDict +from vidxp.app_paths import ( + default_data_directory, + default_model_directory, + default_repository_directory, +) from vidxp.repository_layout import RepositoryLayout @@ -42,10 +46,13 @@ class VidXPSettings(BaseSettings): ) mode: ApplicationMode = ApplicationMode.local - repository_root: Path = Path("chroma_data") + data_dir: Path = Field(default_factory=default_data_directory) + repository_root: Path = Field( + default_factory=default_repository_directory + ) runtime_backend: str = "auto" model_cache: Path = Field( - default_factory=lambda: user_cache_path("vidxp") / "models" + default_factory=default_model_directory ) allow_model_downloads: bool = True max_loaded_models: int = Field(default=3, gt=0, le=16) @@ -173,6 +180,26 @@ class VidXPSettings(BaseSettings): external_capabilities: bool = False capability_allowlist: tuple[str, ...] = () + @model_validator(mode="before") + @classmethod + def _derive_storage_paths(cls, value): + if not isinstance(value, dict): + return value + configured = dict(value) + data_directory = Path( + configured.get("data_dir") or default_data_directory() + ).expanduser() + configured.setdefault("data_dir", data_directory) + configured.setdefault( + "repository_root", + default_repository_directory(data_directory), + ) + configured.setdefault( + "model_cache", + default_model_directory(data_directory), + ) + return configured + @field_validator("slm_base_url", "slm_model", mode="before") @classmethod def _normalize_empty_optional_slm( @@ -589,6 +616,7 @@ class LocalExecutionSettings(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) + data_dir: Path repository_root: Path runtime_backend: str model_cache: Path diff --git a/tests/test_api.py b/tests/test_api.py index f9917fe..f1bed41 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -162,6 +162,15 @@ def context( def auth() -> dict[str, str]: return {"Authorization": f"Bearer {TOKEN}"} + def test_local_http_context_stops_worker_when_closed(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + + context.close() + + context.jobs.stop_worker.assert_called_once_with() + context.jobs.close.assert_called_once_with() + def test_health_and_minimal_readiness_are_public(self): with TemporaryDirectory() as directory: context = self.context( diff --git a/tests/test_cli.py b/tests/test_cli.py index 0888e39..1c73cce 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -61,7 +61,13 @@ def setUp(self): ).list() self.service.index_directory = Path("repo/indexes") self.service.layout.root = Path("repo") + self.service.model_cache = Path("model-cache") self.service.runtime.backends.requested = "cpu" + self.service.model_readiness.return_value = DependencyCheckResult( + ok=True, + modalities=(), + checks=(), + ) self.jobs = Mock() self.registry = Mock(spec=RepositoryRegistry) self.registry.path = Path("repositories.json") @@ -82,8 +88,27 @@ def invoke(self, arguments): repositories=self.registry, repository=self.repository, ), - ): - return self.runner.invoke(cli.app, arguments) + ) as create_local_application: + result = self.runner.invoke(cli.app, arguments) + self.create_local_application = create_local_application + return result + + def test_data_directory_is_forwarded_to_local_composition(self): + result = self.invoke( + [ + "--data-dir", + "custom-data", + "repositories", + "show", + "--json", + ] + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual( + self.create_local_application.call_args.kwargs["data_directory"], + Path("custom-data"), + ) def test_grouped_commands_are_exposed(self): result = self.invoke(["--help"]) @@ -106,10 +131,24 @@ def test_startup_notice_targets_long_interactive_commands(self): startup_command(["media", "import", "video.mp4"]), "media import", ) + self.assertEqual( + startup_command(["--data-dir", "custom-data", "ui"]), + "ui", + ) self.assertIsNone(startup_command(["doctor", "--json"])) self.assertIsNone(startup_command(["--quiet", "prepare"])) self.assertIsNone(startup_command(["repositories", "list"])) + def test_ui_shutdown_stops_its_local_worker(self): + with patch( + "vidxp.frontend.main", + side_effect=SystemExit(0), + ): + result = self.invoke(["ui"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.jobs.stop_worker.assert_called_once_with() + def test_snippet_rejects_an_inverted_time_range_before_submission(self): result = self.invoke( ["artifacts", "snippet", MEDIA_ID, "3", "2"] @@ -386,6 +425,20 @@ def test_doctor_accepts_repeated_modality_options(self): self.assertEqual(command.modalities, ("dialogue", "scene")) def test_prepare_announces_start_and_subscribes_to_job_progress(self): + self.service.model_readiness.return_value = DependencyCheckResult( + ok=False, + modalities=("scene",), + checks=( + CapabilityDependencyCheck( + capability="scene", + kind=DependencyKind.model, + name="google/siglip2-base-patch16-224", + download_size_bytes=1_539_458_338, + ok=False, + error="model artifacts are not prepared", + ), + ), + ) prepared = PrepareModelsResult( prepared=("scene-model",), modalities=("scene",), @@ -409,15 +462,44 @@ def test_prepare_announces_start_and_subscribes_to_job_progress(self): result=PrepareModelsJobResult(result=prepared), ) - result = self.invoke(["prepare", "--modalities", "scene"]) + result = self.invoke( + ["prepare", "--modalities", "scene", "--yes"] + ) self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("1.43 GiB", result.output) self.assertRegex( result.output, r"\[\d{2}:\d{2}:\d{2}\] Starting model preparation for scene\.", ) self.assertTrue(callable(self.jobs.wait.call_args.kwargs["progress"])) + def test_prepare_discloses_size_and_requires_confirmation(self): + self.service.model_readiness.return_value = DependencyCheckResult( + ok=False, + modalities=("scene",), + checks=( + CapabilityDependencyCheck( + capability="scene", + kind=DependencyKind.model, + name="google/siglip2-base-patch16-224", + download_size_bytes=1_539_458_338, + ok=False, + error="model artifacts are not prepared", + ), + ), + ) + + declined = self.invoke( + ["prepare", "--modalities", "scene"], + ) + + self.assertNotEqual(declined.exit_code, 0) + self.assertIn("1.43 GiB", declined.output) + self.assertIn("Model cache: model-cache", declined.output) + self.assertIn("Download these models?", declined.output) + self.jobs.submit_prepare_models.assert_not_called() + def test_doctor_streams_timestamped_runtime_check_progress(self): def check_dependencies( _command, @@ -526,6 +608,7 @@ def test_doctor_reports_missing_models_and_prepare_command(self): capability="scene", kind=DependencyKind.model, name="google/siglip2-base-patch16-224", + download_size_bytes=1_539_458_338, ok=False, error="model artifacts are not prepared", ), @@ -539,6 +622,7 @@ def test_doctor_reports_missing_models_and_prepare_command(self): "vidxp prepare --modalities scene", result.output, ) + self.assertIn("1.43 GiB", result.output) self.assertNotIn("pip install", result.output) def test_invalid_capability_is_a_cli_parameter_error(self): diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 025d481..70a0a58 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -38,6 +38,10 @@ def getvalue(self): class FrontendTests(unittest.TestCase): + def test_model_download_sizes_are_human_readable(self): + self.assertEqual(frontend._format_bytes(1_539_458_338), "1.43 GiB") + self.assertEqual(frontend._format_bytes(38_926_091), "37.1 MiB") + def test_durable_job_ids_restore_from_query_parameters(self): session_state = {} query_params = { diff --git a/tests/test_models.py b/tests/test_models.py index cf200f3..34d5abd 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -170,10 +170,21 @@ def test_model_readiness_checks_the_pinned_cache_without_loading(self): ) self.assertEqual( - [(check.name, check.ok) for check in checks], [ - (YUNET_MODEL.model_id, True), - (SFACE_MODEL.model_id, False), + (check.name, check.download_size_bytes, check.ok) + for check in checks + ], + [ + ( + YUNET_MODEL.model_id, + YUNET_MODEL.download_size_bytes, + True, + ), + ( + SFACE_MODEL.model_id, + SFACE_MODEL.download_size_bytes, + False, + ), ], ) @@ -198,12 +209,18 @@ def download(**options): with patch( "huggingface_hub.snapshot_download", side_effect=download, + ), patch( + "huggingface_hub.constants.HF_HUB_DISABLE_XET", + False, ): resolved = ModelRuntime._download_snapshot( FASTER_WHISPER_MODEL, cache=Path(directory), progress=events.append, ) + self.assertTrue( + __import__("huggingface_hub").constants.HF_HUB_DISABLE_XET + ) self.assertEqual(resolved, snapshot) self.assertTrue( diff --git a/tests/test_repositories.py b/tests/test_repositories.py index cbf80e7..1a24b72 100644 --- a/tests/test_repositories.py +++ b/tests/test_repositories.py @@ -5,6 +5,7 @@ from tempfile import TemporaryDirectory from unittest.mock import patch +from vidxp.app_paths import default_repository_directory from vidxp.repositories import ( DEFAULT_REPOSITORY_NAME, RepositoryConfigError, @@ -23,7 +24,37 @@ def test_missing_registry_exposes_non_persistent_default(self): self.assertEqual(repositories[0].name, DEFAULT_REPOSITORY_NAME) self.assertFalse(repositories[0].configured) - self.assertEqual(selected.index_directory, Path("chroma_data")) + self.assertEqual( + selected.index_directory, + default_repository_directory(), + ) + self.assertTrue(selected.index_directory.is_absolute()) + + def test_data_directory_controls_the_implicit_default_repository(self): + with TemporaryDirectory() as directory: + root = Path(directory) + registry_path = root / "repos.json" + data_directory = root / "application-data" + unrelated_working_directory = root / "working-directory" + unrelated_working_directory.mkdir() + original_working_directory = Path.cwd() + try: + os.chdir(unrelated_working_directory) + with patch.dict( + os.environ, + {"VIDXP_DATA_DIR": str(data_directory)}, + clear=False, + ): + _, selected = resolve_repository( + registry_path=registry_path, + ) + finally: + os.chdir(original_working_directory) + + self.assertEqual( + selected.index_directory, + data_directory / "repositories" / "default", + ) def test_add_use_replace_and_remove_round_trip(self): with TemporaryDirectory() as directory: @@ -109,6 +140,26 @@ def test_explicit_and_environment_overrides_have_stable_precedence(self): self.assertEqual(selected.index_directory, Path("explicit-index")) self.assertEqual(selected.device, "cuda") + def test_explicit_data_directory_precedes_environment_default(self): + with TemporaryDirectory() as directory: + root = Path(directory) + environment_root = root / "environment-data" + explicit_root = root / "explicit-data" + with patch.dict( + os.environ, + {"VIDXP_DATA_DIR": str(environment_root)}, + clear=False, + ): + _, selected = resolve_repository( + registry_path=root / "repos.json", + data_directory=explicit_root, + ) + + self.assertEqual( + selected.index_directory, + explicit_root / "repositories" / "default", + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_settings.py b/tests/test_settings.py index 6daee72..61df8e5 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,11 +1,98 @@ +import os import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch from pydantic import ValidationError -from vidxp.settings import ApplicationMode, VidXPSettings +from vidxp.app_paths import ( + default_data_directory, + default_model_directory, + default_repository_directory, +) +from vidxp.settings import ( + ApplicationMode, + LocalExecutionSettings, + VidXPSettings, +) class SettingsTests(unittest.TestCase): + def test_default_storage_is_under_the_per_user_data_directory(self): + with TemporaryDirectory() as directory: + unrelated_working_directory = Path(directory) + original_working_directory = Path.cwd() + try: + os.chdir(unrelated_working_directory) + settings = VidXPSettings() + finally: + os.chdir(original_working_directory) + + self.assertEqual(settings.data_dir, default_data_directory()) + self.assertEqual( + settings.repository_root, + default_repository_directory(settings.data_dir), + ) + self.assertEqual( + settings.model_cache, + default_model_directory(settings.data_dir), + ) + self.assertTrue(settings.data_dir.is_absolute()) + + def test_data_directory_environment_derives_storage_defaults(self): + with TemporaryDirectory() as directory: + data_directory = Path(directory) / "vidxp-data" + with patch.dict( + os.environ, + {"VIDXP_DATA_DIR": str(data_directory)}, + clear=False, + ): + settings = VidXPSettings() + + self.assertEqual(settings.data_dir, data_directory) + self.assertEqual( + settings.repository_root, + data_directory / "repositories" / "default", + ) + self.assertEqual(settings.model_cache, data_directory / "models") + + def test_explicit_storage_paths_override_derived_defaults(self): + with TemporaryDirectory() as directory: + root = Path(directory) + data_directory = root / "data" + repository_root = root / "repository" + model_cache = root / "model-cache" + with patch.dict( + os.environ, + {"VIDXP_DATA_DIR": str(root / "environment-data")}, + clear=False, + ): + settings = VidXPSettings( + data_dir=data_directory, + repository_root=repository_root, + model_cache=model_cache, + ) + + self.assertEqual(settings.data_dir, data_directory) + self.assertEqual(settings.repository_root, repository_root) + self.assertEqual(settings.model_cache, model_cache) + + def test_local_execution_settings_preserve_the_data_directory(self): + with TemporaryDirectory() as directory: + expected = VidXPSettings(data_dir=Path(directory) / "data") + + reconstructed = LocalExecutionSettings.from_settings( + expected + ).application_settings() + + self.assertEqual(reconstructed.data_dir, expected.data_dir) + self.assertEqual( + reconstructed.repository_root, + expected.repository_root, + ) + self.assertEqual(reconstructed.model_cache, expected.model_cache) + def test_unimplemented_remote_mode_is_rejected_explicitly(self): with self.assertRaisesRegex( ValidationError, From 711f22f181e65a03b875eb4fcbbea51ca2226d4d Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 22:35:07 +0500 Subject: [PATCH 26/46] fix: use capability details in frontend --- changes/frontend-capability-details.bugfix.md | 2 + src/vidxp/frontend.py | 9 ++-- tests/test_frontend.py | 51 +++++++++---------- 3 files changed, 33 insertions(+), 29 deletions(-) create mode 100644 changes/frontend-capability-details.bugfix.md diff --git a/changes/frontend-capability-details.bugfix.md b/changes/frontend-capability-details.bugfix.md new file mode 100644 index 0000000..0b635f8 --- /dev/null +++ b/changes/frontend-capability-details.bugfix.md @@ -0,0 +1,2 @@ +Resolve query-capable modalities from detailed capability metadata so the +browser UI can render search controls after indexing completes. diff --git a/src/vidxp/frontend.py b/src/vidxp/frontend.py index 1fefad0..7ef36d6 100644 --- a/src/vidxp/frontend.py +++ b/src/vidxp/frontend.py @@ -267,7 +267,10 @@ def _available_index_modalities() -> tuple[str, ...]: def _available_query_modalities( configured: tuple[str, ...], ) -> tuple[str, ...]: - def supports_query(capability) -> bool: + service = _configured_service() + + def supports_query(capability_name: str) -> bool: + capability = service.get_capability(capability_name) operations = { operation.name for operation in capability.operations } @@ -278,9 +281,9 @@ def supports_query(capability) -> bool: return tuple( capability.name - for capability in _configured_service().list_capabilities() + for capability in service.list_capabilities() if capability.name in configured - if supports_query(capability) + if supports_query(capability.name) ) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 70a0a58..2aa5f8d 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -20,6 +20,8 @@ ) from vidxp.capabilities.registry import create_capability_registry from vidxp.capability_service import CapabilityService +from vidxp.control_plane import ControlPlaneApplication +from vidxp.repository_layout import RepositoryLayout from vidxp.settings import LocalExecutionSettings, VidXPSettings @@ -68,34 +70,31 @@ def test_durable_job_ids_restore_from_query_parameters(self): }, ) - def test_query_modalities_derive_from_capability_operations(self): - service = Mock() - service.list_capabilities.return_value = ( - SimpleNamespace( - name="visual-plugin", - operations=(SimpleNamespace(name="search"),), - ), - SimpleNamespace( - name="people-plugin", - operations=( - SimpleNamespace(name="clusters"), - SimpleNamespace(name="detections"), + def test_query_modalities_use_real_capability_service_contracts(self): + with TemporaryDirectory() as directory: + root = Path(directory) + service = ControlPlaneApplication( + layout=RepositoryLayout(root=root), + capabilities=CapabilityService( + create_capability_registry() ), - ), - SimpleNamespace(name="index-only", operations=()), - ) - with patch.object( - frontend, - "_configured_service", - return_value=service, - ): - available = frontend._available_query_modalities( - ("visual-plugin", "people-plugin", "index-only"), + media=Mock(), + artifacts=Mock(), + index_status=lambda: None, + model_cache=root / "models", ) + with patch.object( + frontend, + "_configured_service", + return_value=service, + ): + available = frontend._available_query_modalities( + ("dialogue", "scene", "actor"), + ) self.assertEqual( available, - ("visual-plugin", "people-plugin"), + ("dialogue", "scene", "actor"), ) def tearDown(self): @@ -108,9 +107,9 @@ def service(self, root: Path) -> Mock: service.layout.artifacts = root / "artifacts" service.layout.root = root service.registry = create_capability_registry() - service.list_capabilities.return_value = CapabilityService( - service.registry - ).list() + capabilities = CapabilityService(service.registry) + service.list_capabilities.side_effect = capabilities.list + service.get_capability.side_effect = capabilities.get return service def test_service_is_composed_lazily_and_cached(self): From a49ee6f0ac78e1f8d2a6b7e35b9e9dde6b392a6b Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 22:50:51 +0500 Subject: [PATCH 27/46] fix: clarify frontend search behavior --- changes/frontend-search-clarity.bugfix.md | 3 + src/vidxp/frontend.py | 87 +++++++++++++-------- tests/test_frontend.py | 94 +++++++++++++++++++++++ 3 files changed, 154 insertions(+), 30 deletions(-) create mode 100644 changes/frontend-search-clarity.bugfix.md diff --git a/changes/frontend-search-clarity.bugfix.md b/changes/frontend-search-clarity.bugfix.md new file mode 100644 index 0000000..aa9de83 --- /dev/null +++ b/changes/frontend-search-clarity.bugfix.md @@ -0,0 +1,3 @@ +Submit browser searches with Enter, hide internal queue terminology, and +describe scene results as visual-similarity matches rather than precise +first-occurrence answers. diff --git a/src/vidxp/frontend.py b/src/vidxp/frontend.py index 7ef36d6..e244696 100644 --- a/src/vidxp/frontend.py +++ b/src/vidxp/frontend.py @@ -488,7 +488,12 @@ def poll_search_job(): if job.progress is not None: _render_progress(job.progress.model_dump(mode="json")) else: - st.markdown(f"⏳ {label.title()} is queued.") + action = ( + "Starting" + if job.state == JobState.queued + else "Running" + ) + st.markdown(f"⏳ {action} {label}...") return if job.state != JobState.succeeded or job.result is None: _forget_job(SEARCH_JOB_QUERY_PARAM) @@ -608,7 +613,20 @@ def poll_search_job(): return timestamp = result["timestamp"] - st.success(f"Best {search_type} match: {timestamp:.3f} seconds") + result_label = ( + "Closest sampled scene" + if search_type == "scene" + else "Closest supporting evidence" + if search_type == "natural-language" + else f"Closest {search_type} match" + ) + st.success(f"{result_label}: {timestamp:.1f} seconds") + if search_type == "scene": + st.caption( + "Scene search ranks sampled frames by visual similarity. " + "It does not identify the first occurrence and is not reliable " + "for counting people." + ) st.video( str(resource.path), start_time=timestamp, @@ -726,36 +744,45 @@ def _search_controls(ready, uploaded_video, available_modalities): ) st.caption(message) - type_column, query_column = st.columns( - [0.35, 0.65], - gap="small", - vertical_alignment="bottom", - ) - with type_column: - search_type = st.selectbox( - "Search type", - ["natural-language", *available_modalities], - disabled=not ready, + with st.form( + "video_search", + clear_on_submit=False, + enter_to_submit=True, + border=False, + ): + type_column, query_column = st.columns( + [0.35, 0.65], + gap="small", + vertical_alignment="bottom", ) - with query_column: - query = st.text_input( - ( - "Actor cluster ID" - if search_type == "actor" - else "Question" - if search_type == "natural-language" - else "Search query" - ), - placeholder=( - "For example: 1" - if search_type == "actor" - else "For example: What happens after the taxi arrives?" - if search_type == "natural-language" - else "For example: Chef makes pizza and cuts it up." - ), - disabled=not ready, + with type_column: + search_type = st.selectbox( + "Search type", + ["natural-language", *available_modalities], + disabled=not ready, + ) + with query_column: + query = st.text_input( + ( + "Actor cluster ID" + if search_type == "actor" + else "Question" + if search_type == "natural-language" + else "Search query" + ), + placeholder=( + "For example: 1" + if search_type == "actor" + else "For example: What happens after the taxi arrives?" + if search_type == "natural-language" + else "For example: Chef makes pizza and cuts it up." + ), + disabled=not ready, + ) + clicked = st.form_submit_button( + "Search", + disabled=not ready or not query.strip(), ) - clicked = st.button("Search", disabled=not ready or not query.strip()) return clicked, search_type, query diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 2aa5f8d..f104776 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -312,6 +312,47 @@ def test_scene_detail_control_is_conditional_and_defaults_to_balanced(self): self.assertIsNone(selected) selectbox.assert_not_called() + def test_search_controls_use_an_enter_submittable_form(self): + with ( + patch.object( + frontend.st, + "form", + return_value=nullcontext(), + ) as form, + patch.object( + frontend.st, + "columns", + return_value=(nullcontext(), nullcontext()), + ), + patch.object( + frontend.st, + "selectbox", + return_value="scene", + ), + patch.object( + frontend.st, + "text_input", + return_value="three people at the counter", + ), + patch.object( + frontend.st, + "form_submit_button", + return_value=True, + ) as submit, + patch.object(frontend.st, "subheader"), + ): + clicked, search_type, query = frontend._search_controls( + True, + None, + ("scene",), + ) + + self.assertTrue(clicked) + self.assertEqual(search_type, "scene") + self.assertEqual(query, "three people at the counter") + self.assertTrue(form.call_args.kwargs["enter_to_submit"]) + submit.assert_called_once_with("Search", disabled=False) + def test_indexing_submits_selected_scene_sample_rate(self): jobs = Mock() jobs.submit_index.return_value = SimpleNamespace(job_id="job-1") @@ -498,6 +539,59 @@ def test_actor_poll_persists_success_before_stopping(self): ) rerun.assert_called_once_with() + def test_queued_search_is_presented_as_starting(self): + job = Mock(state=JobState.queued, progress=None) + + with patch.object(frontend.st, "markdown") as markdown: + self._render_job( + job, + { + "type": "scene", + "query": "three people at the counter", + "job_id": "job-1", + }, + ) + + markdown.assert_called_once_with("⏳ Starting scene search...") + + def test_scene_result_describes_similarity_and_limitations(self): + service = Mock() + service.open_media_content.return_value = SimpleNamespace( + path=Path("video.mp4"), + ) + with ( + patch.object( + frontend, + "_configured_service", + return_value=service, + ), + patch.object(frontend.st, "success") as success, + patch.object(frontend.st, "caption") as caption, + patch.object(frontend.st, "video") as video, + ): + frontend._render_search_result( + { + "type": "scene", + "query": "three people at the counter", + "timestamp": 24.024, + "media_id": MEDIA_ID, + } + ) + + success.assert_called_once_with( + "Closest sampled scene: 24.0 seconds" + ) + caption.assert_called_once_with( + "Scene search ranks sampled frames by visual similarity. " + "It does not identify the first occurrence and is not reliable " + "for counting people." + ) + video.assert_called_once_with( + "video.mp4", + start_time=24.024, + width="stretch", + ) + def test_actor_poll_persists_unavailable_job_error(self): session_state, rerun = self._render_job(None) From 3b567d677c8bb8b5f6f84a7847159599567eed1e Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 23:22:50 +0500 Subject: [PATCH 28/46] fix: keep frontend search form submittable --- changes/frontend-search-form.bugfix.md | 2 + src/vidxp/frontend.py | 6 +- tests/test_frontend.py | 81 +++++++++++++------------- 3 files changed, 49 insertions(+), 40 deletions(-) create mode 100644 changes/frontend-search-form.bugfix.md diff --git a/changes/frontend-search-form.bugfix.md b/changes/frontend-search-form.bugfix.md new file mode 100644 index 0000000..d1daf04 --- /dev/null +++ b/changes/frontend-search-form.bugfix.md @@ -0,0 +1,2 @@ +Keep the browser search form submit control available before its first +submission and validate empty queries after Streamlit sends the form values. diff --git a/src/vidxp/frontend.py b/src/vidxp/frontend.py index e244696..6029ea4 100644 --- a/src/vidxp/frontend.py +++ b/src/vidxp/frontend.py @@ -778,11 +778,15 @@ def _search_controls(ready, uploaded_video, available_modalities): else "For example: Chef makes pizza and cuts it up." ), disabled=not ready, + key="video_search_query", ) clicked = st.form_submit_button( "Search", - disabled=not ready or not query.strip(), + disabled=not ready, ) + if clicked and not query.strip(): + st.warning("Enter a search query.") + clicked = False return clicked, search_type, query diff --git a/tests/test_frontend.py b/tests/test_frontend.py index f104776..9a0e1a9 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -5,6 +5,8 @@ from tempfile import TemporaryDirectory from unittest.mock import Mock, patch +from streamlit.testing.v1 import AppTest + from vidxp import frontend from vidxp.application_models import ( ApplicationError, @@ -312,46 +314,47 @@ def test_scene_detail_control_is_conditional_and_defaults_to_balanced(self): self.assertIsNone(selected) selectbox.assert_not_called() - def test_search_controls_use_an_enter_submittable_form(self): - with ( - patch.object( - frontend.st, - "form", - return_value=nullcontext(), - ) as form, - patch.object( - frontend.st, - "columns", - return_value=(nullcontext(), nullcontext()), - ), - patch.object( - frontend.st, - "selectbox", - return_value="scene", - ), - patch.object( - frontend.st, - "text_input", - return_value="three people at the counter", - ), - patch.object( - frontend.st, - "form_submit_button", - return_value=True, - ) as submit, - patch.object(frontend.st, "subheader"), - ): - clicked, search_type, query = frontend._search_controls( - True, - None, - ("scene",), - ) + def test_search_form_submits_its_first_query(self): + app = AppTest.from_string( + """ +import streamlit as st +from vidxp.frontend import _search_controls + +clicked, search_type, query = _search_controls( + True, + None, + ("scene",), +) +if clicked: + st.write(f"SUBMITTED:{search_type}:{query}") +""" + ).run() + + self.assertFalse(app.button[0].disabled) + app.selectbox[0].select("scene") + app.text_input[0].input("man in gray pants") + app.button[0].click() + app.run() + + self.assertEqual( + tuple(item.value for item in app.markdown), + ("SUBMITTED:scene:man in gray pants",), + ) + + def test_search_form_rejects_an_empty_query_after_submission(self): + app = AppTest.from_string( + """ +from vidxp.frontend import _search_controls + +clicked, _, _ = _search_controls(True, None, ("scene",)) +""" + ).run() + + app.button[0].click() + app.run() - self.assertTrue(clicked) - self.assertEqual(search_type, "scene") - self.assertEqual(query, "three people at the counter") - self.assertTrue(form.call_args.kwargs["enter_to_submit"]) - submit.assert_called_once_with("Search", disabled=False) + self.assertFalse(app.button[0].disabled) + self.assertEqual(app.warning[0].value, "Enter a search query.") def test_indexing_submits_selected_scene_sample_rate(self): jobs = Mock() From 75c2a5b71f717df6e686db9b6e0792730c2d9ac0 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jul 2026 23:39:30 +0500 Subject: [PATCH 29/46] test: add full-page frontend regressions --- tests/test_frontend.py | 44 ----- tests/test_frontend_app.py | 358 +++++++++++++++++++++++++++++++++++++ 2 files changed, 358 insertions(+), 44 deletions(-) create mode 100644 tests/test_frontend_app.py diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 9a0e1a9..ee426c4 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -5,8 +5,6 @@ from tempfile import TemporaryDirectory from unittest.mock import Mock, patch -from streamlit.testing.v1 import AppTest - from vidxp import frontend from vidxp.application_models import ( ApplicationError, @@ -314,48 +312,6 @@ def test_scene_detail_control_is_conditional_and_defaults_to_balanced(self): self.assertIsNone(selected) selectbox.assert_not_called() - def test_search_form_submits_its_first_query(self): - app = AppTest.from_string( - """ -import streamlit as st -from vidxp.frontend import _search_controls - -clicked, search_type, query = _search_controls( - True, - None, - ("scene",), -) -if clicked: - st.write(f"SUBMITTED:{search_type}:{query}") -""" - ).run() - - self.assertFalse(app.button[0].disabled) - app.selectbox[0].select("scene") - app.text_input[0].input("man in gray pants") - app.button[0].click() - app.run() - - self.assertEqual( - tuple(item.value for item in app.markdown), - ("SUBMITTED:scene:man in gray pants",), - ) - - def test_search_form_rejects_an_empty_query_after_submission(self): - app = AppTest.from_string( - """ -from vidxp.frontend import _search_controls - -clicked, _, _ = _search_controls(True, None, ("scene",)) -""" - ).run() - - app.button[0].click() - app.run() - - self.assertFalse(app.button[0].disabled) - self.assertEqual(app.warning[0].value, "Enter a search query.") - def test_indexing_submits_selected_scene_sample_rate(self): jobs = Mock() jobs.submit_index.return_value = SimpleNamespace(job_id="job-1") diff --git a/tests/test_frontend_app.py b/tests/test_frontend_app.py new file mode 100644 index 0000000..833d2fa --- /dev/null +++ b/tests/test_frontend_app.py @@ -0,0 +1,358 @@ +import unittest +from datetime import datetime, timezone +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace + +from streamlit.testing.v1 import AppTest + +from vidxp.application_models import ( + DependencyCheckResult, + FusedMoment, + FusedSearchResult, + FusionProvenance, + IndexStatus, + IndexStatusSummary, + Job, + JobKind, + JobProgress, + JobQueue, + JobState, + MediaAsset, + MediaPage, + SearchHit, + SearchJobResult, +) +from vidxp.capabilities.registry import create_capability_registry +from vidxp.capability_service import CapabilityService +from vidxp.core.media import MediaState, MediaStream +from vidxp.ports import LocalFileResource + + +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" +SNAPSHOT_ID = "323456781234423481234567890abcde" +INDEX_JOB_ID = "423456781234423481234567890abcde" +SEARCH_JOB_ID = "523456781234423481234567890abcde" + + +def render_frontend(service, jobs): + from unittest.mock import patch + + from vidxp import frontend + + with ( + patch.object( + frontend, + "_configured_service", + return_value=service, + ), + patch.object( + frontend, + "_configured_jobs", + return_value=jobs, + ), + ): + frontend.run() + + +class FrontendApplicationStub: + def __init__(self, root: Path, status: IndexStatus): + self.layout = SimpleNamespace(root=root) + self.model_cache = root / "models" + self._status = status + self._capabilities = CapabilityService( + create_capability_registry() + ) + self._media = MediaAsset( + media_id=MEDIA_ID, + video_id=MEDIA_ID, + original_filename="video.mp4", + sha256="1" * 64, + byte_size=10, + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=27.2, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + state=MediaState.ready, + created_at=datetime.now(timezone.utc), + ) + self._video_path = root / "video.mp4" + self._video_path.write_bytes(b"test video") + + def list_capabilities(self): + return self._capabilities.list() + + def get_capability(self, name): + return self._capabilities.get(name) + + def check_dependencies(self, command): + return DependencyCheckResult( + ok=True, + modalities=command.modalities, + checks=(), + ) + + def model_readiness(self, modalities): + return DependencyCheckResult( + ok=True, + modalities=modalities, + checks=(), + ) + + def index_status(self): + return self._status + + def list_media(self, command): + del command + return MediaPage(items=(self._media,), total=1) + + def open_media_content(self, media_id): + if media_id != MEDIA_ID: + raise AssertionError(f"unexpected media ID: {media_id}") + return LocalFileResource( + path=self._video_path, + filename=self._media.original_filename, + mime_type=self._media.detected_mime_type, + byte_size=self._media.byte_size, + etag=self._media.sha256, + ) + + +class FrontendJobStub: + def __init__(self): + self.jobs = {} + self.submitted_searches = [] + + def get(self, job_id): + return self.jobs.get(job_id) + + def submit_search(self, command): + self.submitted_searches.append(command) + job = Job( + job_id=SEARCH_JOB_ID, + kind=JobKind.search, + state=JobState.queued, + queue=JobQueue.cpu, + ) + self.jobs[job.job_id] = job + return job + + def cancel(self, job_id): + raise AssertionError(f"unexpected cancellation: {job_id}") + + +def ready_status() -> IndexStatus: + return IndexStatus( + schema_version=1, + state="ready", + stage="complete", + message="The video index is ready.", + summary=IndexStatusSummary( + index_schema_version=1, + snapshot_id=SNAPSHOT_ID, + media_count=1, + media_ids=(MEDIA_ID,), + modalities=("dialogue", "scene", "actor"), + ), + ) + + +def scene_search_result() -> FusedSearchResult: + hit = SearchHit( + rank=1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, + start=3.0, + end=4.0, + score=0.8, + raw_distance=0.2, + modality="scene", + source_id="scene:3", + ) + return FusedSearchResult( + query_id="query-1", + query="man in gray pants", + modalities=("scene",), + moments=( + FusedMoment( + rank=1, + score=0.8, + media_id=MEDIA_ID, + start=3.0, + end=4.0, + modalities=("scene",), + hits=(hit,), + ), + ), + fusion=FusionProvenance( + requested_modalities=("scene",), + searched_modalities=("scene",), + ), + ) + + +class FrontendAppTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + + def tearDown(self): + self.temporary_directory.cleanup() + + @staticmethod + def widget(elements, label): + return next(item for item in elements if item.label == label) + + def app(self, service, jobs): + return AppTest.from_function( + render_frontend, + args=(service, jobs), + default_timeout=10, + ) + + def test_ready_page_renders_once_and_submits_first_search(self): + service = FrontendApplicationStub(self.root, ready_status()) + jobs = FrontendJobStub() + app = self.app(service, jobs).run() + + self.assertEqual(tuple(app.exception), ()) + self.assertEqual(len(app.get("video")), 1) + search_form = app.get("form") + self.assertEqual(len(search_form), 1) + self.assertTrue(search_form[0].proto.form.enter_to_submit) + search_type = self.widget(app.selectbox, "Search type") + search_query = app.text_input(key="video_search_query") + search_button = self.widget(app.button, "Search") + self.assertFalse(search_type.disabled) + self.assertFalse(search_query.disabled) + self.assertFalse(search_button.disabled) + + search_type.select("scene") + search_query.input("man in gray pants") + search_button.click() + app.run() + + self.assertEqual(tuple(app.exception), ()) + self.assertEqual(len(jobs.submitted_searches), 1) + self.assertEqual( + jobs.submitted_searches[0].query, + "man in gray pants", + ) + self.assertTrue( + any( + item.value == "⏳ Starting scene search..." + for item in app.markdown + ) + ) + + def test_ready_page_rejects_empty_search_without_disabling_form(self): + service = FrontendApplicationStub(self.root, ready_status()) + jobs = FrontendJobStub() + app = self.app(service, jobs).run() + + search_button = self.widget(app.button, "Search") + search_button.click() + app.run() + + self.assertEqual(tuple(app.exception), ()) + self.assertFalse(self.widget(app.button, "Search").disabled) + self.assertEqual(app.warning[-1].value, "Enter a search query.") + self.assertEqual(jobs.submitted_searches, []) + + def test_running_index_keeps_one_preview_and_disables_mutations(self): + service = FrontendApplicationStub(self.root, ready_status()) + jobs = FrontendJobStub() + jobs.jobs[INDEX_JOB_ID] = Job( + job_id=INDEX_JOB_ID, + kind=JobKind.index, + state=JobState.running, + queue=JobQueue.cpu, + progress=JobProgress( + stage="scene", + message="Indexing sampled scenes.", + current=2, + total=10, + updated_at=datetime.now(timezone.utc), + ), + ) + app = self.app(service, jobs) + app.query_params["index_job"] = INDEX_JOB_ID + app.run() + + self.assertEqual(tuple(app.exception), ()) + self.assertEqual(len(app.get("video")), 1) + self.assertTrue( + self.widget(app.selectbox, "Registered video").disabled + ) + self.assertTrue(app.file_uploader[0].disabled) + self.assertTrue(app.multiselect[0].disabled) + self.assertTrue(self.widget(app.selectbox, "Scene detail").disabled) + self.assertTrue(self.widget(app.selectbox, "Search type").disabled) + self.assertTrue( + app.text_input(key="video_search_query").disabled + ) + self.assertTrue(self.widget(app.button, "Index video").disabled) + self.assertTrue(self.widget(app.button, "Search").disabled) + self.assertFalse( + self.widget(app.button, "Cancel indexing").disabled + ) + self.assertTrue( + any( + item.value == "⏳ Indexing sampled scenes." + for item in app.markdown + ) + ) + + def test_restored_search_job_moves_from_queued_to_typed_result(self): + service = FrontendApplicationStub(self.root, ready_status()) + jobs = FrontendJobStub() + jobs.jobs[SEARCH_JOB_ID] = Job( + job_id=SEARCH_JOB_ID, + kind=JobKind.search, + state=JobState.queued, + queue=JobQueue.cpu, + ) + app = self.app(service, jobs) + app.query_params["search_job"] = SEARCH_JOB_ID + app.query_params["search_type"] = "scene" + app.run() + + self.assertEqual(tuple(app.exception), ()) + self.assertTrue( + any( + item.value == "⏳ Starting scene search..." + for item in app.markdown + ) + ) + + jobs.jobs[SEARCH_JOB_ID] = Job( + job_id=SEARCH_JOB_ID, + kind=JobKind.search, + state=JobState.succeeded, + queue=JobQueue.cpu, + result=SearchJobResult(result=scene_search_result()), + ) + app.run() + + self.assertEqual(tuple(app.exception), ()) + self.assertEqual(len(app.get("video")), 2) + self.assertEqual( + app.success[-1].value, + "Closest sampled scene: 3.0 seconds", + ) + self.assertNotIn("search_job", app.query_params) + self.assertNotIn("search_type", app.query_params) + + +if __name__ == "__main__": + unittest.main() From dae2efdac0bc840f7e6853ce273ea416d6a718ab Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 30 Jul 2026 01:08:09 +0500 Subject: [PATCH 30/46] feat(branding): share app icon across interfaces --- .gitignore | 1 + MANIFEST.in | 1 + desktop/package.json | 4 ++ desktop/scripts/sync-branding.mjs | 10 +++++ desktop/src-tauri/icons/icon.icns | Bin 1373347 -> 1373347 bytes desktop/web/index.html | 1 + pyproject.toml | 1 + setup.py | 17 +++++++++ src/vidxp/branding.py | 37 +++++++++++++++++++ src/vidxp/frontend.py | 9 ++++- src/vidxp/mcp.py | 16 +++++++- tests/test_mcp.py | 37 ++++++++++++++++++- tests/test_packaging.py | 59 ++++++++++++++++++++++++++++++ 13 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 desktop/scripts/sync-branding.mjs create mode 100644 setup.py create mode 100644 src/vidxp/branding.py diff --git a/.gitignore b/.gitignore index 9f4d419..55a6fdd 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ pnpm-debug.log* /desktop/src-tauri/target/ /desktop/src-tauri/gen/ /desktop/src-tauri/binaries/uv-* +/desktop/web/icon.png /desktop/src-tauri/icons/android/ /desktop/src-tauri/icons/ios/ /desktop/src-tauri/icons/64x64.png diff --git a/MANIFEST.in b/MANIFEST.in index 993b28c..383bfce 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,6 @@ include README.md include LICENSE +include docs/images/logo.png global-exclude *.whl global-exclude *.pyc global-exclude __pycache__ diff --git a/desktop/package.json b/desktop/package.json index d45831b..2e31594 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -5,7 +5,11 @@ "type": "module", "scripts": { "tauri": "tauri", + "sync:branding": "node scripts/sync-branding.mjs", + "icons": "npm run sync:branding && tauri icon ../docs/images/logo.png --output src-tauri/icons", + "predesktop:dev": "npm run sync:branding", "desktop:dev": "tauri dev", + "predesktop:build": "npm run icons", "desktop:build": "tauri build", "sidecar:windows": "powershell -ExecutionPolicy Bypass -File scripts/fetch-uv.ps1", "sidecar:unix": "bash scripts/fetch-uv.sh" diff --git a/desktop/scripts/sync-branding.mjs b/desktop/scripts/sync-branding.mjs new file mode 100644 index 0000000..218f170 --- /dev/null +++ b/desktop/scripts/sync-branding.mjs @@ -0,0 +1,10 @@ +import { copyFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const source = resolve(desktopRoot, "../docs/images/logo.png"); +const favicon = resolve(desktopRoot, "web/icon.png"); + +copyFileSync(source, favicon); +console.log("Synced the VidXP desktop favicon from the shared icon."); diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns index e7fd4f602f0d3b41be96d5ecd93de6a78683799a..7a1c9ef47d616b43396b61223e7ba70b2b0e9cbd 100644 GIT binary patch delta 127 zcmZ4dGI;UJ;0^LD?HAYaZ@;)s;Ema4d6vce+xI^eC=Y2qD7^ikFyjHcc5^F1AQl2* z;qB&DB00O-6Tff+F%J;)ZcqHeSG22reKsQyGXXI(5VHU=D-g2*F*^`*05K;Jb8TOr O&Ap{#dqWVTjwApY+cuN{ delta 128 zcmZ4dGI;UJ;0^LD+qtb77xTBP&1c-MHlJy~-S HEJe2ZFeOm>YHFp;PT^=_0 diff --git a/desktop/web/index.html b/desktop/web/index.html index d2f7df7..b04a0ce 100644 --- a/desktop/web/index.html +++ b/desktop/web/index.html @@ -5,6 +5,7 @@ VidXP Setup + diff --git a/pyproject.toml b/pyproject.toml index 592c052..89150bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ vidxp-hooks = "vidxp.hook_cli:main" vidxp-mcp = "vidxp.mcp_cli:main" [project.urls] +Homepage = "https://github.com/grayhatdevelopers/vidxp" Repository = "https://github.com/grayhatdevelopers/vidxp" Issues = "https://github.com/grayhatdevelopers/vidxp/issues" diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..31da439 --- /dev/null +++ b/setup.py @@ -0,0 +1,17 @@ +from pathlib import Path +from shutil import copyfile + +from setuptools import setup +from setuptools.command.build_py import build_py + + +class BuildPy(build_py): + def run(self): + super().run() + source = Path(__file__).parent / "docs" / "images" / "logo.png" + target = Path(self.build_lib) / "vidxp" / "assets" / "icon.png" + target.parent.mkdir(parents=True, exist_ok=True) + copyfile(source, target) + + +setup(cmdclass={"build_py": BuildPy}) diff --git a/src/vidxp/branding.py b/src/vidxp/branding.py new file mode 100644 index 0000000..9abae12 --- /dev/null +++ b/src/vidxp/branding.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import base64 +from functools import lru_cache +from importlib.resources import files +from pathlib import Path + + +PROJECT_URL = "https://github.com/grayhatdevelopers/vidxp" +ICON_MIME_TYPE = "image/png" +ICON_SIZE = "303x303" + + +def icon_path() -> str: + """Return the canonical icon in source or its installed wheel copy.""" + + packaged_icon = files("vidxp").joinpath("assets", "icon.png") + if packaged_icon.is_file(): + return str(packaged_icon) + + source_icon = ( + Path(__file__).resolve().parents[2] / "docs" / "images" / "logo.png" + ) + if source_icon.is_file(): + return str(source_icon) + raise FileNotFoundError("VidXP icon is missing from the installation.") + + +@lru_cache(maxsize=1) +def icon_bytes() -> bytes: + return Path(icon_path()).read_bytes() + + +@lru_cache(maxsize=1) +def icon_data_uri() -> str: + encoded = base64.b64encode(icon_bytes()).decode("ascii") + return f"data:{ICON_MIME_TYPE};base64,{encoded}" diff --git a/src/vidxp/frontend.py b/src/vidxp/frontend.py index 6029ea4..ce15d84 100644 --- a/src/vidxp/frontend.py +++ b/src/vidxp/frontend.py @@ -24,6 +24,7 @@ QueryVideoCommand, SearchCommand, ) +from vidxp.branding import PROJECT_URL, icon_path from vidxp.composition import create_application, create_job_service from vidxp.index_state import IndexNotReadyError from vidxp.job_service import JobService @@ -791,8 +792,14 @@ def _search_controls(ready, uploaded_video, available_modalities): def run(): + application_icon = icon_path() + st.set_page_config( + page_title="VidXP", + page_icon=application_icon, + layout="wide", + ) + st.logo(application_icon, size="large", link=PROJECT_URL) service = _configured_service() - st.set_page_config(page_title="VidXP", page_icon="🎬", layout="wide") st.title("VidXP") st.caption("Index and search video by dialogue, scene, and actor.") st.caption(f"Index repository: {service.layout.root}") diff --git a/src/vidxp/mcp.py b/src/vidxp/mcp.py index 7da06eb..5ebbf13 100644 --- a/src/vidxp/mcp.py +++ b/src/vidxp/mcp.py @@ -16,7 +16,7 @@ from mcp.server.transport_security import TransportSecurityMiddleware from mcp.shared.exceptions import MCPError from mcp.server.mcpserver.exceptions import ToolError -from mcp.types import ToolAnnotations +from mcp.types import Icon, ToolAnnotations from pydantic import Field from starlette.requests import Request from starlette.types import ASGIApp, Receive, Scope, Send @@ -49,6 +49,12 @@ create_authenticator, ) from vidxp.authorization import RepositoryPermission +from vidxp.branding import ( + ICON_MIME_TYPE, + ICON_SIZE, + PROJECT_URL, + icon_data_uri, +) from vidxp.composition import ControlPlaneContext, HttpApplicationContext from vidxp.idempotency import IdempotencyKey, scoped_job_id from vidxp.settings import HttpAuthMode @@ -328,6 +334,14 @@ def create_mcp_server( name="vidxp", title="VidXP", description="Index and search registered video media.", + website_url=PROJECT_URL, + icons=[ + Icon( + src=icon_data_uri(), + mimeType=ICON_MIME_TYPE, + sizes=[ICON_SIZE], + ) + ], instructions=( "Call get_runtime_readiness before indexing. If selected model " "artifacts are missing, submit prepare_models and poll get_job " diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 50d66d2..8909ad8 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -1,7 +1,8 @@ -import unittest -import sys import asyncio +import base64 import socket +import sys +import unittest from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import Mock @@ -34,6 +35,12 @@ ) from vidxp.api import create_app from vidxp.authorization import AuthorizationPolicy +from vidxp.branding import ( + ICON_MIME_TYPE, + ICON_SIZE, + PROJECT_URL, + icon_bytes, +) from vidxp.composition import HttpApplicationContext from vidxp.control_plane import ControlPlaneApplication from vidxp.job_service import JobService @@ -135,6 +142,32 @@ async def test_curated_tools_have_structured_output_schemas(self): self.assertEqual(result.structured_content, {"items": []}) self.assertFalse(result.is_error) + async def test_server_info_exposes_vidxp_branding(self): + with TemporaryDirectory() as directory: + server = create_mcp_server( + self.context(Path(directory)), + default_principal=Principal( + subject="local", + scopes=frozenset({"*"}), + ), + ) + async with Client(server) as client: + server_info = client.server_info + + self.assertIsNotNone(server_info) + self.assertEqual(server_info.title, "VidXP") + self.assertEqual(server_info.website_url, PROJECT_URL) + self.assertEqual(len(server_info.icons or ()), 1) + icon = server_info.icons[0] + self.assertEqual(icon.mime_type, ICON_MIME_TYPE) + self.assertEqual(icon.sizes, [ICON_SIZE]) + prefix = f"data:{ICON_MIME_TYPE};base64," + self.assertTrue(icon.src.startswith(prefix)) + self.assertEqual( + base64.b64decode(icon.src.removeprefix(prefix)), + icon_bytes(), + ) + async def test_index_submission_uses_shared_stable_idempotency(self): with TemporaryDirectory() as directory: context = self.context(Path(directory)) diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 2c68656..8185ead 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -13,6 +13,65 @@ class PackagingTests(unittest.TestCase): + def test_canonical_icon_is_packaged_and_desktop_derivatives_are_wired(self): + icon = ROOT / "docs" / "images" / "logo.png" + self.assertTrue(icon.is_file()) + self.assertTrue(icon.read_bytes().startswith(b"\x89PNG\r\n\x1a\n")) + + self.assertIn( + "include docs/images/logo.png", + (ROOT / "MANIFEST.in").read_text(encoding="utf-8"), + ) + project = tomllib.loads( + (ROOT / "pyproject.toml").read_text(encoding="utf-8") + ) + self.assertEqual( + project["project"]["urls"]["Homepage"], + "https://github.com/grayhatdevelopers/vidxp", + ) + self.assertIn( + "./docs/images/logo.png", + (ROOT / "README.md").read_text(encoding="utf-8"), + ) + build_hook = (ROOT / "setup.py").read_text(encoding="utf-8") + self.assertIn('"docs" / "images" / "logo.png"', build_hook) + self.assertIn('"vidxp" / "assets" / "icon.png"', build_hook) + + package = json.loads( + (ROOT / "desktop" / "package.json").read_text(encoding="utf-8") + ) + self.assertEqual( + package["scripts"]["sync:branding"], + "node scripts/sync-branding.mjs", + ) + self.assertEqual( + package["scripts"]["predesktop:dev"], + "npm run sync:branding", + ) + self.assertEqual( + package["scripts"]["predesktop:build"], + "npm run icons", + ) + self.assertEqual( + package["scripts"]["icons"], + ( + "npm run sync:branding && " + "tauri icon ../docs/images/logo.png " + "--output src-tauri/icons" + ), + ) + sync_script = ( + ROOT / "desktop" / "scripts" / "sync-branding.mjs" + ).read_text(encoding="utf-8") + self.assertIn("../docs/images/logo.png", sync_script) + self.assertIn("web/icon.png", sync_script) + self.assertIn( + 'href="icon.png"', + (ROOT / "desktop" / "web" / "index.html").read_text( + encoding="utf-8" + ), + ) + def test_cpu_lock_uses_explicit_pytorch_index_without_cuda_packages(self): lock = (ROOT / "uv.lock").read_text(encoding="utf-8") project = (ROOT / "pyproject.toml").read_text(encoding="utf-8") From 00516aa12a0b8b1f5d27273143d76b7a5364812e Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 30 Jul 2026 01:37:08 +0500 Subject: [PATCH 31/46] feat(mcp): improve setup and media discovery --- INSTALLATION_GUIDE.md | 34 ++++++- README.md | 30 +++++- changes/mcp-media-usability.feature.md | 1 + src/vidxp/api_routes/jobs.py | 12 +++ src/vidxp/api_routes/media.py | 9 ++ src/vidxp/application_models.py | 97 +++++++++++++++--- src/vidxp/cli.py | 4 + src/vidxp/cli_commands/mcp.py | 64 ++++++++++++ src/vidxp/cli_commands/media.py | 26 ++++- src/vidxp/cli_commands/query.py | 8 +- src/vidxp/cli_commands/search.py | 13 +++ src/vidxp/mcp.py | 39 ++++++-- src/vidxp/mcp_cli.py | 132 ++++++++++++++++++++++--- tests/test_cli.py | 63 +++++++++++- tests/test_mcp.py | 49 +++++++++ 15 files changed, 539 insertions(+), 42 deletions(-) create mode 100644 changes/mcp-media-usability.feature.md create mode 100644 src/vidxp/cli_commands/mcp.py diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md index 7107c05..2edfdd4 100644 --- a/INSTALLATION_GUIDE.md +++ b/INSTALLATION_GUIDE.md @@ -129,6 +129,29 @@ Set `VIDXP_DATA_DIR` instead when every local entry point, including overrides. Docker and Compose are separate deployment profiles and use their explicitly configured volumes. +## Connect a local MCP client + +Add the MCP adapter to the same environment as the local worker. For a source +checkout: + +```bash +uv sync --frozen --extra local-worker --extra mcp +uv run vidxp mcp-config +``` + +For a published pipx worker install: + +```bash +pipx runpip vidxp install "vidxp[mcp]" +vidxp mcp-config +``` + +The second command prints a complete `mcpServers` JSON object with the resolved +absolute executable path. Copy and paste that object into LobeHub's +**Import JSON config** action or another stdio MCP client. To select a named +collection, run `vidxp mcp-config --repository `. The equivalent +standalone commands are `vidxp-mcp --print-config` and `vidxp-mcp --help`. + ## Verify providers and model readiness without downloading ```bash @@ -189,14 +212,21 @@ preparation. ## First indexing run ```bash -vidxp index create samplevideo.mp4 +vidxp media import samplevideo.mp4 --json +vidxp index create vidxp search scene "a yellow taxi on a city street" ``` +The import result contains the stable `media_id` used by indexing and optional +single-video filters. Use `vidxp media list` to rediscover IDs and filenames, +and `vidxp media show ` for the full registered metadata. +Omit `--media-id` from search/query to rank across every media item in the +active index snapshot, or provide it to restrict results to one video. + Use fewer capabilities or adjust the scene sampling rate when appropriate: ```bash -vidxp index create samplevideo.mp4 \ +vidxp index create \ --modality scene \ --scene-sample-fps 1 ``` diff --git a/README.md b/README.md index ec8a882..2df41b7 100644 --- a/README.md +++ b/README.md @@ -153,8 +153,10 @@ use that ID instead of exposing repository file paths. Search the completed index: ```bash +vidxp media list vidxp search dialogue "the bread just came out of the oven" vidxp search scene "a yellow taxi on a city street" --top-k 5 +vidxp search scene "a yellow taxi" --media-id vidxp query "What happens after the taxi arrives?" --media-id vidxp actors list vidxp actors render --json @@ -162,6 +164,18 @@ vidxp artifacts snippet 30 45 --json vidxp artifacts show ``` +`vidxp media list` returns registered filenames and stable IDs. Search and +query omit `--media-id` by default, which ranks evidence across every media +item in the active index snapshot. Add `--media-id` to restrict either command +to one video. `vidxp media show ` returns that video's complete +registered metadata, and every ranked result identifies its source `media_id`. + +Each successful `vidxp index create` adds a new video to the active snapshot or +replaces that video's existing generation; it does not discard the other +indexed videos. `vidxp index status` lists active membership, and +`vidxp index remove ` removes one item. Use separate named +repositories when you need separate searchable video collections. + Index only selected capabilities or adjust the time-based scene sampling rate: ```bash @@ -302,13 +316,25 @@ VIDXP_MCP_ALLOWED_ORIGINS=[] For adjacent local agents, install the MCP extra and use stdio: ```bash -pipx install "vidxp[all,mcp]" -vidxp-mcp --repository default +pipx install "vidxp[local-worker,mcp]" +vidxp mcp-config ``` +`vidxp mcp-config` prints import-ready `mcpServers` JSON containing the resolved +absolute `vidxp-mcp` executable path and `--repository default`. Copy and paste +the whole object into LobeHub's **Import JSON config** action or another stdio +MCP client. `vidxp-mcp --print-config` emits the same JSON, while +`vidxp-mcp --help` includes a ready-to-copy example alongside all server +options. + The stdio process owns its local repository/job lifecycle and exits cleanly when the client closes the transport. +MCP agents discover registered filenames and IDs with `list_media`, then inspect +active indexed membership with `get_index_status`. For `search_moments` and +`query_video`, setting `command.media_id` restricts results to that video; +omitting it searches or queries every media item in the active index snapshot. + ## Container Stable releases are available from GitHub Container Registry. Start the local diff --git a/changes/mcp-media-usability.feature.md b/changes/mcp-media-usability.feature.md new file mode 100644 index 0000000..3f05ffd --- /dev/null +++ b/changes/mcp-media-usability.feature.md @@ -0,0 +1 @@ +MCP setup now prints import-ready local client JSON, while CLI, MCP, and HTTP discovery clearly distinguish one-video media ID filters from cross-media active-snapshot search. diff --git a/src/vidxp/api_routes/jobs.py b/src/vidxp/api_routes/jobs.py index 89e7e6b..6bfb0dd 100644 --- a/src/vidxp/api_routes/jobs.py +++ b/src/vidxp/api_routes/jobs.py @@ -36,6 +36,10 @@ status_code=202, operation_id="startIndexing", summary="Start indexing", + description=( + "Add or replace one registered media item in the active multi-video " + "index snapshot." + ), dependencies=[Depends(write_principal)], ) def submit_index( @@ -66,6 +70,10 @@ def submit_index( status_code=202, operation_id="searchMoments", summary="Search indexed moments", + description=( + "Set media_id to search one video, or omit it to rank moments across " + "every media item in the active index snapshot." + ), dependencies=[Depends(read_principal)], ) def submit_search( @@ -95,6 +103,10 @@ def submit_search( status_code=202, operation_id="queryVideo", summary="Ask a grounded question about indexed media", + description=( + "Set media_id to ground the answer in one video, or omit it to use " + "evidence across every media item in the active index snapshot." + ), dependencies=[Depends(read_principal)], ) def submit_query( diff --git a/src/vidxp/api_routes/media.py b/src/vidxp/api_routes/media.py index b21d9d7..0122bd2 100644 --- a/src/vidxp/api_routes/media.py +++ b/src/vidxp/api_routes/media.py @@ -162,6 +162,11 @@ def import_media( response_model=MediaPage, operation_id="listMedia", summary="List media", + description=( + "List registered filenames, metadata, and stable media IDs. A " + "registered item is searchable only when its ID is also present in " + "the active index snapshot." + ), dependencies=[Depends(read_principal)], ) def list_media( @@ -182,6 +187,10 @@ def list_media( response_model=MediaAsset, operation_id="getMedia", summary="Get media metadata", + description=( + "Get one registered media item by the stable ID returned from the " + "media list or a completed upload." + ), dependencies=[Depends(read_principal)], ) def get_media( diff --git a/src/vidxp/application_models.py b/src/vidxp/application_models.py index 271d206..76a576c 100644 --- a/src/vidxp/application_models.py +++ b/src/vidxp/application_models.py @@ -299,7 +299,12 @@ def _filename_only(cls, value: str | None) -> str | None: class MediaAsset(ApplicationModel): schema_version: Literal[MEDIA_SCHEMA_VERSION] = MEDIA_SCHEMA_VERSION - media_id: MediaId + media_id: MediaId = Field( + description=( + "Stable registered-media identifier used by indexing and optional " + "single-media search/query filters." + ) + ) video_id: VideoId original_filename: str = Field(min_length=1) sha256: Sha256 @@ -314,8 +319,18 @@ class MediaAsset(ApplicationModel): class ListMediaCommand(ApplicationModel): - page_size: int = Field(default=50, gt=0, le=100) - cursor: str | None = Field(default=None, min_length=1, max_length=512) + page_size: int = Field( + default=50, + gt=0, + le=100, + description="Maximum registered media records to return.", + ) + cursor: str | None = Field( + default=None, + min_length=1, + max_length=512, + description="Opaque next_cursor from the previous list_media page.", + ) class MediaPage(Page[MediaAsset]): @@ -346,7 +361,12 @@ class UploadIntent(ApplicationModel): class CreateIndexCommand(ApplicationModel): - media_id: MediaId + media_id: MediaId = Field( + description=( + "Stable identifier returned by list_media, get_media, or a " + "completed upload." + ) + ) modalities: tuple[str, ...] frame_stride: int = Field( default=1, @@ -484,12 +504,22 @@ class IndexStatus(ApplicationModel): class IndexStatusSummary(ApplicationModel): index_schema_version: int = Field(ge=1) snapshot_id: IndexSnapshotId - media_count: int = Field(ge=0) + media_count: int = Field( + ge=0, + description="Number of media items in the active index snapshot.", + ) media_ids: tuple[MediaId, ...] = Field( default=(), max_length=INDEX_STATUS_MEDIA_ID_LIMIT, + description=( + "Stable IDs included in the active snapshot. The list is capped; " + "check media_ids_truncated before treating it as complete." + ), + ) + media_ids_truncated: bool = Field( + default=False, + description="Whether active snapshot media IDs were omitted by the cap.", ) - media_ids_truncated: bool = False modalities: tuple[str, ...] = () @model_validator(mode="after") @@ -510,10 +540,28 @@ class FusionProfile(StrEnum): class SearchCommand(ApplicationModel): - query: SearchQuery - modalities: tuple[Identifier, ...] = () - media_id: MediaId | None = None - top_k: int = Field(default=10, gt=0, le=100) + query: SearchQuery = Field(description="Text to match against indexed moments.") + modalities: tuple[Identifier, ...] = Field( + default=(), + description=( + "Indexed capabilities to search. Omit to use every searchable " + "capability in the active snapshot." + ), + ) + media_id: MediaId | None = Field( + default=None, + description=( + "Optional single-media filter. Provide a stable ID from list_media " + "to search only that video; omit it to rank results across every " + "media item in the active index snapshot." + ), + ) + top_k: int = Field( + default=10, + gt=0, + le=100, + description="Maximum fused moments to return across the selected scope.", + ) @field_validator("modalities") @classmethod @@ -637,10 +685,31 @@ class FusedSearchResult(ApplicationModel): class QueryVideoCommand(ApplicationModel): - question: SearchQuery - media_id: MediaId | None = None - modalities: tuple[Identifier, ...] = Field(default=(), max_length=8) - top_k: int = Field(default=10, gt=0, le=50) + question: SearchQuery = Field( + description="Natural-language question grounded in indexed evidence." + ) + media_id: MediaId | None = Field( + default=None, + description=( + "Optional single-media filter. Provide a stable ID from list_media " + "to use evidence only from that video; omit it to use evidence " + "from every media item in the active index snapshot." + ), + ) + modalities: tuple[Identifier, ...] = Field( + default=(), + max_length=8, + description=( + "Indexed capabilities to use as evidence. Omit to use every " + "queryable capability in the active snapshot." + ), + ) + top_k: int = Field( + default=10, + gt=0, + le=50, + description="Maximum ranked evidence moments used for the answer.", + ) @field_validator("modalities") @classmethod diff --git a/src/vidxp/cli.py b/src/vidxp/cli.py index 22e5ac7..5445d49 100644 --- a/src/vidxp/cli.py +++ b/src/vidxp/cli.py @@ -14,6 +14,7 @@ from vidxp.cli_commands.actors import app as actor_app from vidxp.cli_commands.index import app as index_app from vidxp.cli_commands.jobs import app as jobs_app +from vidxp.cli_commands.mcp import mcp_config from vidxp.cli_commands.media import app as media_app from vidxp.cli_commands.artifacts import app as artifacts_app from vidxp.cli_commands.repositories import app as repositories_app @@ -34,6 +35,7 @@ app.add_typer(artifacts_app, name="artifacts") app.command("search")(search) app.command("query")(query) +app.command("mcp-config")(mcp_config) app.add_typer(repositories_app, name="repositories") app.add_typer(actor_app, name="actors") @@ -118,6 +120,8 @@ def app_options( typer.Option("--quiet", "-q", help="Suppress progress output."), ] = False, ) -> None: + if ctx.invoked_subcommand == "mcp-config": + return local = create_local_application( registry_path=config_file, repository_name=repository_name, diff --git a/src/vidxp/cli_commands/mcp.py b/src/vidxp/cli_commands/mcp.py new file mode 100644 index 0000000..5ef773a --- /dev/null +++ b/src/vidxp/cli_commands/mcp.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Annotated + +import typer + +from vidxp.mcp_cli import render_stdio_client_config + + +def mcp_config( + registry: Annotated[ + Path | None, + typer.Option( + "--registry", + dir_okay=False, + help="Path to the named-repository configuration file.", + ), + ] = None, + repository: Annotated[ + str, + typer.Option( + "--repository", + "-r", + help="Repository the local MCP server should use.", + ), + ] = "default", + index_directory: Annotated[ + Path | None, + typer.Option( + "--index-directory", + file_okay=False, + help="Override the selected repository's index directory.", + ), + ] = None, + data_directory: Annotated[ + Path | None, + typer.Option( + "--data-dir", + file_okay=False, + help="Store VidXP models and the default repository here.", + ), + ] = None, + device: Annotated[ + str | None, + typer.Option( + "--device", + help="Override the selected repository runtime device.", + ), + ] = None, +) -> None: + """Print copy/paste JSON for a local stdio MCP client.""" + + typer.echo( + render_stdio_client_config( + registry=None if registry is None else str(registry), + repository=repository, + index_directory=( + None if index_directory is None else str(index_directory) + ), + data_directory=data_directory, + device=device, + ) + ) diff --git a/src/vidxp/cli_commands/media.py b/src/vidxp/cli_commands/media.py index c237603..a30a6f0 100644 --- a/src/vidxp/cli_commands/media.py +++ b/src/vidxp/cli_commands/media.py @@ -71,7 +71,7 @@ def list_media( typer.Option("--json", help="Emit machine-readable JSON."), ] = False, ) -> None: - """List ready media assets in the selected repository.""" + """List filenames, metadata, and stable IDs used by other commands.""" state = state_from_context(ctx) page = state.service.list_media( @@ -95,3 +95,27 @@ def list_media( f"{asset.byte_size:,}", ) Console().print(table) + + +@app.command("show") +def show_media( + ctx: typer.Context, + media_id: Annotated[ + str, + typer.Argument( + help="Stable media identifier returned by import or list." + ), + ], + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """Show all registered metadata for one media ID.""" + + state = state_from_context(ctx) + payload = state.service.get_media(media_id).model_dump(mode="json") + if effective_output_format(state, json_output) == OutputFormat.json: + emit_json(payload) + else: + Console().print_json(data=payload) diff --git a/src/vidxp/cli_commands/query.py b/src/vidxp/cli_commands/query.py index 631cc0b..36f360a 100644 --- a/src/vidxp/cli_commands/query.py +++ b/src/vidxp/cli_commands/query.py @@ -63,7 +63,13 @@ def query( ], media_id: Annotated[ str | None, - typer.Option("--media-id", help="Restrict evidence to one media ID."), + typer.Option( + "--media-id", + help=( + "Use evidence only from this media ID. Omit to query every " + "media item in the active index snapshot." + ), + ), ] = None, modality: Annotated[ list[str] | None, diff --git a/src/vidxp/cli_commands/search.py b/src/vidxp/cli_commands/search.py index 9cfdb21..086dd46 100644 --- a/src/vidxp/cli_commands/search.py +++ b/src/vidxp/cli_commands/search.py @@ -22,6 +22,7 @@ def run_search( capability: str, query: str, *, + media_id: str | None, top_k: int, json_output: bool, ) -> FusedSearchResult: @@ -33,6 +34,7 @@ def run_search( SearchCommand( modalities=(capability,), query=query, + media_id=media_id, top_k=top_k, ) ) @@ -60,6 +62,16 @@ def search( str, typer.Argument(help="Text query to find."), ], + media_id: Annotated[ + str | None, + typer.Option( + "--media-id", + help=( + "Search only this media ID. Omit to rank matches across every " + "media item in the active index snapshot." + ), + ), + ] = None, top_k: Annotated[ int, typer.Option( @@ -80,6 +92,7 @@ def search( state, capability, query, + media_id=media_id, top_k=top_k, json_output=json_output, ) diff --git a/src/vidxp/mcp.py b/src/vidxp/mcp.py index 5ebbf13..815075a 100644 --- a/src/vidxp/mcp.py +++ b/src/vidxp/mcp.py @@ -347,8 +347,11 @@ def create_mcp_server( "artifacts are missing, submit prepare_models and poll get_job " "until it completes. Discover registered media with list_media. " "Register and upload new video through the HTTP/tus API, then use " - "its media_id with the durable indexing and query tools. Use " - "list_jobs to recover job IDs across agent sessions." + "its media_id with start_indexing. get_index_status identifies the " + "media included in the active index snapshot. For search_moments " + "and query_video, provide command.media_id to restrict work to one " + "video, or omit it to search/query across every media item in that " + "snapshot. Use list_jobs to recover job IDs across agent sessions." ), version=__version__, token_verifier=token_verifier, @@ -399,7 +402,11 @@ async def get_runtime_readiness() -> RuntimeReadiness: ) @server.tool( - description="List registered video metadata without transferring bytes.", + description=( + "List registered video filenames, metadata, and stable media IDs " + "without transferring video bytes. Registration does not imply " + "that a video is present in the active index snapshot." + ), annotations=_READ_ONLY, structured_output=True, ) @@ -420,7 +427,10 @@ async def list_media( ) @server.tool( - description="Get metadata for one registered video.", + description=( + "Get one registered video's metadata by the stable media ID " + "returned by list_media." + ), annotations=_READ_ONLY, structured_output=True, ) @@ -433,7 +443,10 @@ async def get_media(media_id: MediaId) -> MediaAsset: ) @server.tool( - description="Inspect the active index state.", + description=( + "Inspect the active index snapshot, including its indexed media " + "count and media IDs." + ), annotations=_READ_ONLY, structured_output=True, ) @@ -446,7 +459,11 @@ async def get_index_status() -> IndexStatus: ) @server.tool( - description="Submit durable indexing for a registered media ID.", + description=( + "Add or replace one registered media ID in the active multi-video " + "index snapshot. Obtain the ID from list_media or a completed " + "upload, then poll get_job." + ), annotations=_SUBMIT, structured_output=True, ) @@ -504,7 +521,11 @@ def submit(actor: Principal) -> Job: ) @server.tool( - description="Submit a durable search over the active index.", + description=( + "Submit a durable ranked moment search. Set command.media_id to " + "search one registered video; omit it to search across every media " + "item in the active index snapshot. Poll get_job for top-k results." + ), annotations=_SUBMIT, structured_output=True, ) @@ -533,7 +554,9 @@ def submit(actor: Principal) -> Job: @server.tool( description=( "Submit a durable grounded natural-language query over indexed " - "moments and actor evidence." + "moments and actor evidence. Set command.media_id for one video, " + "or omit it to query across every media item in the active index " + "snapshot. Poll get_job for the answer and evidence." ), annotations=_SUBMIT, structured_output=True, diff --git a/src/vidxp/mcp_cli.py b/src/vidxp/mcp_cli.py index 1919f6f..65a62d2 100644 --- a/src/vidxp/mcp_cli.py +++ b/src/vidxp/mcp_cli.py @@ -1,31 +1,137 @@ from __future__ import annotations import argparse +import json +import os +import shutil +import sys from pathlib import Path -from typing import Sequence +from typing import Any, Sequence -from vidxp.application_models import Principal -from vidxp.composition import ( - create_control_plane_application, - create_local_application, -) -from vidxp.mcp import create_mcp_server + +def mcp_executable() -> str: + """Return the most reliable executable path for desktop MCP clients.""" + + discovered = shutil.which("vidxp-mcp") + if discovered is not None: + return str(Path(discovered).resolve()) + executable_name = "vidxp-mcp.exe" if os.name == "nt" else "vidxp-mcp" + sibling = Path(sys.executable).with_name(executable_name) + if sibling.is_file(): + return str(sibling.resolve()) + return "vidxp-mcp" + + +def stdio_client_config( + *, + command: str | None = None, + registry: str | None = None, + repository: str | None = "default", + index_directory: str | None = None, + data_directory: Path | None = None, + device: str | None = None, +) -> dict[str, Any]: + """Build a broadly supported copy/paste MCP client configuration.""" + + arguments: list[str] = [] + for flag, value in ( + ("--registry", registry), + ("--repository", repository), + ("--index-directory", index_directory), + ("--data-dir", data_directory), + ("--device", device), + ): + if value is not None: + arguments.extend((flag, str(value))) + return { + "mcpServers": { + "vidxp": { + "command": command or mcp_executable(), + "args": arguments, + } + } + } + + +def render_stdio_client_config(**options: Any) -> str: + return json.dumps( + stdio_client_config(**options), + ensure_ascii=False, + indent=2, + ) + + +def _parser() -> argparse.ArgumentParser: + example = render_stdio_client_config() + return argparse.ArgumentParser( + description="Run the local VidXP MCP server over stdio.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "COPY/PASTE MCP CLIENT CONFIG\n" + "Import this JSON into LobeHub or another stdio MCP client:\n\n" + f"{example}\n\n" + "Run `vidxp-mcp --print-config` to print only the JSON. Add the " + "same repository/data/device options to either command when you " + "need non-default values." + ), + ) def main(arguments: Sequence[str] | None = None) -> None: - parser = argparse.ArgumentParser( - description="Run the local VidXP MCP server over stdio." + parser = _parser() + parser.add_argument( + "--registry", + help="Path to the named-repository configuration file.", + ) + parser.add_argument( + "--repository", + help="Named repository to use; the implicit default is 'default'.", + ) + parser.add_argument( + "--index-directory", + help="Override the selected repository's index directory.", ) - parser.add_argument("--registry") - parser.add_argument("--repository") - parser.add_argument("--index-directory") parser.add_argument( "--data-dir", type=Path, help="Store VidXP models and the default repository here.", ) - parser.add_argument("--device") + parser.add_argument( + "--device", + help="Override the selected repository runtime device.", + ) + parser.add_argument( + "--print-config", + action="store_true", + help="Print import-ready MCP client JSON and exit.", + ) options = parser.parse_args(arguments) + if options.print_config: + print( + render_stdio_client_config( + registry=options.registry, + repository=options.repository or "default", + index_directory=options.index_directory, + data_directory=options.data_dir, + device=options.device, + ) + ) + return + + from vidxp.application_models import Principal + from vidxp.composition import ( + create_control_plane_application, + create_local_application, + ) + try: + from vidxp.mcp import create_mcp_server + except ModuleNotFoundError as exc: + if exc.name == "mcp": + parser.error( + 'MCP support is not installed. Install "vidxp[mcp]" in this ' + "same environment, then run the command again." + ) + raise local = create_local_application( registry_path=options.registry, diff --git a/tests/test_cli.py b/tests/test_cli.py index 1c73cce..498ef3f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -122,9 +122,23 @@ def test_grouped_commands_are_exposed(self): "artifacts", "doctor", "prepare", + "mcp-config", ): self.assertIn(command, result.output) + def test_mcp_config_is_copy_paste_json_without_opening_repository(self): + result = self.invoke(["mcp-config"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.create_local_application.assert_not_called() + config = json.loads(result.output) + server = config["mcpServers"]["vidxp"] + self.assertIn( + Path(server["command"]).name.lower(), + {"vidxp-mcp", "vidxp-mcp.exe"}, + ) + self.assertEqual(server["args"], ["--repository", "default"]) + def test_startup_notice_targets_long_interactive_commands(self): self.assertEqual(startup_command(["doctor"]), "doctor") self.assertEqual( @@ -184,7 +198,16 @@ def test_search_constructs_shared_command(self): ) result = self.invoke( - ["search", "scene", "yellow taxi", "--top-k", "7", "--json"] + [ + "search", + "scene", + "yellow taxi", + "--media-id", + MEDIA_ID, + "--top-k", + "7", + "--json", + ] ) self.assertEqual(result.exit_code, 0, result.output) @@ -192,11 +215,19 @@ def test_search_constructs_shared_command(self): SearchCommand( modalities=("scene",), query="yellow taxi", + media_id=MEDIA_ID, top_k=7, ) ) self.assertEqual(json.loads(result.output)["query"], "yellow taxi") + def test_search_help_explains_cross_media_default(self): + result = self.invoke(["search", "--help"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("every media item in the active", result.output) + self.assertIn("index snapshot.", result.output) + def test_query_constructs_shared_command_and_emits_typed_answer(self): answer = QueryAnswer( question="What happens?", @@ -340,6 +371,36 @@ def test_media_import_uses_the_local_import_command(self): video.resolve(), ) + def test_media_show_returns_registered_metadata(self): + self.service.get_media.return_value = MediaAsset( + schema_version=1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + original_filename="video.mp4", + sha256="1" * 64, + byte_size=5, + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=1, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + state=MediaState.ready, + created_at=datetime.now(timezone.utc), + ) + + result = self.invoke(["media", "show", MEDIA_ID, "--json"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.service.get_media.assert_called_once_with(MEDIA_ID) + self.assertEqual(json.loads(result.output)["media_id"], MEDIA_ID) + def test_status_serializes_shared_model(self): self.service.index_status.return_value = IndexStatus( schema_version=1, diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 8909ad8..b1cc1ea 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -1,5 +1,8 @@ import asyncio import base64 +import contextlib +import io +import json import socket import sys import unittest @@ -45,6 +48,8 @@ from vidxp.control_plane import ControlPlaneApplication from vidxp.job_service import JobService from vidxp.mcp import VidXPTokenVerifier, create_mcp_server +from vidxp.mcp_cli import main as mcp_main +from vidxp.mcp_cli import stdio_client_config from vidxp.settings import VidXPSettings @@ -139,9 +144,53 @@ async def test_curated_tools_have_structured_output_schemas(self): self.assertTrue( all(tool.output_schema is not None for tool in discovered.tools) ) + tools = {tool.name: tool for tool in discovered.tools} + for name in ("search_moments", "query_video"): + schema = tools[name].input_schema + command_ref = schema["properties"]["command"]["$ref"] + command_name = command_ref.rsplit("/", 1)[-1] + media_description = schema["$defs"][command_name]["properties"][ + "media_id" + ]["description"] + self.assertIn("omit it", media_description) + self.assertIn("active index snapshot", media_description) self.assertEqual(result.structured_content, {"items": []}) self.assertFalse(result.is_error) + def test_stdio_help_and_config_are_ready_to_copy(self): + config = stdio_client_config( + command=r"C:\VidXP\vidxp-mcp.exe", + repository="library", + ) + self.assertEqual( + config, + { + "mcpServers": { + "vidxp": { + "command": r"C:\VidXP\vidxp-mcp.exe", + "args": ["--repository", "library"], + } + } + }, + ) + + output = io.StringIO() + with contextlib.redirect_stdout(output): + with self.assertRaises(SystemExit) as raised: + mcp_main(["--help"]) + self.assertEqual(raised.exception.code, 0) + self.assertIn("COPY/PASTE MCP CLIENT CONFIG", output.getvalue()) + self.assertIn('"mcpServers"', output.getvalue()) + + output = io.StringIO() + with contextlib.redirect_stdout(output): + mcp_main(["--print-config", "--repository", "library"]) + rendered = json.loads(output.getvalue()) + self.assertEqual( + rendered["mcpServers"]["vidxp"]["args"], + ["--repository", "library"], + ) + async def test_server_info_exposes_vidxp_branding(self): with TemporaryDirectory() as directory: server = create_mcp_server( From daf119d81b59cfc9f59397cf1d40592b3f028cc2 Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 30 Jul 2026 02:05:05 +0500 Subject: [PATCH 32/46] feat(cli): add MCP self-check and index listing --- INSTALLATION_GUIDE.md | 11 +-- README.md | 19 ++++-- changes/mcp-self-check-index-list.feature.md | 1 + src/vidxp/cli_commands/index.py | 71 +++++++++++++++++++- src/vidxp/mcp_cli.py | 51 ++++++++++++-- tests/test_cli.py | 48 +++++++++++++ tests/test_mcp.py | 20 ++++++ 7 files changed, 207 insertions(+), 14 deletions(-) create mode 100644 changes/mcp-self-check-index-list.feature.md diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md index 2edfdd4..7a1e14b 100644 --- a/INSTALLATION_GUIDE.md +++ b/INSTALLATION_GUIDE.md @@ -147,10 +147,13 @@ vidxp mcp-config ``` The second command prints a complete `mcpServers` JSON object with the resolved -absolute executable path. Copy and paste that object into LobeHub's -**Import JSON config** action or another stdio MCP client. To select a named -collection, run `vidxp mcp-config --repository `. The equivalent -standalone commands are `vidxp-mcp --print-config` and `vidxp-mcp --help`. +absolute executable path. Copy that object into a stdio MCP client's JSON +configuration. To select a named collection, run +`vidxp mcp-config --repository `. The equivalent standalone commands are +`vidxp-mcp --print-config` and `vidxp-mcp --help`. +Run `vidxp-mcp --check --repository ` to perform a local protocol +handshake, discover the tools, call the read-only index-status tool, print the +resolved data/index paths, and exit. ## Verify providers and model readiness without downloading diff --git a/README.md b/README.md index 2df41b7..960d023 100644 --- a/README.md +++ b/README.md @@ -172,8 +172,9 @@ registered metadata, and every ranked result identifies its source `media_id`. Each successful `vidxp index create` adds a new video to the active snapshot or replaces that video's existing generation; it does not discard the other -indexed videos. `vidxp index status` lists active membership, and -`vidxp index remove ` removes one item. Use separate named +indexed videos. `vidxp index list` joins active membership to registered +filenames and metadata, while `vidxp index status` shows the underlying snapshot +state. `vidxp index remove ` removes one item. Use separate named repositories when you need separate searchable video collections. Index only selected capabilities or adjust the time-based scene sampling rate: @@ -321,12 +322,20 @@ vidxp mcp-config ``` `vidxp mcp-config` prints import-ready `mcpServers` JSON containing the resolved -absolute `vidxp-mcp` executable path and `--repository default`. Copy and paste -the whole object into LobeHub's **Import JSON config** action or another stdio -MCP client. `vidxp-mcp --print-config` emits the same JSON, while +absolute `vidxp-mcp` executable path and `--repository default`. Copy the whole +object into a stdio MCP client's JSON configuration. `vidxp-mcp --print-config` +emits the same JSON, while `vidxp-mcp --help` includes a ready-to-copy example alongside all server options. +Verify the installed MCP adapter, selected repository, protocol handshake, tool +discovery, and a read-only index-status tool call without leaving a server +running: + +```bash +vidxp-mcp --check --repository default +``` + The stdio process owns its local repository/job lifecycle and exits cleanly when the client closes the transport. diff --git a/changes/mcp-self-check-index-list.feature.md b/changes/mcp-self-check-index-list.feature.md new file mode 100644 index 0000000..7fb0c24 --- /dev/null +++ b/changes/mcp-self-check-index-list.feature.md @@ -0,0 +1 @@ +Local installs can now self-check the MCP protocol surface and list active indexed media with their registered filenames and metadata. diff --git a/src/vidxp/cli_commands/index.py b/src/vidxp/cli_commands/index.py index d5f83fd..8d0036a 100644 --- a/src/vidxp/cli_commands/index.py +++ b/src/vidxp/cli_commands/index.py @@ -3,8 +3,13 @@ from typing import Annotated, Iterable import typer +from rich.console import Console +from rich.table import Table -from vidxp.application_models import CreateIndexCommand, RemoveIndexCommand +from vidxp.application_models import ( + CreateIndexCommand, + RemoveIndexCommand, +) from vidxp.cli_support import ( CLIState, IndexProgress, @@ -196,6 +201,70 @@ def index_status( ) +@app.command("list") +def index_list( + ctx: typer.Context, + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """List registered metadata for media in the active index snapshot.""" + + state = state_from_context(ctx) + status = state.service.index_status() + summary = status.summary + assets = ( + () + if summary is None + else tuple( + state.service.get_media(media_id) + for media_id in summary.media_ids + ) + ) + payload = { + "state": status.state, + "message": status.message, + "snapshot_id": None if summary is None else summary.snapshot_id, + "media_count": 0 if summary is None else summary.media_count, + "media_ids_truncated": ( + False if summary is None else summary.media_ids_truncated + ), + "modalities": [] if summary is None else list(summary.modalities), + "items": [asset.model_dump(mode="json") for asset in assets], + } + if effective_output_format(state, json_output) == OutputFormat.json: + emit_json(payload) + return + if summary is None: + typer.echo(status.message) + return + + table = Table(title="Active index media") + table.add_column("ID") + table.add_column("Filename") + table.add_column("Duration", justify="right") + table.add_column("Size", justify="right") + for asset in assets: + table.add_row( + asset.media_id, + asset.original_filename, + f"{asset.duration_seconds:.3f}s", + f"{asset.byte_size:,}", + ) + Console().print(table) + typer.echo( + f"Snapshot {summary.snapshot_id}: {summary.media_count} media item(s); " + f"modalities: {', '.join(summary.modalities) or 'none'}." + ) + if summary.media_ids_truncated: + typer.secho( + "The active snapshot is larger than this status page; some media " + "items are not shown.", + fg=typer.colors.YELLOW, + ) + + @app.command("clear") def index_clear( ctx: typer.Context, diff --git a/src/vidxp/mcp_cli.py b/src/vidxp/mcp_cli.py index 65a62d2..a4de11e 100644 --- a/src/vidxp/mcp_cli.py +++ b/src/vidxp/mcp_cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import asyncio import json import os import shutil @@ -68,7 +69,7 @@ def _parser() -> argparse.ArgumentParser: formatter_class=argparse.RawDescriptionHelpFormatter, epilog=( "COPY/PASTE MCP CLIENT CONFIG\n" - "Import this JSON into LobeHub or another stdio MCP client:\n\n" + "Import this JSON into any stdio MCP client:\n\n" f"{example}\n\n" "Run `vidxp-mcp --print-config` to print only the JSON. Add the " "same repository/data/device options to either command when you " @@ -77,6 +78,26 @@ def _parser() -> argparse.ArgumentParser: ) +async def _inspect_server(server: Any) -> dict[str, Any]: + from mcp.client import Client + + async with Client(server) as client: + discovered = await client.list_tools() + status = await client.call_tool("get_index_status", {}) + if status.is_error: + raise RuntimeError("The MCP index-status probe failed.") + server_info = client.server_info + return { + "server": server_info.title or server_info.name, + "version": server_info.version, + "tools": [tool.name for tool in discovered.tools], + "index_state": (status.structured_content or {}).get( + "state", + "unknown", + ), + } + + def main(arguments: Sequence[str] | None = None) -> None: parser = _parser() parser.add_argument( @@ -100,11 +121,20 @@ def main(arguments: Sequence[str] | None = None) -> None: "--device", help="Override the selected repository runtime device.", ) - parser.add_argument( + actions = parser.add_mutually_exclusive_group() + actions.add_argument( "--print-config", action="store_true", help="Print import-ready MCP client JSON and exit.", ) + actions.add_argument( + "--check", + action="store_true", + help=( + "Perform a local MCP handshake and tool probe, print the resolved " + "runtime paths, and exit." + ), + ) options = parser.parse_args(arguments) if options.print_config: print( @@ -142,14 +172,27 @@ def main(arguments: Sequence[str] | None = None) -> None: ) context = create_control_plane_application(local.settings) try: - create_mcp_server( + server = create_mcp_server( context, default_principal=Principal( subject="local", client_id="stdio", scopes=frozenset({"*"}), ), - ).run("stdio") + ) + if options.check: + result = asyncio.run(_inspect_server(server)) + print(f"OK {result['server']} MCP {result['version']}") + print(f"Repository: {local.repository.name}") + print(f"Data: {context.settings.data_dir}") + print(f"Index: {context.settings.repository_root}") + print(f"Index state: {result['index_state']}") + print( + f"Tools: {len(result['tools'])} " + f"({', '.join(result['tools'])})" + ) + return + server.run("stdio") finally: context.close() local.close() diff --git a/tests/test_cli.py b/tests/test_cli.py index 498ef3f..9fd23ca 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -22,6 +22,7 @@ IndexJobResult, IndexResult, IndexStatus, + IndexStatusSummary, Job, JobKind, JobQueue, @@ -414,6 +415,53 @@ def test_status_serializes_shared_model(self): self.assertEqual(result.exit_code, 0, result.output) self.assertEqual(json.loads(result.output)["state"], "missing") + def test_index_list_joins_active_ids_to_registered_metadata(self): + self.service.index_status.return_value = IndexStatus( + schema_version=1, + state="ready", + stage="status", + message="Index ready.", + summary=IndexStatusSummary( + index_schema_version=1, + snapshot_id=SNAPSHOT_ID, + media_count=1, + media_ids=(MEDIA_ID,), + modalities=("scene", "dialogue"), + ), + ) + self.service.get_media.return_value = MediaAsset( + schema_version=1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + original_filename="video.mp4", + sha256="1" * 64, + byte_size=5, + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=1, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + state=MediaState.ready, + created_at=datetime.now(timezone.utc), + ) + + result = self.invoke(["index", "list", "--json"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.service.get_media.assert_called_once_with(MEDIA_ID) + payload = json.loads(result.output) + self.assertEqual(payload["snapshot_id"], SNAPSHOT_ID) + self.assertEqual(payload["media_count"], 1) + self.assertEqual(payload["modalities"], ["scene", "dialogue"]) + self.assertEqual(payload["items"][0]["original_filename"], "video.mp4") + def test_doctor_and_prepare_use_shared_models(self): self.service.check_dependencies.return_value = DependencyCheckResult( ok=True, diff --git a/tests/test_mcp.py b/tests/test_mcp.py index b1cc1ea..970bee3 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -191,6 +191,26 @@ def test_stdio_help_and_config_are_ready_to_copy(self): ["--repository", "library"], ) + def test_stdio_check_performs_handshake_and_tool_probe(self): + output = io.StringIO() + with TemporaryDirectory() as directory: + with contextlib.redirect_stdout(output): + mcp_main( + [ + "--check", + "--data-dir", + directory, + "--device", + "cpu", + ] + ) + + rendered = output.getvalue() + self.assertIn("OK VidXP MCP", rendered) + self.assertIn("Index state: missing", rendered) + self.assertIn("Tools: 14", rendered) + self.assertIn("get_index_status", rendered) + async def test_server_info_exposes_vidxp_branding(self): with TemporaryDirectory() as directory: server = create_mcp_server( From 8da5d55e27b465dc3b82d818d07ab85dc0db3361 Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 30 Jul 2026 02:30:26 +0500 Subject: [PATCH 33/46] feat(artifacts): expose clip downloads across interfaces --- README.md | 8 ++ docs/architecture/platform.md | 5 +- src/vidxp/api_routes/artifacts.py | 7 +- src/vidxp/api_routes/jobs.py | 7 +- src/vidxp/application_models.py | 24 ++++- src/vidxp/cli_commands/artifacts.py | 113 +++++++++++++++++++++-- src/vidxp/mcp.py | 136 +++++++++++++++++++++++++++- tests/test_api.py | 26 ++++++ tests/test_cli.py | 89 ++++++++++++++++++ tests/test_mcp.py | 79 +++++++++++++++- 10 files changed, 474 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 960d023..4a4cb60 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ vidxp actors list vidxp actors render --json vidxp artifacts snippet 30 45 --json vidxp artifacts show +vidxp artifacts download ./clip.mp4 ``` `vidxp media list` returns registered filenames and stable IDs. Search and @@ -177,6 +178,13 @@ filenames and metadata, while `vidxp index status` shows the underlying snapshot state. `vidxp index remove ` removes one item. Use separate named repositories when you need separate searchable video collections. +Search and query results include a `media_id` and evidence time range. The HTTP +API can pass those values to `POST /api/v1/jobs/snippet`, poll the returned job, +and download the completed artifact from +`GET /api/v1/artifacts/{artifact_id}/content`. MCP clients use the equivalent +`create_clip`, `get_job`, and `get_artifact_download` sequence; video bytes are +read only when the client opens the returned resource link. + Index only selected capabilities or adjust the time-based scene sampling rate: ```bash diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md index b715298..02c1182 100644 --- a/docs/architecture/platform.md +++ b/docs/architecture/platform.md @@ -810,7 +810,10 @@ links. FFmpeg output is cached by source checksum, interval and encoding profile written to a temporary key, validated, and atomically published. Actor overlays and other rendered media use the same artifact workflow. Public -commands never accept arbitrary output paths. +rendering commands never accept output paths. The separate CLI artifact-download +command may copy an already-authorized managed artifact to an explicit user +destination; API downloads remain protected responses and MCP returns a lazy +resource link instead of embedding video bytes in the tool result. ## 18. FastAPI adapter diff --git a/src/vidxp/api_routes/artifacts.py b/src/vidxp/api_routes/artifacts.py index bd36e48..1b2c3d7 100644 --- a/src/vidxp/api_routes/artifacts.py +++ b/src/vidxp/api_routes/artifacts.py @@ -48,7 +48,12 @@ def _content( "/{artifact_id}/content", response_model=None, operation_id="getArtifactContent", - summary="Download artifact content", + summary="Download a generated clip or artifact", + description=( + "Stream the generated artifact with attachment, byte-range, and ETag " + "support. Use the artifact_id returned by a completed snippet or " + "actor-overlay job." + ), ) def get_artifact_content( artifact_id: ArtifactId, diff --git a/src/vidxp/api_routes/jobs.py b/src/vidxp/api_routes/jobs.py index 6bfb0dd..72e85c6 100644 --- a/src/vidxp/api_routes/jobs.py +++ b/src/vidxp/api_routes/jobs.py @@ -135,7 +135,12 @@ def submit_query( response_model=Job, status_code=202, operation_id="createSnippet", - summary="Create a snippet", + summary="Create a downloadable video clip", + description=( + "Create a durable clip-rendering job from a media ID and time range " + "returned by search or query. Poll the job, then download the " + "resulting artifact through GET /api/v1/artifacts/{artifact_id}/content." + ), dependencies=[Depends(write_principal)], ) def submit_snippet( diff --git a/src/vidxp/application_models.py b/src/vidxp/application_models.py index 76a576c..9975e3a 100644 --- a/src/vidxp/application_models.py +++ b/src/vidxp/application_models.py @@ -482,10 +482,26 @@ class SnippetProfile(StrEnum): class CreateSnippetCommand(ApplicationModel): - media_id: MediaId - start_seconds: float = Field(ge=0) - end_seconds: float = Field(gt=0) - profile: SnippetProfile = SnippetProfile.compatible_mp4 + media_id: MediaId = Field( + description=( + "Source video ID, normally copied from a search or query result." + ) + ) + start_seconds: float = Field( + ge=0, + description="Inclusive clip start from the selected result, in seconds.", + ) + end_seconds: float = Field( + gt=0, + description="Exclusive clip end from the selected result, in seconds.", + ) + profile: SnippetProfile = Field( + default=SnippetProfile.compatible_mp4, + description=( + "Use compatible_mp4 for a broadly playable download; source " + "preserves source codecs in a Matroska container." + ), + ) def model_post_init(self, _context: Any) -> None: if self.end_seconds <= self.start_seconds: diff --git a/src/vidxp/cli_commands/artifacts.py b/src/vidxp/cli_commands/artifacts.py index 6d23539..6e38f81 100644 --- a/src/vidxp/cli_commands/artifacts.py +++ b/src/vidxp/cli_commands/artifacts.py @@ -1,10 +1,17 @@ from __future__ import annotations +import shutil +from pathlib import Path from typing import Annotated +from uuid import uuid4 import typer -from vidxp.application_models import CreateSnippetCommand, SnippetProfile +from vidxp.application_models import ( + ArtifactJobResult, + CreateSnippetCommand, + SnippetProfile, +) from vidxp.cli_support import ( OutputFormat, effective_output_format, @@ -82,14 +89,27 @@ def create_snippet( if output_format == OutputFormat.json: emit_json(payload) else: - typer.secho( - ( - f"Snippet job queued: {job.job_id}" - if detach - else f"Snippet artifact job completed: {job.job_id}" - ), - fg=typer.colors.GREEN, - ) + if detach: + typer.secho( + f"Snippet job queued: {job.job_id}", + fg=typer.colors.GREEN, + ) + elif isinstance(job.result, ArtifactJobResult): + artifact = job.result.result + typer.secho( + f"Clip ready: {artifact.artifact_id} " + f"({artifact.byte_size:,} bytes)", + fg=typer.colors.GREEN, + ) + typer.echo( + "Download it with: " + f"vidxp artifacts download {artifact.artifact_id}" + ) + else: + typer.secho( + f"Snippet artifact job completed: {job.job_id}", + fg=typer.colors.GREEN, + ) @app.command("show") @@ -116,3 +136,78 @@ def show_artifact( f"{artifact.kind.value} {artifact.artifact_id} " f"({artifact.byte_size:,} bytes)" ) + + +@app.command("download") +def download_artifact( + ctx: typer.Context, + artifact_id: Annotated[ + str, + typer.Argument(help="Artifact identifier from a completed job."), + ], + destination: Annotated[ + Path | None, + typer.Argument( + help=( + "Output file or existing directory. Defaults to the generated " + "artifact filename in the current directory." + ), + ), + ] = None, + force: Annotated[ + bool, + typer.Option( + "--force", + help="Replace an existing output file.", + ), + ] = False, + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """Download a generated clip or artifact to a local file.""" + + state = state_from_context(ctx) + resource = state.service.open_artifact_content(artifact_id) + output = destination or Path(resource.filename) + if output.is_dir(): + output = output / resource.filename + output = output.expanduser().resolve() + if not output.parent.is_dir(): + raise typer.BadParameter( + "The output directory does not exist.", + param_hint="destination", + ) + if output.exists() and not force: + raise typer.BadParameter( + "The output file already exists; pass --force to replace it.", + param_hint="destination", + ) + + temporary = output.with_name(f".{output.name}.{uuid4().hex}.tmp") + try: + shutil.copyfile(resource.path, temporary) + if output.exists() and not force: + raise typer.BadParameter( + "The output file already exists; pass --force to replace it.", + param_hint="destination", + ) + temporary.replace(output) + finally: + temporary.unlink(missing_ok=True) + + payload = { + "artifact_id": artifact_id, + "path": str(output), + "mime_type": resource.mime_type, + "byte_size": resource.byte_size, + "sha256": resource.etag, + } + if effective_output_format(state, json_output) == OutputFormat.json: + emit_json(payload) + else: + typer.secho( + f"Downloaded {resource.byte_size:,} bytes to {output}", + fg=typer.colors.GREEN, + ) diff --git a/src/vidxp/mcp.py b/src/vidxp/mcp.py index 815075a..e9a297f 100644 --- a/src/vidxp/mcp.py +++ b/src/vidxp/mcp.py @@ -16,7 +16,7 @@ from mcp.server.transport_security import TransportSecurityMiddleware from mcp.shared.exceptions import MCPError from mcp.server.mcpserver.exceptions import ToolError -from mcp.types import Icon, ToolAnnotations +from mcp.types import Icon, ResourceLink, ToolAnnotations from pydantic import Field from starlette.requests import Request from starlette.types import ASGIApp, Receive, Scope, Send @@ -26,6 +26,7 @@ ApplicationError, CapabilityInfo, CapabilityList, + CreateSnippetCommand, CreateIndexCommand, ErrorCategory, Identifier, @@ -57,6 +58,7 @@ ) from vidxp.composition import ControlPlaneContext, HttpApplicationContext from vidxp.idempotency import IdempotencyKey, scoped_job_id +from vidxp.core.identifiers import ArtifactId from vidxp.settings import HttpAuthMode @@ -351,13 +353,71 @@ def create_mcp_server( "media included in the active index snapshot. For search_moments " "and query_video, provide command.media_id to restrict work to one " "video, or omit it to search/query across every media item in that " - "snapshot. Use list_jobs to recover job IDs across agent sessions." + "snapshot. To deliver a matching time range, submit create_clip, " + "poll get_job, then call get_artifact_download with the completed " + "job's artifact_id. Use list_jobs to recover job IDs across agent " + "sessions." ), version=__version__, token_verifier=token_verifier, auth=auth, ) + async def artifact_bytes( + artifact_id: ArtifactId, + *, + expected_mime_type: str, + ) -> bytes: + resource = await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=lambda _actor: ( + context.application.open_artifact_content(artifact_id) + ), + ) + if resource.mime_type != expected_mime_type: + raise MCPError( + -32602, + "The artifact URI does not match its media type.", + { + "code": "artifact_media_type_mismatch", + "category": "validation", + "retryable": False, + }, + ) + return await anyio.to_thread.run_sync(resource.path.read_bytes) + + @server.resource( + "vidxp://artifacts/{artifact_id}/content.mp4", + name="vidxp_artifact_mp4", + title="VidXP MP4 artifact", + description=( + "Binary content for a generated VidXP clip or video artifact." + ), + mime_type="video/mp4", + ) + async def read_mp4_artifact(artifact_id: ArtifactId) -> bytes: + return await artifact_bytes( + artifact_id, + expected_mime_type="video/mp4", + ) + + @server.resource( + "vidxp://artifacts/{artifact_id}/content.mkv", + name="vidxp_artifact_matroska", + title="VidXP Matroska artifact", + description=( + "Binary content for a source-profile VidXP clip artifact." + ), + mime_type="video/x-matroska", + ) + async def read_matroska_artifact(artifact_id: ArtifactId) -> bytes: + return await artifact_bytes( + artifact_id, + expected_mime_type="video/x-matroska", + ) + @server.tool( description="List installed VidXP capabilities.", annotations=_READ_ONLY, @@ -583,6 +643,78 @@ def submit(actor: Principal) -> Job: operation=submit, ) + @server.tool( + description=( + "Create a downloadable clip from a media ID and time range returned " + "by search_moments or query_video. Poll get_job, then pass the " + "completed result's artifact_id to get_artifact_download." + ), + annotations=_SUBMIT, + structured_output=True, + ) + async def create_clip( + command: CreateSnippetCommand, + idempotency_key: IdempotencyKey, + ) -> Job: + def submit(actor: Principal) -> Job: + return context.jobs.submit_snippet( + command, + job_id=scoped_job_id( + principal=actor, + transport="mcp", + operation="snippet", + idempotency_key=idempotency_key, + ), + ) + + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.write, + operation=submit, + ) + + @server.tool( + description=( + "Return a readable MCP resource link for a completed clip or video " + "artifact. The artifact_id is in the completed create_clip job " + "result; clients read the link only when they need the video bytes." + ), + annotations=_READ_ONLY, + structured_output=True, + ) + async def get_artifact_download( + artifact_id: ArtifactId, + ) -> ResourceLink: + artifact = await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=lambda _actor: ( + context.application.get_artifact(artifact_id) + ), + ) + suffix = ( + "mp4" + if artifact.mime_type == "video/mp4" + else "mkv" + ) + filename = f"{artifact.kind.value}-{artifact.artifact_id}.{suffix}" + return ResourceLink( + name=filename, + title=f"VidXP {artifact.kind.value.replace('_', ' ')}", + uri=( + f"vidxp://artifacts/{artifact.artifact_id}/" + f"content.{suffix}" + ), + description=( + f"Generated from media {artifact.media_id}; " + f"{artifact.byte_size:,} bytes." + ), + mimeType=artifact.mime_type, + size=artifact.byte_size, + ) + @server.tool( description="List durable jobs so work can be recovered across sessions.", annotations=_READ_ONLY, diff --git a/tests/test_api.py b/tests/test_api.py index f1bed41..b862c1e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -49,6 +49,7 @@ MEDIA_ID = "123456781234423481234567890abcde" JOB_ID = "223456781234423481234567890abcde" IDEMPOTENCY_KEY = "323456781234423481234567890abcde" +ARTIFACT_ID = "423456781234423481234567890abcde" TOKEN = "a" * 32 @@ -668,6 +669,15 @@ def test_file_delivery_uses_starlette_range_and_strong_etag(self): etag="1" * 64, ) ) + context.application.open_artifact_content.return_value = ( + LocalFileResource( + path=content, + filename="snippet.mp4", + mime_type="video/mp4", + byte_size=10, + etag="1" * 64, + ) + ) with TestClient(create_app(context=context)) as client: ranged = client.get( f"/api/v1/media/{MEDIA_ID}/content", @@ -677,6 +687,10 @@ def test_file_delivery_uses_starlette_range_and_strong_etag(self): f"/api/v1/media/{MEDIA_ID}/content", headers={"If-None-Match": f'"{"1" * 64}"'}, ) + artifact = client.get( + f"/api/v1/artifacts/{ARTIFACT_ID}/content", + headers={"Range": "bytes=6-9"}, + ) self.assertEqual(ranged.status_code, 206) self.assertEqual(ranged.content, b"2345") @@ -684,6 +698,12 @@ def test_file_delivery_uses_starlette_range_and_strong_etag(self): self.assertEqual(ranged.headers["etag"], f'"{"1" * 64}"') self.assertEqual(cached.status_code, 304) self.assertEqual(cached.content, b"") + self.assertEqual(artifact.status_code, 206) + self.assertEqual(artifact.content, b"6789") + self.assertIn( + "snippet.mp4", + artifact.headers["content-disposition"], + ) def test_openapi_is_curated_and_has_unique_operation_ids(self): with TemporaryDirectory() as directory: @@ -710,6 +730,12 @@ def test_openapi_is_curated_and_has_unique_operation_ids(self): upload_schema = schema["paths"]["/api/v1/media"]["post"] self.assertIn("multipart/form-data", upload_schema["requestBody"]["content"]) self.assertNotIn('"format": "path"', str(upload_schema)) + clip_schema = schema["paths"]["/api/v1/jobs/snippet"]["post"] + self.assertIn("downloadable", clip_schema["summary"].lower()) + artifact_schema = schema["paths"][ + "/api/v1/artifacts/{artifact_id}/content" + ]["get"] + self.assertIn("download", artifact_schema["summary"].lower()) def test_openapi_declares_bearer_security_without_securing_health(self): with TemporaryDirectory() as directory: diff --git a/tests/test_cli.py b/tests/test_cli.py index 9fd23ca..5c3f45b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,6 +13,8 @@ from vidxp.composition import LocalApplicationContext, settings_for_repository from vidxp.settings import ApplicationMode from vidxp.application_models import ( + Artifact, + ArtifactJobResult, CapabilityDependencyCheck, CreateIndexCommand, DependencyCheckResult, @@ -40,16 +42,19 @@ SearchJobResult, SearchMomentsPlanStep, ) +from vidxp.core.artifacts import ArtifactKind, ArtifactState from vidxp.core.media import MediaState, MediaStream from vidxp.capabilities.registry import create_capability_registry from vidxp.capability_service import CapabilityService from vidxp.repositories import RepositoryConfig, RepositoryRegistry +from vidxp.ports import LocalFileResource MEDIA_ID = "123456781234423481234567890abcde" GENERATION_ID = "223456781234423481234567890abcde" SNAPSHOT_ID = "323456781234423481234567890abcde" JOB_ID = "423456781234423481234567890abcde" +ARTIFACT_ID = "523456781234423481234567890abcde" class CliTests(unittest.TestCase): @@ -174,6 +179,90 @@ def test_snippet_rejects_an_inverted_time_range_before_submission(self): self.assertIn("start.", result.output) self.jobs.submit_snippet.assert_not_called() + def test_snippet_completion_points_to_the_download_command(self): + artifact = Artifact( + artifact_id=ARTIFACT_ID, + media_id=MEDIA_ID, + kind=ArtifactKind.snippet, + profile="compatible_mp4", + mime_type="video/mp4", + byte_size=12, + sha256="1" * 64, + state=ArtifactState.ready, + created_at=datetime.now(timezone.utc), + ) + self.jobs.submit_snippet.return_value = Job( + job_id=JOB_ID, + kind=JobKind.snippet, + state=JobState.queued, + queue=JobQueue.cpu, + ) + self.jobs.wait.return_value = Job( + job_id=JOB_ID, + kind=JobKind.snippet, + state=JobState.succeeded, + queue=JobQueue.cpu, + result=ArtifactJobResult( + kind=JobKind.snippet, + result=artifact, + ), + ) + + result = self.invoke( + ["artifacts", "snippet", MEDIA_ID, "9", "17"] + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn(f"Clip ready: {ARTIFACT_ID}", result.output) + self.assertIn( + f"vidxp artifacts download {ARTIFACT_ID}", + result.output, + ) + + def test_artifact_download_copies_authorized_content(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "managed.mp4" + source.write_bytes(b"clip-content") + destination = root / "exported.mp4" + self.service.open_artifact_content.return_value = ( + LocalFileResource( + path=source, + filename=f"snippet-{ARTIFACT_ID}.mp4", + mime_type="video/mp4", + byte_size=12, + etag="1" * 64, + ) + ) + + result = self.invoke( + [ + "artifacts", + "download", + ARTIFACT_ID, + str(destination), + "--json", + ] + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(destination.read_bytes(), b"clip-content") + payload = json.loads(result.output) + self.assertEqual(payload["artifact_id"], ARTIFACT_ID) + self.assertEqual(payload["path"], str(destination.resolve())) + + refused = self.invoke( + [ + "artifacts", + "download", + ARTIFACT_ID, + str(destination), + ] + ) + + self.assertEqual(refused.exit_code, 2, refused.output) + self.assertIn("already exists", refused.output) + def test_search_constructs_shared_command(self): search_result = FusedSearchResult( query_id="fused:1", diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 970bee3..eda1d88 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -6,6 +6,7 @@ import socket import sys import unittest +from datetime import datetime, timezone from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import Mock @@ -17,9 +18,11 @@ from mcp.client.stdio import StdioServerParameters, stdio_client from mcp.client.streamable_http import streamable_http_client from mcp.shared.exceptions import MCPError +from mcp.types import ResourceLink from vidxp.application_models import ( ApplicationError, + Artifact, ErrorCategory, IndexStatus, Job, @@ -46,15 +49,18 @@ ) from vidxp.composition import HttpApplicationContext from vidxp.control_plane import ControlPlaneApplication +from vidxp.core.artifacts import ArtifactKind, ArtifactState from vidxp.job_service import JobService from vidxp.mcp import VidXPTokenVerifier, create_mcp_server from vidxp.mcp_cli import main as mcp_main from vidxp.mcp_cli import stdio_client_config +from vidxp.ports import LocalFileResource from vidxp.settings import VidXPSettings MEDIA_ID = "123456781234423481234567890abcde" JOB_ID = "223456781234423481234567890abcde" +ARTIFACT_ID = "323456781234423481234567890abcde" def queued_job() -> Job: @@ -135,6 +141,8 @@ async def test_curated_tools_have_structured_output_schemas(self): "prepare_models", "search_moments", "query_video", + "create_clip", + "get_artifact_download", "list_jobs", "get_job", "retry_job", @@ -208,7 +216,7 @@ def test_stdio_check_performs_handshake_and_tool_probe(self): rendered = output.getvalue() self.assertIn("OK VidXP MCP", rendered) self.assertIn("Index state: missing", rendered) - self.assertIn("Tools: 14", rendered) + self.assertIn("Tools: 16", rendered) self.assertIn("get_index_status", rendered) async def test_server_info_exposes_vidxp_branding(self): @@ -424,6 +432,71 @@ async def test_retry_job_uses_shared_stable_idempotency(self): calls[1].kwargs["retry_id"], ) + async def test_clip_submission_and_lazy_artifact_download(self): + with TemporaryDirectory() as directory: + root = Path(directory) + clip = root / "clip.mp4" + clip.write_bytes(b"clip-content") + context = self.context(root) + context.jobs.submit_snippet.return_value = ( + queued_job().model_copy(update={"kind": JobKind.snippet}) + ) + context.application.get_artifact.return_value = Artifact( + artifact_id=ARTIFACT_ID, + media_id=MEDIA_ID, + kind=ArtifactKind.snippet, + profile="compatible_mp4", + mime_type="video/mp4", + byte_size=12, + sha256="1" * 64, + state=ArtifactState.ready, + created_at=datetime.now(timezone.utc), + ) + context.application.open_artifact_content.return_value = ( + LocalFileResource( + path=clip, + filename=f"snippet-{ARTIFACT_ID}.mp4", + mime_type="video/mp4", + byte_size=12, + etag="1" * 64, + ) + ) + server = create_mcp_server( + context, + default_principal=Principal( + subject="agent", + scopes=frozenset({"*"}), + ), + ) + async with Client(server) as client: + submitted = await client.call_tool( + "create_clip", + { + "command": { + "media_id": MEDIA_ID, + "start_seconds": 9, + "end_seconds": 17, + }, + "idempotency_key": "agent-clip-0001", + }, + ) + linked = await client.call_tool( + "get_artifact_download", + {"artifact_id": ARTIFACT_ID}, + ) + link = linked.content[0] + downloaded = await client.read_resource(str(link.uri)) + + self.assertFalse(submitted.is_error) + submitted_command = context.jobs.submit_snippet.call_args.args[0] + self.assertEqual(submitted_command.media_id, MEDIA_ID) + self.assertEqual(submitted_command.start_seconds, 9) + self.assertEqual(submitted_command.end_seconds, 17) + self.assertIsInstance(link, ResourceLink) + self.assertEqual(link.mime_type, "video/mp4") + self.assertEqual(link.size, 12) + self.assertEqual(downloaded.contents[0].blob, "Y2xpcC1jb250ZW50") + async def test_application_errors_are_machine_readable_and_safe(self): with TemporaryDirectory() as directory: context = self.context(Path(directory)) @@ -528,6 +601,8 @@ async def test_stdio_entrypoint_serves_the_same_curated_surface(self): "prepare_models", "search_moments", "query_video", + "create_clip", + "get_artifact_download", "list_jobs", "get_job", "retry_job", @@ -585,7 +660,7 @@ async def test_streamable_http_works_with_the_official_remote_client(self): server.should_exit = True await serving - self.assertEqual(len(discovered.tools), 14) + self.assertEqual(len(discovered.tools), 16) self.assertEqual(result.structured_content, {"items": []}) async def test_oidc_verifier_projects_the_shared_validated_token(self): From 41c6b4f4bdb852c9366afb536866aff120c57ec7 Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 30 Jul 2026 03:03:41 +0500 Subject: [PATCH 34/46] feat(runtime): add guided ffmpeg initialization --- INSTALLATION_GUIDE.md | 43 +++- README.md | 27 +- changes/media-runtime-init.feature.md | 1 + desktop/runtime-manifest.json | 2 +- desktop/src-tauri/src/lib.rs | 346 +++++++++++++++++++++++++- desktop/web/app.js | 43 +++- desktop/web/index.html | 6 +- docs/desktop.md | 21 +- src/vidxp/application_boundary.py | 8 +- src/vidxp/cli.py | 5 +- src/vidxp/cli_commands/artifacts.py | 2 + src/vidxp/cli_commands/media.py | 2 + src/vidxp/cli_commands/runtime.py | 157 ++++++++++++ src/vidxp/cli_support.py | 23 +- src/vidxp/entrypoint.py | 1 + src/vidxp/media_runtime.py | 317 +++++++++++++++++++++++ src/vidxp/settings.py | 11 +- tests/test_application_boundary.py | 43 ++++ tests/test_cli.py | 161 +++++++++++- tests/test_media_runtime.py | 141 +++++++++++ tests/test_settings.py | 16 ++ 21 files changed, 1330 insertions(+), 46 deletions(-) create mode 100644 changes/media-runtime-init.feature.md create mode 100644 src/vidxp/media_runtime.py create mode 100644 tests/test_application_boundary.py create mode 100644 tests/test_media_runtime.py diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md index 7a1e14b..bcf4957 100644 --- a/INSTALLATION_GUIDE.md +++ b/INSTALLATION_GUIDE.md @@ -10,8 +10,8 @@ dependencies are never installed accidentally. - Apple Silicon macOS 14 or newer - Linux x86-64 - Windows x86-64 -- FFmpeg and ffprobe on `PATH` for video/audio processing. VidXP wheels do not - install or modify operating-system packages. +- FFmpeg, ffprobe, and the `libx264`/`aac` encoders for video/audio processing. + VidXP wheels do not install or modify operating-system packages. The actor capability uses OpenCV YuNet and SFace. It does not use `dlib`, CMake, or a local C++ compiler. @@ -22,17 +22,35 @@ complete. ## Install the lightweight CLI -Use pipx to expose `vidxp` on `PATH` while keeping its dependencies isolated: +Use uv to expose `vidxp` on `PATH` while keeping its dependencies isolated: ```bash -pipx install vidxp +uv tool install vidxp vidxp --version +vidxp init ``` This base install intentionally excludes Chroma, Torch, model providers, Streamlit, and model weights. It is the correct foundation for a future remote API client and for commands that do not execute local indexing. +`vidxp init` is independent of the local-worker stack. It checks FFmpeg and +ffprobe, verifies the required codecs, and stores their resolved absolute paths +in VidXP's per-user configuration. If they are missing, an interactive Windows +or macOS terminal can approve the exact displayed package-manager command. +Linux prints the applicable command for the user to run because elevation +belongs to the system terminal. Non-interactive input never prompts or installs +unless the caller explicitly supplies `--yes`. + +To run only this setup without keeping a tool environment: + +```bash +uvx vidxp init +``` + +`pipx install vidxp` remains supported if pipx is preferred; run `vidxp init` +afterward in the same way. + ## Install a published local worker The `local-worker` extra contains Chroma and all current CPU capability @@ -45,6 +63,7 @@ stack: ```bash pipx install "vidxp[local-worker,frontend]" +vidxp init ``` ### Linux and Windows @@ -56,6 +75,7 @@ Torch wheel into the pipx environment first, then install VidXP's local extras: pipx install vidxp pipx runpip vidxp install "torch==2.13.0+cpu" --index-url https://download.pytorch.org/whl/cpu pipx runpip vidxp install "vidxp[local-worker,frontend]" +vidxp init ``` The final command accepts the already installed `2.13.0+cpu` build because it @@ -74,6 +94,7 @@ uses PyPI's native wheel on macOS, and never uses `extra-index-url` mixing: ```bash uv sync --frozen --extra local-worker --extra frontend +uv run vidxp init uv run vidxp doctor ``` @@ -277,10 +298,16 @@ the [Coolify deployment](docs/deployment/coolify.md). ### FFmpeg is not found -Run `vidxp doctor`; it checks both executables without downloading or changing -the machine. If either check fails, run `ffmpeg -version` and `ffprobe -version` -in the same terminal, install FFmpeg with the operating-system package manager -or add its executable directory to `PATH`, then rerun the doctor command. +Run `vidxp init`. It checks both executables and the required `libx264` and +`aac` encoders, then displays the exact supported package-manager command if +anything is missing. It asks before running supported package managers and +persists verified absolute executable paths for later CLI, UI, API, and MCP +processes. + +`vidxp doctor` is always read-only. It reports the failure and points back to +`vidxp init`; it never installs or changes system packages. CI and container +images should install FFmpeg in their image definition, then run +`vidxp init --json --ffmpeg /absolute/path --ffprobe /absolute/path`. ### Linux pulls CUDA or NVIDIA packages diff --git a/README.md b/README.md index 4a4cb60..158b90b 100644 --- a/README.md +++ b/README.md @@ -81,9 +81,21 @@ Some ideas on how to use VidXP: ## Quick start VidXP supports Python 3.11 through 3.14. Media operations require the -`ffmpeg` and `ffprobe` executables on `PATH`; Python wheels intentionally do -not install operating-system packages. `vidxp doctor` reports either missing -executable before media work begins. See the +`ffmpeg` and `ffprobe` executables plus the `libx264` and `aac` encoders. +Python wheels intentionally do not install operating-system packages. Run the +guided initialization once before local media work: + +```bash +uv tool install vidxp +vidxp init +``` + +For a disposable CLI environment, use `uvx vidxp init`. The command verifies +the executables/codecs, shows the exact supported package-manager command when +installation is needed, asks before running it in an interactive terminal, and +persists the verified absolute executable paths in VidXP's per-user +configuration. It never installs without confirmation or an explicit `--yes`. +`vidxp doctor` remains read-only. See the [installation guide](INSTALLATION_GUIDE.md) for platform-specific local-worker installation, model preparation, and troubleshooting. @@ -100,6 +112,7 @@ index on Linux and Windows: ```bash uv sync --frozen --extra local-worker --extra frontend +uv run vidxp init uv run vidxp doctor ``` @@ -120,17 +133,21 @@ models: ```bash vidxp --version +vidxp init vidxp doctor vidxp prepare ``` -`doctor` does not download anything. It reports missing model artifacts and +`init` is the guided system-dependency setup command. `doctor` never installs +or downloads anything. It reports missing model artifacts and their pinned sizes, then exits unsuccessfully until the selected capabilities are prepared. `prepare` shows the missing models, maximum additional download/cache space, and cache location before requiring confirmation (`--yes` for non-interactive use). Indexing, API jobs, and MCP tools do not turn a first request into an implicit model download; model downloads happen only -through `prepare` or the model-preparation job. +through `prepare` or the model-preparation job. If FFmpeg is unavailable, local +media commands stop with an actionable `Run vidxp init` error; API and MCP +errors carry the same remediation. Local commands do not store data in the directory where they were launched. The CLI, browser UI, local MCP process, and locally launched server use the diff --git a/changes/media-runtime-init.feature.md b/changes/media-runtime-init.feature.md new file mode 100644 index 0000000..8b6fa77 --- /dev/null +++ b/changes/media-runtime-init.feature.md @@ -0,0 +1 @@ +Add an explicit `vidxp init` FFmpeg setup contract and run the same preflight before desktop Python or package installation. diff --git a/desktop/runtime-manifest.json b/desktop/runtime-manifest.json index cc0c845..c10e2ba 100644 --- a/desktop/runtime-manifest.json +++ b/desktop/runtime-manifest.json @@ -32,6 +32,6 @@ "ffmpeg", "ffprobe" ], - "reason": "Bundled FFmpeg builds remain disabled until their target-specific codec licenses and provenance are recorded." + "reason": "First-run setup verifies the system FFmpeg runtime and offers a consented package-manager command; bundling remains disabled until target-specific codec licenses and provenance are recorded." } } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 18d1959..3059ac0 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,6 +1,6 @@ use std::{ collections::{BTreeMap, BTreeSet}, - fs, + env, fs, io::Write, net::{SocketAddr, TcpListener, TcpStream}, path::{Path, PathBuf}, @@ -74,6 +74,29 @@ struct RuntimeStatus { detail: String, } +#[derive(Clone, Serialize)] +struct MediaRuntimeStatus { + ready: bool, + ffmpeg_executable: Option, + ffprobe_executable: Option, + required_encoders: Vec, + errors: Vec, + package_manager: Option, + install_command: Option, + automatic_install: bool, +} + +struct VerifiedMediaRuntime { + ffmpeg: PathBuf, + ffprobe: PathBuf, +} + +struct SystemInstallPlan { + manager: String, + command: Vec, + automatic: bool, +} + #[derive(Clone, Deserialize, Serialize)] struct ActiveRuntime { schema_version: u32, @@ -266,6 +289,221 @@ fn executable(runtime: &Path, name: &str) -> PathBuf { } } +fn executable_candidates(name: &str) -> Vec { + let requested = PathBuf::from(name); + if requested.is_absolute() || requested.components().count() > 1 { + return vec![requested]; + } + let mut directories = env::var_os("PATH") + .map(|value| env::split_paths(&value).collect::>()) + .unwrap_or_default(); + if cfg!(windows) + && let Some(local) = env::var_os("LOCALAPPDATA") + { + directories.push( + PathBuf::from(local) + .join("Microsoft") + .join("WinGet") + .join("Links"), + ); + } + if cfg!(target_os = "macos") { + directories.extend([ + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/local/bin"), + ]); + } else if !cfg!(windows) { + directories.extend([ + PathBuf::from("/usr/local/bin"), + PathBuf::from("/usr/bin"), + PathBuf::from("/snap/bin"), + ]); + } + directories + .into_iter() + .flat_map(|directory| { + let plain = directory.join(name); + if cfg!(windows) && plain.extension().is_none() { + vec![plain.with_extension("exe"), plain] + } else { + vec![plain] + } + }) + .collect() +} + +fn resolve_system_executable(name: &str) -> Option { + executable_candidates(name) + .into_iter() + .find(|candidate| candidate.is_file()) + .and_then(|candidate| fs::canonicalize(&candidate).ok().or(Some(candidate))) +} + +fn combined_output(output: &Output) -> String { + format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} + +fn required_encoder_missing(output: &str, encoder: &str) -> bool { + !output + .lines() + .flat_map(|line| line.split_whitespace()) + .any(|token| token == encoder) +} + +fn system_install_plan() -> Option { + if cfg!(windows) { + resolve_system_executable("winget")?; + return Some(SystemInstallPlan { + manager: "Windows Package Manager".into(), + command: vec![ + "winget".into(), + "install".into(), + "--id".into(), + "Gyan.FFmpeg".into(), + "--exact".into(), + "--source".into(), + "winget".into(), + "--accept-package-agreements".into(), + "--accept-source-agreements".into(), + ], + automatic: true, + }); + } + if cfg!(target_os = "macos") { + let brew = resolve_system_executable("brew")?; + return Some(SystemInstallPlan { + manager: "Homebrew".into(), + command: vec![ + brew.to_string_lossy().into_owned(), + "install".into(), + "ffmpeg".into(), + ], + automatic: true, + }); + } + if resolve_system_executable("apt-get").is_some() { + return Some(SystemInstallPlan { + manager: "APT".into(), + command: vec![ + "sudo".into(), + "apt-get".into(), + "install".into(), + "ffmpeg".into(), + ], + automatic: false, + }); + } + if resolve_system_executable("dnf").is_some() { + return Some(SystemInstallPlan { + manager: "DNF".into(), + command: vec![ + "sudo".into(), + "dnf".into(), + "install".into(), + "ffmpeg".into(), + ], + automatic: false, + }); + } + None +} + +fn display_command(arguments: &[String]) -> String { + arguments + .iter() + .map(|argument| { + if argument.contains(char::is_whitespace) { + format!("\"{}\"", argument.replace('"', "\\\"")) + } else { + argument.clone() + } + }) + .collect::>() + .join(" ") +} + +fn inspect_media_runtime() -> MediaRuntimeStatus { + let ffmpeg = resolve_system_executable("ffmpeg"); + let ffprobe = resolve_system_executable("ffprobe"); + let mut errors = Vec::new(); + if let Some(path) = &ffmpeg { + let mut version_command = Command::new(path); + version_command.arg("-version"); + let version = checked_output(version_command, "FFmpeg version check"); + if let Err(error) = version { + errors.push(error); + } else { + let mut encoder_command = Command::new(path); + encoder_command.args(["-hide_banner", "-encoders"]); + match checked_output(encoder_command, "FFmpeg encoder check") { + Ok(output) => { + let encoders = combined_output(&output); + for required in ["libx264", "aac"] { + if required_encoder_missing(&encoders, required) { + errors.push(format!( + "FFmpeg is missing the required {required} encoder." + )); + } + } + } + Err(error) => errors.push(error), + } + } + } else { + errors.push("FFmpeg was not found.".into()); + } + if let Some(path) = &ffprobe { + let mut probe_command = Command::new(path); + probe_command.arg("-version"); + if let Err(error) = checked_output(probe_command, "ffprobe version check") { + errors.push(error); + } + } else { + errors.push("ffprobe was not found.".into()); + } + let plan = if errors.is_empty() { + None + } else { + system_install_plan() + }; + MediaRuntimeStatus { + ready: errors.is_empty(), + ffmpeg_executable: ffmpeg.map(|path| path.to_string_lossy().into_owned()), + ffprobe_executable: ffprobe.map(|path| path.to_string_lossy().into_owned()), + required_encoders: vec!["libx264".into(), "aac".into()], + errors, + package_manager: plan.as_ref().map(|plan| plan.manager.clone()), + install_command: plan.as_ref().map(|plan| display_command(&plan.command)), + automatic_install: plan.is_some_and(|plan| plan.automatic), + } +} + +fn verified_media_runtime() -> Result { + let status = inspect_media_runtime(); + if !status.ready { + return Err(format!( + "{} Run the guided FFmpeg setup, then retry.", + status.errors.join(" ") + )); + } + Ok(VerifiedMediaRuntime { + ffmpeg: PathBuf::from( + status + .ffmpeg_executable + .ok_or("FFmpeg did not resolve to an absolute path.")?, + ), + ffprobe: PathBuf::from( + status + .ffprobe_executable + .ok_or("ffprobe did not resolve to an absolute path.")?, + ), + }) +} + fn clean_environment(paths: &DesktopPaths) -> Vec<(String, String)> { let mut environment: Vec<_> = std::env::vars() .filter(|(key, _)| { @@ -477,10 +715,69 @@ fn runtime_manifest() -> Result { manifest() } +#[tauri::command] +fn media_runtime_status() -> MediaRuntimeStatus { + inspect_media_runtime() +} + +#[tauri::command] +async fn install_media_runtime( + app: AppHandle, + state: tauri::State<'_, DesktopState>, +) -> Result { + let current = inspect_media_runtime(); + if current.ready { + return Ok(current); + } + if state + .operation_active + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err("Another install or model-preparation operation is active.".into()); + } + let _operation_guard = OperationGuard { + active: &state.operation_active, + }; + let plan = system_install_plan().ok_or("No supported system package manager was found.")?; + if !plan.automatic { + return Err(format!( + "Run this command in a system terminal, then retry: {}", + display_command(&plan.command) + )); + } + let command = app + .shell() + .command(plan.command[0].clone()) + .args(&plan.command[1..]); + supervised_output( + &state, + command, + &format!("{} FFmpeg installation", plan.manager), + ) + .await?; + let status = inspect_media_runtime(); + if !status.ready { + return Err(format!( + "FFmpeg installation finished but verification failed: {}", + status.errors.join(" ") + )); + } + Ok(status) +} + #[tauri::command] fn runtime_status(app: AppHandle) -> Result { let manifest = manifest()?; let paths = desktop_paths(&app)?; + if let Err(detail) = verified_media_runtime() { + return Ok(RuntimeStatus { + ready: false, + package_version: manifest.package_version, + capabilities: Vec::new(), + detail, + }); + } let active = match active_runtime(&paths) { Ok(active) => active, Err(detail) => { @@ -538,6 +835,7 @@ async fn install_runtime( }; let manifest = manifest()?; let capabilities = selected_capabilities(&manifest, &request.capabilities)?; + let media_runtime = verified_media_runtime()?; let paths = desktop_paths(&app)?; for directory in [ &paths.data, @@ -608,6 +906,23 @@ async fn install_runtime( ) .await?; + run_vidxp_supervised( + &app, + &state, + &staging, + &paths, + &[ + "init".into(), + "--json".into(), + "--ffmpeg".into(), + media_runtime.ffmpeg.to_string_lossy().into_owned(), + "--ffprobe".into(), + media_runtime.ffprobe.to_string_lossy().into_owned(), + ], + "FFmpeg configuration", + ) + .await?; + let doctor_arguments = capability_command_arguments(&manifest, "doctor", &capabilities); run_vidxp_supervised( &app, @@ -797,6 +1112,8 @@ pub fn run() { .manage(DesktopState::default()) .invoke_handler(tauri::generate_handler![ runtime_manifest, + media_runtime_status, + install_media_runtime, runtime_status, install_runtime, launch_ui @@ -826,8 +1143,9 @@ pub fn run() { mod tests { use super::{ base_package_specification, capability_command_arguments, - dependency_installation_arguments, manifest, package_acquisition_arguments, package_index, - package_specification, selected_capabilities, + dependency_installation_arguments, display_command, manifest, + package_acquisition_arguments, package_index, package_specification, + required_encoder_missing, selected_capabilities, }; use std::path::Path; @@ -893,4 +1211,26 @@ mod tests { ["doctor", "--json", "--modalities", "dialogue,scene"] ); } + + #[test] + fn ffmpeg_encoder_check_matches_complete_encoder_names() { + let encoders = " V....D libx264 H.264\n A....D aac AAC"; + + assert!(!required_encoder_missing(encoders, "libx264")); + assert!(!required_encoder_missing(encoders, "aac")); + assert!(required_encoder_missing(encoders, "libx265")); + } + + #[test] + fn package_manager_command_is_presented_as_copyable_text() { + assert_eq!( + display_command(&[ + "winget".into(), + "install".into(), + "--id".into(), + "Gyan.FFmpeg".into(), + ]), + "winget install --id Gyan.FFmpeg" + ); + } } diff --git a/desktop/web/app.js b/desktop/web/app.js index 8a9d157..1959c4e 100644 --- a/desktop/web/app.js +++ b/desktop/web/app.js @@ -55,6 +55,41 @@ async function refreshStatus() { : status.detail; } +async function ensureMediaRuntime() { + let status = await invoke("media_runtime_status"); + if (status.ready) { + return status; + } + const errors = status.errors.join(" "); + if (!status.install_command) { + throw new Error( + `${errors} Install FFmpeg and ffprobe, then click Install selected again.`, + ); + } + if (!status.automatic_install) { + throw new Error( + `${errors} Run this command in a system terminal, then retry: ${status.install_command}`, + ); + } + const approved = window.confirm( + [ + "FFmpeg and ffprobe are required before VidXP can be installed.", + "", + `Install through ${status.package_manager}?`, + `Command: ${status.install_command}`, + ].join("\n"), + ); + if (!approved) { + throw new Error("FFmpeg installation was cancelled; VidXP was not installed."); + } + statusNode.textContent = `Installing FFmpeg through ${status.package_manager}…`; + status = await invoke("install_media_runtime"); + if (!status.ready) { + throw new Error(status.errors.join(" ")); + } + return status; +} + installButton.addEventListener("click", async () => { const capabilities = selectedCapabilities(); if (capabilities.length === 0) { @@ -63,10 +98,12 @@ installButton.addEventListener("click", async () => { } setBusy(true); - statusNode.textContent = prepareModels.checked - ? "Installing and preparing models. Large downloads can take a while…" - : "Installing an isolated VidXP runtime…"; try { + statusNode.textContent = "Checking FFmpeg and required codecs…"; + await ensureMediaRuntime(); + statusNode.textContent = prepareModels.checked + ? "Installing VidXP, then preparing models. Large downloads can take a while…" + : "Installing an isolated VidXP runtime…"; const result = await invoke("install_runtime", { request: { capabilities, diff --git a/desktop/web/index.html b/desktop/web/index.html index b04a0ce..0c6be54 100644 --- a/desktop/web/index.html +++ b/desktop/web/index.html @@ -45,8 +45,10 @@

    Capabilities

    - The first setup needs internet access. FFmpeg and ffprobe must currently - be available on this machine; setup verifies both before activation. + The first setup needs internet access. Before Python or VidXP is + downloaded, setup verifies FFmpeg, ffprobe, and the required codecs. If + they are missing, VidXP shows the exact system package-manager command + and asks before running it.

    diff --git a/docs/desktop.md b/docs/desktop.md index 92e4cfd..6800351 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -30,10 +30,16 @@ cache uses the operating system-provided app-cache directory. Docker and Compose storage remains explicitly volume-backed and does not inherit this desktop layout. -The first release is an online bootstrap. It bundles `uv`, installs the exact -Python and VidXP versions in `desktop/runtime-manifest.json`, and activates a new -runtime only after `vidxp doctor` passes. A failed setup never replaces the -previous active runtime. +The first release is an online bootstrap. It bundles `uv` and performs a +system-media preflight before creating Python or downloading VidXP. If FFmpeg +is missing on Windows or macOS, setup shows the exact approved package-manager +command and asks before running it. Linux shows the applicable terminal command +without trying to automate elevation. Setup verifies ffmpeg, ffprobe, +`libx264`, and `aac`, then installs the exact Python and VidXP versions in +`desktop/runtime-manifest.json`, persists the verified absolute executable +paths through `vidxp init`, runs the full `vidxp doctor`, and only then activates +the new runtime. A failed or cancelled setup never replaces the previous active +runtime. ## Build locally @@ -79,9 +85,10 @@ the selected modalities. Setup subprocesses are owned by the Tauri supervisor; closing the app cancels the active process and stops a preparation worker before exit. No model is bundled in the installer. -`vidxp doctor` validates FFmpeg and ffprobe as system dependencies before -activating the staged runtime. The Python package and desktop bootstrap do not -install OS packages. Target binaries will not be bundled until the exact build +The native NSIS, DMG, and AppImage packages themselves never install FFmpeg or +run a package manager. That consented action belongs to first-run setup, where +errors and retries are visible. `vidxp doctor` remains a read-only validation +gate. Target FFmpeg binaries will not be bundled until their exact build provenance, enabled codecs, and redistribution licenses are recorded. This is an explicit packaging gate, not a silent download from an unaudited third party. diff --git a/src/vidxp/application_boundary.py b/src/vidxp/application_boundary.py index 2965b64..33caa4a 100644 --- a/src/vidxp/application_boundary.py +++ b/src/vidxp/application_boundary.py @@ -130,7 +130,9 @@ def wrapped(*args: Any, **kwargs: Any) -> Any: raise ApplicationError( "media_probe_unavailable", ErrorCategory.unavailable, - "The media probe dependency is unavailable.", + "The media probe dependency is unavailable. " + "For a local installation, run `vidxp init` and retry.", + details={"remediation": "vidxp init"}, retryable=True, ) from exc except MediaImportNotAllowedError as exc: @@ -165,7 +167,9 @@ def wrapped(*args: Any, **kwargs: Any) -> Any: raise ApplicationError( "artifact_renderer_unavailable", ErrorCategory.unavailable, - "The configured artifact renderer is unavailable.", + "The configured artifact renderer is unavailable. " + "For a local installation, run `vidxp init` and retry.", + details={"remediation": "vidxp init"}, retryable=True, ) from exc except ArtifactRenderError as exc: diff --git a/src/vidxp/cli.py b/src/vidxp/cli.py index 5445d49..30a0985 100644 --- a/src/vidxp/cli.py +++ b/src/vidxp/cli.py @@ -18,7 +18,7 @@ from vidxp.cli_commands.media import app as media_app from vidxp.cli_commands.artifacts import app as artifacts_app from vidxp.cli_commands.repositories import app as repositories_app -from vidxp.cli_commands.runtime import doctor, prepare, ui +from vidxp.cli_commands.runtime import doctor, initialize, prepare, ui from vidxp.cli_commands.search import search from vidxp.cli_commands.query import query from vidxp.cli_support import CLIState, OutputFormat @@ -42,6 +42,7 @@ app.add_typer(benchmark_app, name="benchmark") app.command()(doctor) +app.command("init")(initialize) app.command()(prepare) app.command()(ui) @@ -120,7 +121,7 @@ def app_options( typer.Option("--quiet", "-q", help="Suppress progress output."), ] = False, ) -> None: - if ctx.invoked_subcommand == "mcp-config": + if ctx.invoked_subcommand in {"init", "mcp-config"}: return local = create_local_application( registry_path=config_file, diff --git a/src/vidxp/cli_commands/artifacts.py b/src/vidxp/cli_commands/artifacts.py index 6e38f81..8d590ff 100644 --- a/src/vidxp/cli_commands/artifacts.py +++ b/src/vidxp/cli_commands/artifacts.py @@ -18,6 +18,7 @@ emit_job_progress, emit_json, emit_progress, + require_media_runtime, state_from_context, ) @@ -63,6 +64,7 @@ def create_snippet( "The snippet end must be greater than its start.", param_hint="end_seconds", ) + require_media_runtime() state = state_from_context(ctx) output_format = effective_output_format(state, json_output) show_progress = ( diff --git a/src/vidxp/cli_commands/media.py b/src/vidxp/cli_commands/media.py index a30a6f0..83f2746 100644 --- a/src/vidxp/cli_commands/media.py +++ b/src/vidxp/cli_commands/media.py @@ -13,6 +13,7 @@ effective_output_format, emit_json, emit_progress, + require_media_runtime, state_from_context, ) @@ -40,6 +41,7 @@ def import_media( ) -> None: """Validate and register a local video, returning its stable media ID.""" + require_media_runtime() state = state_from_context(ctx) output_format = effective_output_format(state, json_output) if not state.quiet and output_format == OutputFormat.rich: diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py index 374fa59..f5992bc 100644 --- a/src/vidxp/cli_commands/runtime.py +++ b/src/vidxp/cli_commands/runtime.py @@ -1,5 +1,7 @@ from __future__ import annotations +import sys +from pathlib import Path from typing import Annotated import typer @@ -21,8 +23,16 @@ emit_progress, parse_capability_options, parse_modalities, + require_media_runtime, state_from_context, ) +from vidxp.media_runtime import ( + MediaRuntimeStatus, + inspect_media_runtime, + install_media_runtime, + media_runtime_config_path, + save_media_runtime_configuration, +) def _format_bytes(size: int) -> str: @@ -33,6 +43,142 @@ def _format_bytes(size: int) -> str: return f"{size / mib:.1f} MiB" +def _media_runtime_payload(status: MediaRuntimeStatus) -> dict: + payload = status.model_dump(mode="json") + if status.install_plan is not None: + payload["install_command"] = status.install_plan.display_command + else: + payload["install_command"] = None + return payload + + +def initialize( + ctx: typer.Context, + ffmpeg: Annotated[ + Path | None, + typer.Option( + exists=True, + dir_okay=False, + resolve_path=True, + help="Use this FFmpeg executable instead of searching PATH.", + ), + ] = None, + ffprobe: Annotated[ + Path | None, + typer.Option( + exists=True, + dir_okay=False, + resolve_path=True, + help="Use this ffprobe executable instead of searching PATH.", + ), + ] = None, + yes: Annotated[ + bool, + typer.Option( + "--yes", + "-y", + help="Confirm the displayed system package-manager command.", + ), + ] = False, + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """Verify and save the system FFmpeg runtime used by VidXP.""" + + root_format = ctx.find_root().params.get( + "output_format", + OutputFormat.rich, + ) + output_format = ( + OutputFormat.json + if json_output or root_format == OutputFormat.json + else OutputFormat.rich + ) + status = inspect_media_runtime(ffmpeg=ffmpeg, ffprobe=ffprobe) + if not status.ready: + payload = _media_runtime_payload(status) + if output_format == OutputFormat.rich: + typer.secho( + "FFmpeg and ffprobe are not ready.", + fg=typer.colors.YELLOW, + bold=True, + ) + for error in status.errors: + typer.echo(f" {error}") + if status.install_plan is not None: + typer.echo( + f"Package manager: {status.install_plan.manager}" + ) + typer.secho( + f"Command: {status.install_plan.display_command}", + fg=typer.colors.YELLOW, + ) + plan = status.install_plan + if plan is None or not plan.automatic: + if output_format == OutputFormat.json: + emit_json(payload) + elif plan is None: + typer.echo( + "Install FFmpeg and ffprobe with your operating-system " + "package manager, or pass explicit --ffmpeg and " + "--ffprobe paths, then rerun `vidxp init`." + ) + else: + typer.echo( + "Install FFmpeg with the command above, or pass explicit " + "--ffmpeg and --ffprobe paths, then rerun `vidxp init`." + ) + raise typer.Exit(1) + interactive = sys.stdin.isatty() and sys.stdout.isatty() + if not yes: + if not interactive: + if output_format == OutputFormat.json: + emit_json(payload) + else: + typer.echo( + "No installation was started. Rerun interactively or " + "pass --yes after reviewing the command." + ) + raise typer.Exit(1) + typer.confirm( + f"Install through {plan.manager} using the displayed command?", + abort=True, + ) + if output_format == OutputFormat.rich: + emit_progress(f"Installing FFmpeg through {plan.manager}...") + install_media_runtime( + plan, + output_to_stderr=output_format == OutputFormat.json, + ) + status = inspect_media_runtime(ffmpeg=ffmpeg, ffprobe=ffprobe) + if not status.ready: + if output_format == OutputFormat.json: + emit_json(_media_runtime_payload(status)) + else: + for error in status.errors: + typer.secho(error, fg=typer.colors.RED) + raise typer.Exit(1) + + configuration = save_media_runtime_configuration(status) + payload = { + **_media_runtime_payload(status), + "initialized": True, + "configuration": str(media_runtime_config_path()), + } + if output_format == OutputFormat.json: + emit_json(payload) + else: + typer.secho( + "VidXP media runtime is initialized.", + fg=typer.colors.GREEN, + bold=True, + ) + typer.echo(f"FFmpeg: {configuration.ffmpeg_executable}") + typer.echo(f"ffprobe: {configuration.ffprobe_executable}") + + def doctor( ctx: typer.Context, modalities: Annotated[ @@ -160,6 +306,16 @@ def show_check_complete( + " ".join(external_distributions), fg=typer.colors.YELLOW, ) + media_failures = tuple( + check + for check in result.checks + if not check.ok and check.capability == "media" + ) + if media_failures: + typer.secho( + "REMEDY: Run `vidxp init` to verify and configure FFmpeg.", + fg=typer.colors.YELLOW, + ) missing_model_checks = tuple( check for check in result.checks @@ -361,6 +517,7 @@ def ui( ) -> None: """Launch Streamlit with the selected repository configuration.""" + require_media_runtime() state = state_from_context(ctx) show_progress = ( not state.quiet and state.output_format == OutputFormat.rich diff --git a/src/vidxp/cli_support.py b/src/vidxp/cli_support.py index 0dc215d..934c3b5 100644 --- a/src/vidxp/cli_support.py +++ b/src/vidxp/cli_support.py @@ -17,7 +17,13 @@ ) from rich.table import Table -from vidxp.application_models import FusedSearchResult, QueryAnswer +from vidxp.application_models import ( + ApplicationError, + ErrorCategory, + FusedSearchResult, + QueryAnswer, +) +from vidxp.media_runtime import media_runtime_is_initialized from vidxp.repositories import RepositoryConfig, RepositoryRegistry from typing import TYPE_CHECKING @@ -61,6 +67,21 @@ def state_from_context(ctx: typer.Context) -> CLIState: return state +def require_media_runtime() -> None: + if media_runtime_is_initialized(): + return + raise ApplicationError( + "media_runtime_uninitialized", + ErrorCategory.unavailable, + "FFmpeg and ffprobe are not initialized for local media work. " + "Run `vidxp init`, then retry this command.", + details={ + "remediation": "vidxp init", + "install_hint": "Run vidxp init", + }, + ) + + def effective_output_format( state: CLIState, json_output: bool, diff --git a/src/vidxp/entrypoint.py b/src/vidxp/entrypoint.py index 9906ca7..8d8b3c1 100644 --- a/src/vidxp/entrypoint.py +++ b/src/vidxp/entrypoint.py @@ -52,6 +52,7 @@ def startup_command(arguments: list[str]) -> str | None: if path and path[0] in { "benchmark", "doctor", + "init", "prepare", "query", "search", diff --git a/src/vidxp/media_runtime.py b/src/vidxp/media_runtime.py new file mode 100644 index 0000000..0fad17a --- /dev/null +++ b/src/vidxp/media_runtime.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Literal +from uuid import uuid4 + +from pydantic import BaseModel, ConfigDict, Field + +from vidxp.app_paths import default_config_directory + + +MEDIA_RUNTIME_SCHEMA_VERSION = 1 +MEDIA_RUNTIME_FILENAME = "media-runtime.json" +REQUIRED_FFMPEG_ENCODERS = ("libx264", "aac") + + +class MediaRuntimeModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class MediaRuntimeConfiguration(MediaRuntimeModel): + schema_version: Literal[MEDIA_RUNTIME_SCHEMA_VERSION] = ( + MEDIA_RUNTIME_SCHEMA_VERSION + ) + ffmpeg_executable: Path + ffprobe_executable: Path + + +class SystemInstallPlan(MediaRuntimeModel): + manager: str = Field(min_length=1) + command: tuple[str, ...] = Field(min_length=1) + automatic: bool + + @property + def display_command(self) -> str: + if sys.platform == "win32": + return subprocess.list2cmdline(self.command) + return shlex.join(self.command) + + +class MediaRuntimeStatus(MediaRuntimeModel): + ready: bool + initialized: bool + ffmpeg_executable: Path | None = None + ffprobe_executable: Path | None = None + required_encoders: tuple[str, ...] = REQUIRED_FFMPEG_ENCODERS + errors: tuple[str, ...] = () + install_plan: SystemInstallPlan | None = None + + +def media_runtime_config_path( + config_directory: Path | None = None, +) -> Path: + return (config_directory or default_config_directory()) / ( + MEDIA_RUNTIME_FILENAME + ) + + +def load_media_runtime_configuration( + config_directory: Path | None = None, +) -> MediaRuntimeConfiguration | None: + path = media_runtime_config_path(config_directory) + try: + contents = path.read_text(encoding="utf-8") + except FileNotFoundError: + return None + return MediaRuntimeConfiguration.model_validate_json(contents) + + +def default_media_executable(name: Literal["ffmpeg", "ffprobe"]) -> str: + try: + configuration = load_media_runtime_configuration() + except (OSError, ValueError): + return name + if configuration is None: + return name + return str(getattr(configuration, f"{name}_executable")) + + +def _resolve_executable(value: str | Path) -> Path | None: + candidate = Path(value).expanduser() + if candidate.is_absolute() or candidate.parent != Path("."): + return candidate.resolve() if candidate.is_file() else None + resolved = shutil.which(str(value)) + return Path(resolved).resolve() if resolved else None + + +def _command_output(arguments: list[str], *, timeout: float = 15) -> str: + completed = subprocess.run( + arguments, + check=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=timeout, + ) + return completed.stdout.decode(errors="replace") + + +def _verify_ffmpeg(path: Path) -> tuple[str, ...]: + errors = [] + try: + _command_output([str(path), "-version"]) + encoders = _command_output([str(path), "-hide_banner", "-encoders"]) + except (OSError, subprocess.SubprocessError) as exc: + return (f"FFmpeg could not be executed: {exc}",) + available = { + token + for line in encoders.splitlines() + for token in line.split() + } + missing = tuple( + encoder + for encoder in REQUIRED_FFMPEG_ENCODERS + if encoder not in available + ) + if missing: + errors.append( + "FFmpeg is missing required encoder(s): " + ", ".join(missing) + ) + return tuple(errors) + + +def _verify_ffprobe(path: Path) -> tuple[str, ...]: + try: + _command_output([str(path), "-version"]) + except (OSError, subprocess.SubprocessError) as exc: + return (f"ffprobe could not be executed: {exc}",) + return () + + +def system_install_plan() -> SystemInstallPlan | None: + if sys.platform == "win32": + if shutil.which("winget"): + return SystemInstallPlan( + manager="Windows Package Manager", + command=( + "winget", + "install", + "--id", + "Gyan.FFmpeg", + "--exact", + "--source", + "winget", + "--accept-package-agreements", + "--accept-source-agreements", + ), + automatic=True, + ) + return None + if sys.platform == "darwin": + brew = shutil.which("brew") + if brew is None: + for candidate in ( + Path("/opt/homebrew/bin/brew"), + Path("/usr/local/bin/brew"), + ): + if candidate.is_file(): + brew = str(candidate) + break + if brew: + return SystemInstallPlan( + manager="Homebrew", + command=(brew, "install", "ffmpeg"), + automatic=True, + ) + return None + if shutil.which("apt-get"): + return SystemInstallPlan( + manager="APT", + command=("sudo", "apt-get", "install", "ffmpeg"), + automatic=False, + ) + if shutil.which("dnf"): + return SystemInstallPlan( + manager="DNF", + command=("sudo", "dnf", "install", "ffmpeg"), + automatic=False, + ) + if shutil.which("pacman"): + return SystemInstallPlan( + manager="pacman", + command=("sudo", "pacman", "-S", "ffmpeg"), + automatic=False, + ) + return None + + +def inspect_media_runtime( + *, + ffmpeg: str | Path | None = None, + ffprobe: str | Path | None = None, + config_directory: Path | None = None, +) -> MediaRuntimeStatus: + configuration = None + configuration_error = None + try: + configuration = load_media_runtime_configuration(config_directory) + except (OSError, ValueError) as exc: + configuration_error = f"The saved media runtime is invalid: {exc}" + + ffmpeg_value = ( + ffmpeg + or ( + configuration.ffmpeg_executable + if configuration is not None + else "ffmpeg" + ) + ) + ffprobe_value = ( + ffprobe + or ( + configuration.ffprobe_executable + if configuration is not None + else "ffprobe" + ) + ) + ffmpeg_path = _resolve_executable(ffmpeg_value) + ffprobe_path = _resolve_executable(ffprobe_value) + errors = [] + if configuration_error: + errors.append(configuration_error) + if ffmpeg_path is None: + errors.append(f"FFmpeg was not found: {ffmpeg_value}") + else: + errors.extend(_verify_ffmpeg(ffmpeg_path)) + if ffprobe_path is None: + errors.append(f"ffprobe was not found: {ffprobe_value}") + else: + errors.extend(_verify_ffprobe(ffprobe_path)) + return MediaRuntimeStatus( + ready=not errors, + initialized=configuration is not None, + ffmpeg_executable=ffmpeg_path, + ffprobe_executable=ffprobe_path, + errors=tuple(errors), + install_plan=system_install_plan() if errors else None, + ) + + +def save_media_runtime_configuration( + status: MediaRuntimeStatus, + *, + config_directory: Path | None = None, +) -> MediaRuntimeConfiguration: + if ( + not status.ready + or status.ffmpeg_executable is None + or status.ffprobe_executable is None + ): + raise ValueError("Only a verified media runtime can be saved.") + configuration = MediaRuntimeConfiguration( + ffmpeg_executable=status.ffmpeg_executable, + ffprobe_executable=status.ffprobe_executable, + ) + destination = media_runtime_config_path(config_directory) + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_name( + f".{destination.name}.{uuid4().hex}.tmp" + ) + try: + temporary.write_text( + configuration.model_dump_json(indent=2), + encoding="utf-8", + ) + temporary.replace(destination) + finally: + temporary.unlink(missing_ok=True) + return configuration + + +def install_media_runtime( + plan: SystemInstallPlan, + *, + output_to_stderr: bool = False, +) -> None: + if not plan.automatic: + raise ValueError( + "This package-manager command must be run in a system terminal." + ) + destination = sys.stderr if output_to_stderr else None + subprocess.run( + list(plan.command), + check=True, + stdout=destination, + stderr=destination, + ) + + +def explicitly_configured_by_environment() -> bool: + return bool( + os.environ.get("VIDXP_FFMPEG_EXECUTABLE") + and os.environ.get("VIDXP_FFPROBE_EXECUTABLE") + ) + + +def media_runtime_is_initialized( + config_directory: Path | None = None, +) -> bool: + if explicitly_configured_by_environment(): + return True + try: + configuration = load_media_runtime_configuration(config_directory) + except (OSError, ValueError): + return False + if bool( + configuration is not None + and configuration.ffmpeg_executable.is_file() + and configuration.ffprobe_executable.is_file() + ): + return True + return bool(shutil.which("ffmpeg") and shutil.which("ffprobe")) diff --git a/src/vidxp/settings.py b/src/vidxp/settings.py index a331a29..18a2f1c 100644 --- a/src/vidxp/settings.py +++ b/src/vidxp/settings.py @@ -22,6 +22,7 @@ default_model_directory, default_repository_directory, ) +from vidxp.media_runtime import default_media_executable from vidxp.repository_layout import RepositoryLayout @@ -175,8 +176,14 @@ class VidXPSettings(BaseSettings): slm_timeout_seconds: float = Field(default=60, gt=0, le=600) slm_output_retries: int = Field(default=1, ge=0, le=3) trusted_local_import_roots: tuple[Path, ...] = () - ffprobe_executable: str = Field(default="ffprobe", min_length=1) - ffmpeg_executable: str = Field(default="ffmpeg", min_length=1) + ffprobe_executable: str = Field( + default_factory=lambda: default_media_executable("ffprobe"), + min_length=1, + ) + ffmpeg_executable: str = Field( + default_factory=lambda: default_media_executable("ffmpeg"), + min_length=1, + ) external_capabilities: bool = False capability_allowlist: tuple[str, ...] = () diff --git a/tests/test_application_boundary.py b/tests/test_application_boundary.py new file mode 100644 index 0000000..21deedb --- /dev/null +++ b/tests/test_application_boundary.py @@ -0,0 +1,43 @@ +import unittest + +from vidxp.application_boundary import application_boundary +from vidxp.application_models import ApplicationError +from vidxp.core.artifacts import ArtifactRendererUnavailableError +from vidxp.core.media import MediaProbeUnavailableError + + +class ApplicationBoundaryTests(unittest.TestCase): + def test_missing_media_probe_carries_init_remediation(self): + @application_boundary + def operation(): + raise MediaProbeUnavailableError("missing") + + with self.assertRaises(ApplicationError) as raised: + operation() + + self.assertEqual(raised.exception.code, "media_probe_unavailable") + self.assertEqual( + raised.exception.detail.details["remediation"], + "vidxp init", + ) + + def test_missing_artifact_renderer_carries_init_remediation(self): + @application_boundary + def operation(): + raise ArtifactRendererUnavailableError("missing") + + with self.assertRaises(ApplicationError) as raised: + operation() + + self.assertEqual( + raised.exception.code, + "artifact_renderer_unavailable", + ) + self.assertEqual( + raised.exception.detail.details["remediation"], + "vidxp init", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py index 5c3f45b..6e07e63 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -11,6 +11,11 @@ from vidxp import cli from vidxp.entrypoint import startup_command from vidxp.composition import LocalApplicationContext, settings_for_repository +from vidxp.media_runtime import ( + MediaRuntimeConfiguration, + MediaRuntimeStatus, + SystemInstallPlan, +) from vidxp.settings import ApplicationMode from vidxp.application_models import ( Artifact, @@ -84,17 +89,23 @@ def setUp(self): configured=False, ) - def invoke(self, arguments): - with patch.object( - cli, - "create_local_application", - return_value=LocalApplicationContext( - application=self.service, - jobs=self.jobs, - repositories=self.registry, - repository=self.repository, + def invoke(self, arguments, *, media_runtime_initialized=True): + with ( + patch.object( + cli, + "create_local_application", + return_value=LocalApplicationContext( + application=self.service, + jobs=self.jobs, + repositories=self.registry, + repository=self.repository, + ), + ) as create_local_application, + patch( + "vidxp.cli_support.media_runtime_is_initialized", + return_value=media_runtime_initialized, ), - ) as create_local_application: + ): result = self.runner.invoke(cli.app, arguments) self.create_local_application = create_local_application return result @@ -126,6 +137,7 @@ def test_grouped_commands_are_exposed(self): "search", "actors", "artifacts", + "init", "doctor", "prepare", "mcp-config", @@ -146,6 +158,7 @@ def test_mcp_config_is_copy_paste_json_without_opening_repository(self): self.assertEqual(server["args"], ["--repository", "default"]) def test_startup_notice_targets_long_interactive_commands(self): + self.assertEqual(startup_command(["init"]), "init") self.assertEqual(startup_command(["doctor"]), "doctor") self.assertEqual( startup_command(["media", "import", "video.mp4"]), @@ -159,6 +172,134 @@ def test_startup_notice_targets_long_interactive_commands(self): self.assertIsNone(startup_command(["--quiet", "prepare"])) self.assertIsNone(startup_command(["repositories", "list"])) + def test_init_saves_verified_paths_without_opening_a_repository(self): + ffmpeg = Path("tools/ffmpeg.exe").resolve() + ffprobe = Path("tools/ffprobe.exe").resolve() + status = MediaRuntimeStatus( + ready=True, + initialized=False, + ffmpeg_executable=ffmpeg, + ffprobe_executable=ffprobe, + ) + configuration = MediaRuntimeConfiguration( + ffmpeg_executable=ffmpeg, + ffprobe_executable=ffprobe, + ) + with ( + patch( + "vidxp.cli_commands.runtime.inspect_media_runtime", + return_value=status, + ), + patch( + "vidxp.cli_commands.runtime.save_media_runtime_configuration", + return_value=configuration, + ) as save, + patch( + "vidxp.cli_commands.runtime.media_runtime_config_path", + return_value=Path("config/media-runtime.json").resolve(), + ), + ): + result = self.invoke(["init", "--json"]) + + self.assertEqual(result.exit_code, 0, result.output) + payload = json.loads(result.output) + self.assertTrue(payload["ready"]) + self.assertTrue(payload["initialized"]) + save.assert_called_once_with(status) + self.create_local_application.assert_not_called() + + def test_noninteractive_init_reports_command_without_installing(self): + plan = SystemInstallPlan( + manager="Windows Package Manager", + command=( + "winget", + "install", + "--id", + "Gyan.FFmpeg", + "--exact", + ), + automatic=True, + ) + status = MediaRuntimeStatus( + ready=False, + initialized=False, + errors=("FFmpeg was not found.",), + install_plan=plan, + ) + with ( + patch( + "vidxp.cli_commands.runtime.inspect_media_runtime", + return_value=status, + ), + patch( + "vidxp.cli_commands.runtime.install_media_runtime", + ) as install, + ): + result = self.invoke(["init", "--json"]) + + self.assertEqual(result.exit_code, 1, result.output) + payload = json.loads(result.output) + self.assertFalse(payload["ready"]) + self.assertIn("Gyan.FFmpeg", payload["install_command"]) + install.assert_not_called() + self.create_local_application.assert_not_called() + + def test_yes_explicitly_runs_the_displayed_installer_and_verifies(self): + plan = SystemInstallPlan( + manager="Windows Package Manager", + command=("winget", "install", "--id", "Gyan.FFmpeg"), + automatic=True, + ) + missing = MediaRuntimeStatus( + ready=False, + initialized=False, + errors=("FFmpeg was not found.",), + install_plan=plan, + ) + ready = MediaRuntimeStatus( + ready=True, + initialized=False, + ffmpeg_executable=Path("tools/ffmpeg.exe").resolve(), + ffprobe_executable=Path("tools/ffprobe.exe").resolve(), + ) + configuration = MediaRuntimeConfiguration( + ffmpeg_executable=ready.ffmpeg_executable, + ffprobe_executable=ready.ffprobe_executable, + ) + with ( + patch( + "vidxp.cli_commands.runtime.inspect_media_runtime", + side_effect=(missing, ready), + ), + patch( + "vidxp.cli_commands.runtime.install_media_runtime", + ) as install, + patch( + "vidxp.cli_commands.runtime.save_media_runtime_configuration", + return_value=configuration, + ), + ): + result = self.invoke(["init", "--yes", "--json"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertTrue(json.loads(result.output)["ready"]) + install.assert_called_once_with(plan, output_to_stderr=True) + self.create_local_application.assert_not_called() + + def test_first_media_command_points_to_init_when_uninitialized(self): + with TemporaryDirectory() as directory: + video = Path(directory) / "video.mp4" + video.write_bytes(b"video") + result = self.invoke( + ["media", "import", str(video)], + media_runtime_initialized=False, + ) + + self.assertEqual(result.exit_code, 1) + self.assertEqual(result.exception.code, "media_runtime_uninitialized") + self.assertIn("vidxp init", str(result.exception)) + self.service.import_media.assert_not_called() + def test_ui_shutdown_stops_its_local_worker(self): with patch( "vidxp.frontend.main", diff --git a/tests/test_media_runtime.py b/tests/test_media_runtime.py new file mode 100644 index 0000000..5f8c477 --- /dev/null +++ b/tests/test_media_runtime.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import os +import sys +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from vidxp.media_runtime import ( + MediaRuntimeStatus, + inspect_media_runtime, + load_media_runtime_configuration, + media_runtime_is_initialized, + save_media_runtime_configuration, + system_install_plan, +) + + +class MediaRuntimeTests(unittest.TestCase): + def test_inspection_verifies_both_executables_and_required_encoders(self): + ffmpeg = Path("resolved/ffmpeg").resolve() + ffprobe = Path("resolved/ffprobe").resolve() + + def resolve(value): + return ffmpeg if str(value) == "ffmpeg" else ffprobe + + def output(arguments, **_kwargs): + if "-encoders" in arguments: + return " V....D libx264\n A....D aac\n" + return "version" + + with ( + patch("vidxp.media_runtime._resolve_executable", side_effect=resolve), + patch("vidxp.media_runtime._command_output", side_effect=output), + patch("vidxp.media_runtime.system_install_plan"), + ): + status = inspect_media_runtime() + + self.assertTrue(status.ready) + self.assertEqual(status.ffmpeg_executable, ffmpeg) + self.assertEqual(status.ffprobe_executable, ffprobe) + self.assertEqual(status.errors, ()) + + def test_inspection_rejects_an_ffmpeg_build_without_required_codecs(self): + executable = Path("resolved/tool").resolve() + + def output(arguments, **_kwargs): + return " V....D libx264\n" if "-encoders" in arguments else "version" + + with ( + patch( + "vidxp.media_runtime._resolve_executable", + return_value=executable, + ), + patch("vidxp.media_runtime._command_output", side_effect=output), + patch("vidxp.media_runtime.system_install_plan", return_value=None), + ): + status = inspect_media_runtime() + + self.assertFalse(status.ready) + self.assertIn("aac", " ".join(status.errors)) + + def test_verified_absolute_paths_are_persisted_and_reloaded(self): + with TemporaryDirectory() as directory: + root = Path(directory) + ffmpeg = root / "ffmpeg.exe" + ffprobe = root / "ffprobe.exe" + ffmpeg.touch() + ffprobe.touch() + status = MediaRuntimeStatus( + ready=True, + initialized=False, + ffmpeg_executable=ffmpeg.resolve(), + ffprobe_executable=ffprobe.resolve(), + ) + + saved = save_media_runtime_configuration( + status, + config_directory=root / "config", + ) + loaded = load_media_runtime_configuration(root / "config") + + self.assertEqual(loaded, saved) + self.assertTrue(saved.ffmpeg_executable.is_absolute()) + self.assertTrue(saved.ffprobe_executable.is_absolute()) + + def test_initialized_accepts_saved_config_environment_or_path(self): + with TemporaryDirectory() as directory: + root = Path(directory) + ffmpeg = root / "ffmpeg" + ffprobe = root / "ffprobe" + ffmpeg.touch() + ffprobe.touch() + save_media_runtime_configuration( + MediaRuntimeStatus( + ready=True, + initialized=False, + ffmpeg_executable=ffmpeg.resolve(), + ffprobe_executable=ffprobe.resolve(), + ), + config_directory=root, + ) + self.assertTrue(media_runtime_is_initialized(root)) + + with patch.dict( + os.environ, + { + "VIDXP_FFMPEG_EXECUTABLE": "custom-ffmpeg", + "VIDXP_FFPROBE_EXECUTABLE": "custom-ffprobe", + }, + clear=False, + ): + self.assertTrue(media_runtime_is_initialized(Path("missing"))) + + with ( + patch.dict(os.environ, {}, clear=True), + patch( + "vidxp.media_runtime.load_media_runtime_configuration", + return_value=None, + ), + patch("vidxp.media_runtime.shutil.which", return_value=None), + ): + self.assertFalse(media_runtime_is_initialized()) + + @unittest.skipUnless(sys.platform == "win32", "Windows package contract") + def test_windows_plan_uses_the_approved_exact_winget_package(self): + with patch("vidxp.media_runtime.shutil.which", return_value="winget"): + plan = system_install_plan() + + self.assertIsNotNone(plan) + assert plan is not None + self.assertEqual(plan.manager, "Windows Package Manager") + self.assertTrue(plan.automatic) + self.assertIn("Gyan.FFmpeg", plan.command) + self.assertIn("--exact", plan.command) + self.assertIn("--source", plan.command) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_settings.py b/tests/test_settings.py index 61df8e5..c089a17 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -78,6 +78,22 @@ def test_explicit_storage_paths_override_derived_defaults(self): self.assertEqual(settings.repository_root, repository_root) self.assertEqual(settings.model_cache, model_cache) + def test_saved_media_runtime_paths_become_settings_defaults(self): + ffmpeg = Path("tools/ffmpeg").resolve() + ffprobe = Path("tools/ffprobe").resolve() + + def executable(name): + return str(ffmpeg if name == "ffmpeg" else ffprobe) + + with patch( + "vidxp.settings.default_media_executable", + side_effect=executable, + ): + settings = VidXPSettings() + + self.assertEqual(settings.ffmpeg_executable, str(ffmpeg)) + self.assertEqual(settings.ffprobe_executable, str(ffprobe)) + def test_local_execution_settings_preserve_the_data_directory(self): with TemporaryDirectory() as directory: expected = VidXPSettings(data_dir=Path(directory) / "data") From e27ac3a4a6c400f9ac73abbdb3031d62f71e2331 Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 30 Jul 2026 03:40:03 +0500 Subject: [PATCH 35/46] docs: prepare public release documentation --- INSTALLATION_GUIDE.md | 552 ++++++++++------ README.md | 613 ++++++++---------- changes/+product-interfaces.feature.md | 1 + changes/+public-transports.security.md | 1 + changes/+runtime-setup.feature.md | 1 + changes/+server-deployment.feature.md | 1 + changes/+video-library.feature.md | 1 + changes/19.bugfix.md | 1 - changes/README.md | 19 +- changes/actor-cluster-pagination.bugfix.md | 2 - changes/actor-detection-pagination.bugfix.md | 2 - changes/actor-jobs.bugfix.md | 2 - changes/api-openapi-auth.bugfix.md | 2 - changes/api-readiness.bugfix.md | 1 - changes/api-schema-validation.bugfix.md | 1 - changes/application-boundary.bugfix.md | 4 - changes/artifact-catalog.feature.md | 8 - changes/benchmark-cli.bugfix.md | 2 - changes/benchmark-dependencies.bugfix.md | 2 - changes/coolify-deployment.feature.md | 1 - changes/coolify-shutdown.bugfix.md | 2 - changes/cursor-pagination.bugfix.md | 2 - changes/desktop.feature.md | 7 - changes/durable-jobs.feature.md | 7 - changes/durable-read-snapshots.bugfix.md | 3 - .../durable-search-compatibility.bugfix.md | 1 - changes/durable-search.feature.md | 2 - changes/explicit-model-readiness.bugfix.md | 7 - changes/frontend-capability-details.bugfix.md | 2 - changes/frontend-search-clarity.bugfix.md | 3 - changes/frontend-search-form.bugfix.md | 2 - changes/gpu-evaluation.documentation.md | 4 - changes/http-api.feature.md | 8 - changes/http-auth.security.md | 9 - changes/http-jobs.feature.md | 8 - changes/http-media.feature.md | 9 - changes/index-snapshots.feature.md | 6 - changes/large-local-import.feature.md | 2 - changes/local-runtime-smoke.bugfix.md | 3 - changes/local-runtime-usability.bugfix.md | 2 - changes/local-storage-durability.bugfix.md | 3 - changes/local-worker-lifecycle.bugfix.md | 17 - changes/mcp-discovery.feature.md | 2 - changes/mcp-media-usability.feature.md | 1 - changes/mcp-reserved-path.security.md | 2 - changes/mcp-self-check-index-list.feature.md | 1 - changes/mcp-server.feature.md | 3 - changes/media-catalog.feature.md | 6 - changes/media-runtime-init.feature.md | 1 - changes/media-scoped-search.bugfix.md | 1 - changes/model-providers.feature.md | 2 - changes/model-unavailable-error.bugfix.md | 2 - changes/multimodal-query.feature.md | 1 - changes/oidc-transport.security.md | 4 - changes/publication-foundation.bugfix.md | 5 - changes/remote-ingestion.feature.md | 1 - changes/scene-sampling.feature.md | 2 - changes/server-persistence.feature.md | 1 - changes/server-storage-readiness.bugfix.md | 2 - changes/snippet-artifacts.feature.md | 5 - changes/standard-local-storage.bugfix.md | 5 - changes/transport-terminals.bugfix.md | 3 - changes/workflow-compatibility.bugfix.md | 2 - docs/CONTRIBUTING.md | 319 ++++++--- docs/deployment/coolify.md | 37 ++ 65 files changed, 878 insertions(+), 856 deletions(-) create mode 100644 changes/+product-interfaces.feature.md create mode 100644 changes/+public-transports.security.md create mode 100644 changes/+runtime-setup.feature.md create mode 100644 changes/+server-deployment.feature.md create mode 100644 changes/+video-library.feature.md delete mode 100644 changes/19.bugfix.md delete mode 100644 changes/actor-cluster-pagination.bugfix.md delete mode 100644 changes/actor-detection-pagination.bugfix.md delete mode 100644 changes/actor-jobs.bugfix.md delete mode 100644 changes/api-openapi-auth.bugfix.md delete mode 100644 changes/api-readiness.bugfix.md delete mode 100644 changes/api-schema-validation.bugfix.md delete mode 100644 changes/application-boundary.bugfix.md delete mode 100644 changes/artifact-catalog.feature.md delete mode 100644 changes/benchmark-cli.bugfix.md delete mode 100644 changes/benchmark-dependencies.bugfix.md delete mode 100644 changes/coolify-deployment.feature.md delete mode 100644 changes/coolify-shutdown.bugfix.md delete mode 100644 changes/cursor-pagination.bugfix.md delete mode 100644 changes/desktop.feature.md delete mode 100644 changes/durable-jobs.feature.md delete mode 100644 changes/durable-read-snapshots.bugfix.md delete mode 100644 changes/durable-search-compatibility.bugfix.md delete mode 100644 changes/durable-search.feature.md delete mode 100644 changes/explicit-model-readiness.bugfix.md delete mode 100644 changes/frontend-capability-details.bugfix.md delete mode 100644 changes/frontend-search-clarity.bugfix.md delete mode 100644 changes/frontend-search-form.bugfix.md delete mode 100644 changes/gpu-evaluation.documentation.md delete mode 100644 changes/http-api.feature.md delete mode 100644 changes/http-auth.security.md delete mode 100644 changes/http-jobs.feature.md delete mode 100644 changes/http-media.feature.md delete mode 100644 changes/index-snapshots.feature.md delete mode 100644 changes/large-local-import.feature.md delete mode 100644 changes/local-runtime-smoke.bugfix.md delete mode 100644 changes/local-runtime-usability.bugfix.md delete mode 100644 changes/local-storage-durability.bugfix.md delete mode 100644 changes/local-worker-lifecycle.bugfix.md delete mode 100644 changes/mcp-discovery.feature.md delete mode 100644 changes/mcp-media-usability.feature.md delete mode 100644 changes/mcp-reserved-path.security.md delete mode 100644 changes/mcp-self-check-index-list.feature.md delete mode 100644 changes/mcp-server.feature.md delete mode 100644 changes/media-catalog.feature.md delete mode 100644 changes/media-runtime-init.feature.md delete mode 100644 changes/media-scoped-search.bugfix.md delete mode 100644 changes/model-providers.feature.md delete mode 100644 changes/model-unavailable-error.bugfix.md delete mode 100644 changes/multimodal-query.feature.md delete mode 100644 changes/oidc-transport.security.md delete mode 100644 changes/publication-foundation.bugfix.md delete mode 100644 changes/remote-ingestion.feature.md delete mode 100644 changes/scene-sampling.feature.md delete mode 100644 changes/server-persistence.feature.md delete mode 100644 changes/server-storage-readiness.bugfix.md delete mode 100644 changes/snippet-artifacts.feature.md delete mode 100644 changes/standard-local-storage.bugfix.md delete mode 100644 changes/transport-terminals.bugfix.md delete mode 100644 changes/workflow-compatibility.bugfix.md diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md index bcf4957..bdb1254 100644 --- a/INSTALLATION_GUIDE.md +++ b/INSTALLATION_GUIDE.md @@ -1,328 +1,468 @@ # Installation guide -The main [README](README.md) contains the shortest path. This guide separates -the lightweight CLI from the native local-worker stack so CPU, CUDA, and model -dependencies are never installed accidentally. +Use this guide to choose one supported VidXP shape and install only what that +shape needs. -## Supported local platforms +## Choose an installation -- Python 3.11 through 3.14 -- Apple Silicon macOS 14 or newer -- Linux x86-64 -- Windows x86-64 -- FFmpeg, ffprobe, and the `libx264`/`aac` encoders for video/audio processing. - VidXP wheels do not install or modify operating-system packages. +| Goal | Recommended installation | What runs | +|---|---|---| +| Try the browser UI locally | `compose.yaml` | One CPU worker/UI container | +| Native CLI indexing | `vidxp[local-worker]` | CLI plus a supervised local worker | +| Native browser UI | `vidxp[local-worker,frontend]` | CLI, local worker, Streamlit | +| Local agent integration | `vidxp[local-worker,mcp]` | Local worker and stdio MCP | +| Local application server | `vidxp[local-worker,server]` | Loopback HTTP API, remote MCP, local worker | +| Desktop preview | Build the Tauri app | Guided app-owned Python/worker/UI runtime | +| Public/self-hosted service | `compose.coolify.yaml` | API/MCP control plane, CPU worker, PostgreSQL, Chroma, tusd | +| Embed one capability | `dialogue`, `scene`, or `actor` extra | Python indexing/retrieval code | -The actor capability uses OpenCV YuNet and SFace. It does not use `dlib`, CMake, -or a local C++ compiler. +Do not install the bare package and expect it to index video. Base `vidxp` +provides the lightweight command shell, configuration, and typed contracts. +Local model work requires a capability or worker extra. -CPU execution is the current default on every platform. CUDA is a later runtime -profile. MPS is not selected by `auto` until its capability parity gate is -complete. +## Requirements -## Install the lightweight CLI +| Requirement | Supported | +|---|---| +| Python | 3.11 through 3.14 | +| Windows | x86-64 | +| Linux | x86-64 | +| macOS | Apple Silicon, macOS 14 or newer | +| Local runtime | CPU | +| Media tools | FFmpeg, ffprobe, `libx264`, and `aac` | +| GPU | Deferred; not a supported installation profile | + +Python wheels never install operating-system packages. `vidxp init` is the +explicit, guided FFmpeg setup command; `vidxp doctor` is always read-only. +Native installation uses +[uv](https://docs.astral.sh/uv/getting-started/installation/) and begins with +`uv --version`; Docker users do not need a host Python or uv installation. -Use uv to expose `vidxp` on `PATH` while keeping its dependencies isolated: +## Fastest local setup: Docker + +The local image contains the CPU worker, browser UI, and FFmpeg. Model weights +remain an explicit first-run download. ```bash -uv tool install vidxp -vidxp --version -vidxp init +git clone https://github.com/grayhatdevelopers/vidxp.git +cd vidxp +docker compose pull +docker compose run --rm vidxp vidxp prepare +docker compose up ``` -This base install intentionally excludes Chroma, Torch, model providers, -Streamlit, and model weights. It is the correct foundation for a future remote -API client and for commands that do not execute local indexing. +Open `http://localhost:8501`. -`vidxp init` is independent of the local-worker stack. It checks FFmpeg and -ffprobe, verifies the required codecs, and stores their resolved absolute paths -in VidXP's per-user configuration. If they are missing, an interactive Windows -or macOS terminal can approve the exact displayed package-manager command. -Linux prints the applicable command for the user to run because elevation -belongs to the system terminal. Non-interactive input never prompts or installs -unless the caller explicitly supplies `--yes`. +| Setting | Default | Purpose | +|---|---|---| +| `VIDXP_IMAGE` | `ghcr.io/grayhatdevelopers/vidxp:latest` | Local image tag or digest | +| `VIDXP_PORT` | `8501` | Host port for the browser UI | +| `VIDXP_DEVICE` | `cpu` | Runtime device; CPU is the supported value | +| `vidxp-data` volume | Managed by Compose | Media, indexes, jobs, artifacts, and models | -To run only this setup without keeping a tool environment: +Stop the app with `Ctrl+C` or: ```bash -uvx vidxp init +docker compose down ``` -`pipx install vidxp` remains supported if pipx is preferred; run `vidxp init` -afterward in the same way. +`docker compose down` keeps the named volume. Add `--volumes` only when you +intentionally want to remove all persisted VidXP data. -## Install a published local worker +## Native local setup -The `local-worker` extra contains Chroma and all current CPU capability -providers. The `frontend` extra adds Streamlit. +The native sequence uses uv for Python selection, environment creation, and +CPU-aware PyTorch resolution. -### macOS - -PyPI publishes native macOS Torch wheels without the Linux CUDA dependency -stack: +### 1. Create an isolated environment ```bash -pipx install "vidxp[local-worker,frontend]" -vidxp init +uv venv --python 3.14 ``` -### Linux and Windows +| Windows PowerShell | macOS or Linux | +|---|---| +| `.venv\Scripts\Activate.ps1` | `source .venv/bin/activate` | + +### 2. Install the profile you need + +| Goal | Command | +|---|---| +| CLI worker | `uv pip install --torch-backend cpu "vidxp[local-worker]"` | +| Browser UI | `uv pip install --torch-backend cpu "vidxp[local-worker,frontend]"` | +| Local stdio MCP | `uv pip install --torch-backend cpu "vidxp[local-worker,mcp]"` | +| Local API + remote MCP | `uv pip install --torch-backend cpu "vidxp[local-worker,server]"` | +| UI + API + both MCP transports | `uv pip install --torch-backend cpu "vidxp[local-worker,frontend,server]"` | -Python package metadata cannot select a package index. Install the official CPU -Torch wheel into the pipx environment first, then install VidXP's local extras: +The explicit CPU backend prevents Linux and Windows from resolving the much +larger CUDA-enabled PyTorch distribution. Apple Silicon uses its native PyPI +wheel. + +### 3. Initialize FFmpeg ```bash -pipx install vidxp -pipx runpip vidxp install "torch==2.13.0+cpu" --index-url https://download.pytorch.org/whl/cpu -pipx runpip vidxp install "vidxp[local-worker,frontend]" vidxp init ``` -The final command accepts the already installed `2.13.0+cpu` build because it -satisfies VidXP's published `torch>=2.13,<3` requirement. Repeat the staged -Torch step after recreating the pipx environment. +`init` performs these steps: -Do not add a Torch wheel URL to VidXP dependency metadata. Direct URL -requirements are unnecessary here and can make published package metadata fail -repository validation. +1. resolves FFmpeg and ffprobe; +2. executes version checks; +3. verifies the `libx264` and `aac` encoders; +4. shows the exact supported package-manager command if anything is missing; +5. asks before running WinGet or Homebrew; and +6. stores the verified absolute paths in VidXP’s per-user configuration. -## Install from source +Linux prints an applicable APT, DNF, or pacman command for the user to run in a +system terminal. VidXP does not automate `sudo`. -The repository lock is the preferred development and reproducible local-worker -path. It routes Torch through an explicit CPU-only index on Linux and Windows, -uses PyPI's native wheel on macOS, and never uses `extra-index-url` mixing: +For scripts and CI: ```bash -uv sync --frozen --extra local-worker --extra frontend -uv run vidxp init -uv run vidxp doctor +vidxp init --json \ + --ffmpeg /absolute/path/to/ffmpeg \ + --ffprobe /absolute/path/to/ffprobe ``` -Add benchmark-only dependencies when working on benchmark adapters: +Redirected/noninteractive input never prompts or installs. `--yes` is an +explicit authorization to run the displayed supported package-manager command. + +The lightweight one-off form is: ```bash -uv sync --frozen --extra local-worker --extra benchmarks +uvx vidxp init ``` -For a non-repository virtual environment, uv can select the Torch backend -without embedding a direct URL in project metadata: +Because the paths are stored in per-user configuration, a later permanent +installation can reuse initialization performed by `uvx`. + +### 4. Prepare models ```bash -uv venv --python 3.14 +vidxp prepare +``` -# macOS/Linux -source .venv/bin/activate +The command displays the models that are missing, their pinned download sizes, +the cache location, and the maximum additional disk use before asking for +confirmation. -# Windows PowerShell -.venv\Scripts\Activate.ps1 +| Capability | Models | Approximate download | +|---|---|---:| +| Dialogue | Qwen3 Embedding 0.6B + faster-whisper large-v3-turbo | 2.64 GiB | +| Scene | SigLIP2 base patch16-224 | 1.43 GiB | +| Actor | OpenCV Zoo YuNet + SFace | 37 MiB | -uv pip install --torch-backend cpu "vidxp[local-worker,frontend]" +Prepare only what you plan to index: + +```bash +vidxp prepare --modalities scene +vidxp prepare --modalities dialogue,actor ``` -## Local data location +Noninteractive preparation requires explicit confirmation: -Local installs do not use the directory where `vidxp` was launched as their -storage root. The CLI, browser UI, local MCP process, and locally launched -server use the operating system's per-user VidXP data directory: +```bash +vidxp prepare --modalities scene --yes +``` -| Platform | Default data root | -|---|---| -| Windows | `%LOCALAPPDATA%\VidXP` | -| macOS | `~/Library/Application Support/VidXP` | -| Linux | `${XDG_DATA_HOME:-~/.local/share}/VidXP` | +Indexing, API jobs, and MCP tools never turn the first request into a hidden +model download. + +### 5. Verify + +```bash +vidxp doctor +``` -The default repository is under `repositories/default/` and prepared models are -under `models/` within that root. Named-repository configuration stays in the -operating system's standard user configuration directory, such as -`%APPDATA%\VidXP\repositories.json` on Windows. None of these paths are inside -the package environment or source checkout unless explicitly requested. +`doctor` checks installed distributions, provider imports, FFmpeg, codecs, and +the selected pinned model artifacts. It never installs packages, changes the +operating system, constructs models, or downloads weights. -Use the global option when invoking the `vidxp` command: +Limit the check when you prepared a subset: ```bash -vidxp --data-dir /path/to/vidxp-data doctor -vidxp --data-dir /path/to/vidxp-data ui +vidxp doctor --modalities scene +vidxp doctor --modalities dialogue,actor ``` -Set `VIDXP_DATA_DIR` instead when every local entry point, including -`vidxp-api` or `vidxp-mcp`, should use the same alternate root. -`VIDXP_INDEX_DIR` and `VIDXP_MODEL_CACHE` remain advanced per-location -overrides. Docker and Compose are separate deployment profiles and use their -explicitly configured volumes. +### 6. Start the selected surface -## Connect a local MCP client +| Surface | Command | +|---|---| +| CLI | `vidxp --help` | +| Browser UI | `vidxp ui` | +| Local HTTP API + remote MCP | `vidxp-api` | +| Local stdio MCP | `vidxp-mcp` | -Add the MCP adapter to the same environment as the local worker. For a source -checkout: +## First CLI index ```bash -uv sync --frozen --extra local-worker --extra mcp -uv run vidxp mcp-config +vidxp media import samplevideo.mp4 --json +vidxp index create +vidxp search scene "a yellow taxi on a city street" ``` -For a published pipx worker install: +- `media import` copies and validates the video in managed storage. +- `media list` rediscovers registered filenames and IDs. +- `index list` shows active searchable membership. +- Search/query without `--media-id` ranks across every indexed video. +- `--media-id ` restricts results to one video. + +Index one capability or change scene cadence: ```bash -pipx runpip vidxp install "vidxp[mcp]" -vidxp mcp-config +vidxp index create \ + --modality scene \ + --scene-sample-fps 1 ``` -The second command prints a complete `mcpServers` JSON object with the resolved -absolute executable path. Copy that object into a stdio MCP client's JSON -configuration. To select a named collection, run -`vidxp mcp-config --repository `. The equivalent standalone commands are -`vidxp-mcp --print-config` and `vidxp-mcp --help`. -Run `vidxp-mcp --check --repository ` to perform a local protocol -handshake, discover the tools, call the read-only index-status tool, print the -resolved data/index paths, and exit. +## Optional dependency extras + +Extras are composable: + +| Extra | Includes | Does not include | +|---|---|---| +| `storage` | Embedded Chroma and host monitoring | Model providers or UI | +| `dialogue` | Storage, transcription, dialogue embeddings | Scene/actor providers | +| `scene` | Storage, PyTorch, Transformers, OpenCV, Pillow | Dialogue/actor providers | +| `actor` | Storage, OpenCV, YuNet/SFace support | Dialogue/scene providers | +| `all` | Dialogue, scene, and actor | Grounded-query model client and UI | +| `local-worker` | `all` plus grounded-query client | Browser UI, MCP SDK, HTTP server | +| `frontend` | Streamlit | Worker providers | +| `mcp` | MCP SDK | Worker providers | +| `slm` | OpenAI-compatible local query-model client | A language model or model weights | +| `server` | FastAPI, remote MCP, PostgreSQL/control dependencies | Local providers | +| `server-worker` | Server storage client and every CPU provider | Public API process | +| `benchmarks` | Benchmark adapter dependencies | Local worker providers | +| `test` | Pytest and HTTP test client | Product runtime features | -## Verify providers and model readiness without downloading +The `server` extra is intentionally model-free. Use it with `local-worker` for +a loopback all-in-one API, or use the separate control and worker images for a +deployed server. + +## Local MCP + +After installing `local-worker,mcp`: ```bash -vidxp doctor +vidxp mcp-config ``` -Restrict the check to selected capabilities: +The command prints a complete, import-ready `mcpServers` JSON object with the +resolved absolute `vidxp-mcp` executable and default repository argument. ```bash -vidxp doctor --modalities scene -vidxp doctor --modalities dialogue,actor +vidxp-mcp --check --repository default ``` -The doctor command checks installed distributions, imports each selected native -provider, and reports every pinned model artifact as cached or missing. It never -constructs models or downloads weights. Missing selected models make the command -exit unsuccessfully with the exact `vidxp prepare --modalities ...` remedy. - -## Prepare pinned models +The self-check performs a real stdio handshake, discovers tools, calls the +read-only index-status tool, prints resolved data/index paths, and exits. -Model weights are cached separately from the Python environment: +Useful alternatives: -| Capability | Provider/model | +| Command | Result | |---|---| -| Dialogue embeddings | `Qwen/Qwen3-Embedding-0.6B` | -| Transcription | `dropbox-dash/faster-whisper-large-v3-turbo` | -| Scene search | `google/siglip2-base-patch16-224` | -| Actor detection | OpenCV Zoo YuNet | -| Actor recognition | OpenCV Zoo SFace | +| `vidxp mcp-config --repository ` | Client JSON for a named repository | +| `vidxp-mcp --print-config` | JSON without other output | +| `vidxp-mcp --help` | Options plus a copy/paste example | -Every model revision and weight checksum is pinned in its capability spec. -Explicitly prepare all selected models before indexing: +## Local HTTP API + +Install `local-worker,server`, prepare models, then run: ```bash -vidxp prepare +vidxp-api ``` -Indexing, API jobs, and MCP tools never download missing models implicitly. -Preparation is a durable job and reports download bytes plus model-loading -stages through CLI output or normal job polling. The CLI and UI show every -missing model's pinned size, the maximum additional cache space, and the cache -location before requiring confirmation. Non-interactive CLI preparation -requires `--yes`. +The unauthenticated local default is deliberately loopback-only: -Prepare a subset when disk or network capacity is limited: +| Endpoint | Purpose | +|---|---| +| `http://127.0.0.1:8000/docs` | Interactive OpenAPI | +| `http://127.0.0.1:8000/openapi.json` | Machine-readable contract | +| `http://127.0.0.1:8000/health` | Process liveness | +| `http://127.0.0.1:8000/ready` | Aggregate runtime readiness | +| `http://127.0.0.1:8000/mcp` | Streamable HTTP MCP | -```bash -vidxp prepare --modalities scene -vidxp prepare --modalities dialogue,actor -vidxp prepare --modalities scene --yes # non-interactive confirmation +Do not bind an unauthenticated API to a non-loopback address. Public +deployments require static bearer or OIDC authentication and should use the +supported server Compose topology. + +## Published container images + +Every stable release publishes three Linux/amd64 images from the same validated +release commit: + +| Image | Published tag | Purpose | +|---|---|---| +| Local | ``, `.`, `latest` | CPU worker + browser UI | +| Control | `-control` | API, remote MCP, migrations, private upload hooks | +| Worker | `-worker` | CPU model providers and server storage client | + +Repository: + +```text +ghcr.io/grayhatdevelopers/vidxp ``` -Prepared models normally use the `models/` directory beneath the VidXP data -root described above. Set `VIDXP_MODEL_CACHE` only to override that one -location. Set `VIDXP_ALLOW_MODEL_DOWNLOADS=false` for an offline worker after -preparation. +The local image is used by `compose.yaml`. The control and worker images are +used by `compose.coolify.yaml`. -## First indexing run +## Coolify and server Compose -```bash -vidxp media import samplevideo.mp4 --json -vidxp index create -vidxp search scene "a yellow taxi on a city street" +The supported server topology contains: + +| Control plane | Execution and storage | Ingestion | +|---|---|---| +| FastAPI + Streamable HTTP MCP | One CPU worker, PostgreSQL, Chroma | tusd + private hook service | + +It is a single-node, single-repository deployment. It does not claim +multi-replica failover, hosted database substitution, or GPU execution. + +Follow [Coolify deployment](docs/deployment/coolify.md) for: + +- exact control/worker image variables; +- required secrets and public hostnames; +- proxy rules for MCP and resumable uploads; +- explicit worker model preparation through the authenticated API or MCP; +- readiness/migration gates; +- persistent volumes and backups; and +- the optional, explicitly prepared Ollama profile. + +## Desktop preview + +The Tauri desktop shell performs setup in this order: + +```text +FFmpeg preflight +→ explicit package-manager consent when missing +→ app-owned Python and VidXP installation +→ optional model preparation +→ full doctor +→ atomic runtime activation ``` -The import result contains the stable `media_id` used by indexing and optional -single-video filters. Use `vidxp media list` to rediscover IDs and filenames, -and `vidxp media show ` for the full registered metadata. -Omit `--media-id` from search/query to rank across every media item in the -active index snapshot, or provide it to restrict results to one video. +The NSIS/DMG/AppImage packages do not install FFmpeg themselves. Desktop +packaging is currently a source-built preview; signed installer publication is +still under release validation. Build instructions are in +[Desktop application](docs/desktop.md). + +## Install from source -Use fewer capabilities or adjust the scene sampling rate when appropriate: +Source installation is for contributors and reproducible development, not the +normal public quick start: ```bash -vidxp index create \ - --modality scene \ - --scene-sample-fps 1 +git clone https://github.com/grayhatdevelopers/vidxp.git +cd vidxp +uv sync --frozen \ + --extra local-worker \ + --extra frontend \ + --extra mcp \ + --extra server +uv run --no-sync vidxp init +uv run --no-sync vidxp prepare +uv run --no-sync vidxp doctor +uv run --no-sync vidxp ui ``` -Indexes and manifests live under the configured repository root. Model identity, -revision, checksum, precision, license, and resolved runtime state are recorded -for reproducibility. +The lock routes PyTorch through the official CPU index on Linux and Windows. + +## Data and configuration locations -## Browser interface +Local commands do not treat the current directory as their storage root. -With the `frontend` extra installed: +| Platform | Data root | Configuration root | +|---|---|---| +| Windows | `%LOCALAPPDATA%\VidXP` | `%APPDATA%\VidXP` | +| macOS | `~/Library/Application Support/VidXP` | `~/Library/Application Support/VidXP` | +| Linux | `${XDG_DATA_HOME:-~/.local/share}/VidXP` | `${XDG_CONFIG_HOME:-~/.config}/VidXP` | + +The data root contains: + +```text +VidXP/ + repositories/ + default/ + models/ +``` + +Use an alternate root for one CLI invocation: ```bash -vidxp ui +vidxp --data-dir /path/to/vidxp-data ui ``` -The browser interface uses the same application service and capability -contracts as the CLI. It does not implement a second indexing/search layer. +Set `VIDXP_DATA_DIR` when the CLI, UI, local MCP, and local API should all use +the same alternate root. `VIDXP_INDEX_DIR` and `VIDXP_MODEL_CACHE` are advanced +single-location overrides. Docker uses its declared volumes instead. + +## Troubleshooting + +### `vidxp` is not found -## Run the current container +- Activate the virtual environment created above. +- For a uv tool installation, run `uv tool update-shell`, restart the shell, + and try again. -The CPU image contains the local worker and Streamlit frontend, but not model -weights. Compose persists indexes and the model cache: +### FFmpeg or a codec is missing ```bash -docker compose pull -docker compose run --rm vidxp vidxp prepare -docker compose up +vidxp init ``` -The interface is available at `http://localhost:8501`. Configure the image, -port, and CPU backend in `.env` when needed: +Review the displayed package-manager command before approving it. Rerun +`vidxp doctor` afterward. -```dotenv -VIDXP_IMAGE=ghcr.io/grayhatdevelopers/vidxp:0.2.1 -VIDXP_PORT=8501 -VIDXP_DEVICE=cpu -``` +### Linux or Windows starts resolving NVIDIA packages + +Recreate the environment and retain the explicit CPU option: -The local image above is distinct from the prebuilt server topology. Stable -releases also publish `-control` and `-worker` images used by -the [Coolify deployment](docs/deployment/coolify.md). +```bash +uv pip install --torch-backend cpu "vidxp[local-worker,frontend]" +``` -## Common problems +Do not use plain `pip install "vidxp[local-worker]"` on Linux unless you have +already installed and constrained the intended CPU PyTorch build. -### FFmpeg is not found +### uv cannot hardlink from its cache -Run `vidxp init`. It checks both executables and the required `libx264` and -`aac` encoders, then displays the exact supported package-manager command if -anything is missing. It asks before running supported package managers and -persists verified absolute executable paths for later CLI, UI, API, and MCP -processes. +This warning means the cache and environment are on filesystems that cannot +hardlink to each other. Installation falls back to copying and remains valid. +Suppress the warning when that layout is intentional: -`vidxp doctor` is always read-only. It reports the failure and points back to -`vidxp init`; it never installs or changes system packages. CI and container -images should install FFmpeg in their image definition, then run -`vidxp init --json --ffmpeg /absolute/path --ffprobe /absolute/path`. +```bash +uv sync --link-mode copy +``` -### Linux pulls CUDA or NVIDIA packages +or set `UV_LINK_MODE=copy`. -Do not use a plain published `vidxp[local-worker]` install on Linux. Recreate -the environment with the staged pipx commands above, use `uv pip ---torch-backend cpu`, or use the repository's frozen uv lock. +### A model is missing or the worker is offline -### A model cannot be used offline +```bash +vidxp prepare +vidxp doctor +``` -Temporarily allow downloads, run `vidxp prepare` for the selected capability, -then restore `VIDXP_ALLOW_MODEL_DOWNLOADS=false`. The runtime verifies pinned -checksums before recording an artifact as resolved. +For offline operation, prepare while network access is allowed, then set +`VIDXP_ALLOW_MODEL_DOWNLOADS=false`. ### Search says the index is not ready -Wait for indexing to finish. If the earlier process failed or was interrupted, -run the create command again. The current local worker resumes or rebuilds -according to its manifest and index state. +- Check `vidxp index status`. +- Check `vidxp jobs list` and inspect the failed job. +- Retry indexing after fixing the reported dependency/model error. +- A failed or cancelled generation does not replace the previous active + snapshot. + +### Resetting local data + +VidXP intentionally does not provide an implicit destructive reset. Inspect the +resolved paths first: + +```bash +vidxp repositories show +``` + +Back up or remove only the specific per-user repository/model directories you +intend to discard. diff --git a/README.md b/README.md index 158b90b..45912dd 100644 --- a/README.md +++ b/README.md @@ -1,222 +1,198 @@

    - - logo - + + VidXP logo +

    VidXP

    - Search video by what was said, what appeared on screen, and recurring faces. + Search video by what was said, what appeared, and who was on screen.

    -
    -

    - VidXP is a local-first video indexing and search engine distributed as a Python - package. -

    - -
    -
    -
    You can use it: -
      -
    • From the command line
    • -
    • Through its browser interface
    • -
    • As an indexing and retrieval layer inside another application
    • -
    • Through the HTTP API or MCP
    • -
    • Through the desktop bootstrap currently in release validation
    • -
    -

    - Dialogue search · Scene search · Actor grouping · CLI · Browser UI · Python API + Local-first video indexing for people, applications, and AI agents.

    - - PyPI version - - - Supported Python versions - - - CI status - - - MIT license - - - Discord - + PyPI version + Supported Python versions + CI status + MIT license + Discord

    -## Why VidXP +## What VidXP does -Finding one moment in a video should not require scrubbing through the entire -timeline. VidXP builds a searchable index from three kinds of evidence: +VidXP turns one video or a whole local collection into a persistent, +searchable index. Models run on the worker rather than in the browser, API, or +MCP process, and model downloads happen only after explicit confirmation. -- **Dialogue:** semantic search over timestamped transcripts. -- **Scenes:** text-to-frame search. -- **Actors:** groups similar detected faces and exports a highlighted video for a selected cluster. +| Dialogue | Scenes | Actors | +|---|---|---| +| Transcribes speech and indexes timestamped phrases | Samples video at a source-aware cadence and embeds frames | Detects faces and groups similar appearances within a video | +| Search by meaning, not only exact words | Search with descriptions such as “a red car at night” | Inspect clusters and render highlighted actor-overlay videos | -After the required model weights are available, video processing and search run completely locally, for your privacy and security. +The same repository is available through every supported surface: -Some ideas on how to use VidXP: -- Use it as way to find your favorite relatives in a huge folder of wedding videos (been there, done that) -- Use it in your application, allow users to search videos (an idea: use it alongside a video-editing application) -- Use it as an "understanding" layer so your LLM / agent can understand videos +| CLI | Browser and desktop | HTTP API and MCP | Docker | +|---|---|---|---| +| Import, index, search, query, jobs, and clips | Guided local workflow with visible preparation and job progress | Typed control plane for applications and agents | Local all-in-one image or separated server control/worker images | -[![Video Screenshot](./docs/images/video-screenshot.jpeg)](https://www.linkedin.com/feed/update/urn:li:activity:7343569473720725505/) +Other product-level behavior includes: +- multi-video snapshots with cross-video top-k search; +- optional `media_id` filtering for one-video search; +- durable indexing, search, model-preparation, and artifact jobs; +- managed media, actor overlays, and downloadable video clips; +- grounded natural-language answers that retain timestamped evidence; and +- named repositories for separate searchable collections. -## Current capabilities +[![VidXP browser interface](./docs/images/video-screenshot.jpeg)](https://www.linkedin.com/feed/update/urn:li:activity:7343569473720725505/) -| Capability | Available now | Result | -|---|---|---| -| Dialogue search | Transcription, word alignment, semantic phrase indexing | Matching video time | -| Scene search | Text search over sampled video frames | Matching frame and time | -| Actor grouping | Within-video face detection and clustering | Clustered detections and highlighted output video | -| Interfaces | Typer CLI, Streamlit browser interface, Python and HTTP APIs | Interactive or programmatic use | -| Index management | Saved progress, ready/failed state, cancellation, isolated programmatic runs | Traceable and reusable indexes | +## Start here: local browser app -## Quick start - -VidXP supports Python 3.11 through 3.14. Media operations require the -`ffmpeg` and `ffprobe` executables plus the `libx264` and `aac` encoders. -Python wheels intentionally do not install operating-system packages. Run the -guided initialization once before local media work: +This is the native end-user sequence. It installs the CPU worker and browser +interface from PyPI into an isolated virtual environment. Install +[uv](https://docs.astral.sh/uv/getting-started/installation/) first, then verify +it is available: ```bash -uv tool install vidxp -vidxp init +uv --version ``` -For a disposable CLI environment, use `uvx vidxp init`. The command verifies -the executables/codecs, shows the exact supported package-manager command when -installation is needed, asks before running it in an interactive terminal, and -persists the verified absolute executable paths in VidXP's per-user -configuration. It never installs without confirmation or an explicit `--yes`. -`vidxp doctor` remains read-only. See the -[installation guide](INSTALLATION_GUIDE.md) for platform-specific -local-worker installation, model preparation, and troubleshooting. - -Install the lightweight command line in an isolated environment with -[pipx](https://packaging.python.org/en/latest/guides/installing-stand-alone-command-line-tools/). +### 1. Create and activate the environment ```bash -pipx install vidxp +uv venv --python 3.14 ``` -For a local CPU worker and browser UI, a source checkout is the strictest -cross-platform path because the lock routes only Torch through its official CPU -index on Linux and Windows: +| Windows PowerShell | macOS or Linux | +|---|---| +| `.venv\Scripts\Activate.ps1` | `source .venv/bin/activate` | + +### 2. Install VidXP ```bash -uv sync --frozen --extra local-worker --extra frontend -uv run vidxp init -uv run vidxp doctor +uv pip install --torch-backend cpu "vidxp[local-worker,frontend]" ``` -On Apple Silicon macOS 14+, the published package can be installed directly -because PyPI provides the native CPU/MPS Torch wheel: +### 3. Initialize and prepare ```bash -pipx install "vidxp[local-worker,frontend]" +vidxp init +vidxp prepare +vidxp doctor ``` -Linux and Windows published installs require a staged CPU Torch install; plain -`pipx install "vidxp[local-worker]"` can otherwise resolve PyPI's CUDA-enabled -Linux Torch build. The exact commands are in the -[installation guide](INSTALLATION_GUIDE.md#install-a-published-local-worker). +- `init` verifies FFmpeg, ffprobe, `libx264`, and `aac`. If FFmpeg is missing, + it shows the exact supported package-manager command and asks before running + it. +- `prepare` lists each missing model, download size, cache location, and total + additional space before asking for confirmation. +- `doctor` is read-only. It installs and downloads nothing. -Confirm package and model readiness, then explicitly prepare any missing pinned -models: +### 4. Launch ```bash -vidxp --version -vidxp init -vidxp doctor -vidxp prepare +vidxp ui ``` -`init` is the guided system-dependency setup command. `doctor` never installs -or downloads anything. It reports missing model artifacts and -their pinned sizes, then exits unsuccessfully until the selected capabilities -are prepared. `prepare` shows the missing models, maximum additional -download/cache space, and cache location before requiring confirmation -(`--yes` for non-interactive use). Indexing, API jobs, and MCP tools do not turn -a first request into an implicit model download; model downloads happen only -through `prepare` or the model-preparation job. If FFmpeg is unavailable, local -media commands stop with an actionable `Run vidxp init` error; API and MCP -errors carry the same remediation. +Open `http://localhost:8501` if it does not open automatically. VidXP stores +its repository and model cache in the operating system’s per-user application +data directory, not in the directory where the command was run. -Local commands do not store data in the directory where they were launched. -The CLI, browser UI, local MCP process, and locally launched server use the -operating system's per-user VidXP data directory. See -[Models and local data](#models-and-local-data) for the layout and overrides. +See the [installation guide](INSTALLATION_GUIDE.md) for a CLI-only worker, +MCP, a local API, Docker, macOS-specific installation, and troubleshooting. -## Index and search +## Start here: Docker -Build an index containing dialogue, scene, and actor information: +The local image contains the CPU worker, browser UI, and FFmpeg, but no model +weights. ```bash -vidxp media import samplevideo.mp4 --json -vidxp index create +git clone https://github.com/grayhatdevelopers/vidxp.git +cd vidxp +docker compose pull +docker compose run --rm vidxp vidxp prepare +docker compose up ``` -The import command copies and validates the video in managed local storage and -returns its stable `media_id`. Indexing, search results, and generated artifacts -use that ID instead of exposing repository file paths. +The UI is available at `http://localhost:8501`. The `vidxp-data` volume keeps +media, indexes, jobs, and models across container replacement. + +## Installation profiles + +Install only the surfaces that belong in the same process environment. + +| Profile or extra | Adds | Use it for | +|---|---|---| +| Base `vidxp` | Lightweight CLI, typed contracts, repository configuration, `init` | Version/configuration commands or building a custom client; not local indexing | +| `storage` | Embedded Chroma and host monitoring | Custom local storage integrations | +| `dialogue` | Storage, faster-whisper, sentence-transformers | Dialogue-only local indexing | +| `scene` | Storage, PyTorch, Transformers, OpenCV, Pillow | Scene-only local indexing | +| `actor` | Storage, OpenCV, YuNet/SFace support | Actor-only local indexing | +| `all` | Dialogue, scene, and actor providers | A custom full worker without the query-model client | +| `local-worker` | All providers plus the optional grounded-query client | Recommended complete local worker | +| `frontend` | Streamlit | Browser UI; combine with a local worker | +| `mcp` | MCP SDK | Local stdio MCP; combine with a local worker | +| `server` | FastAPI, remote MCP, PostgreSQL control-plane dependencies | API/MCP control image or a local API when combined with `local-worker` | +| `server-worker` | Server storage client plus every CPU provider | Separated server worker image | +| `benchmarks` | DiDeMo and HiREST adapter dependencies | Evaluation and benchmark development | +| `test` | Pytest and HTTP test client | Contributor validation only | + +Common combinations: + +| Goal | Package selection | Start command | +|---|---|---| +| Local CLI worker | `vidxp[local-worker]` | `vidxp --help` | +| Local browser app | `vidxp[local-worker,frontend]` | `vidxp ui` | +| Local stdio MCP | `vidxp[local-worker,mcp]` | `vidxp-mcp` | +| Local API + remote MCP | `vidxp[local-worker,server]` | `vidxp-api` | +| Everything local | `vidxp[local-worker,frontend,server]` | `vidxp ui` or `vidxp-api` | + +Python package installation never changes operating-system packages. Run +`vidxp init` explicitly for guided FFmpeg setup. + +## Index and search from the CLI -Search the completed index: +After initialization and model preparation: ```bash -vidxp media list +vidxp media import samplevideo.mp4 --json +vidxp index create vidxp search dialogue "the bread just came out of the oven" vidxp search scene "a yellow taxi on a city street" --top-k 5 -vidxp search scene "a yellow taxi" --media-id -vidxp query "What happens after the taxi arrives?" --media-id -vidxp actors list -vidxp actors render --json -vidxp artifacts snippet 30 45 --json -vidxp artifacts show -vidxp artifacts download ./clip.mp4 +vidxp query "What happens after the taxi arrives?" ``` -`vidxp media list` returns registered filenames and stable IDs. Search and -query omit `--media-id` by default, which ranks evidence across every media -item in the active index snapshot. Add `--media-id` to restrict either command -to one video. `vidxp media show ` returns that video's complete -registered metadata, and every ranked result identifies its source `media_id`. - -Each successful `vidxp index create` adds a new video to the active snapshot or -replaces that video's existing generation; it does not discard the other -indexed videos. `vidxp index list` joins active membership to registered -filenames and metadata, while `vidxp index status` shows the underlying snapshot -state. `vidxp index remove ` removes one item. Use separate named -repositories when you need separate searchable video collections. +The import command copies and validates the video in managed storage and +returns a stable `media_id`. -Search and query results include a `media_id` and evidence time range. The HTTP -API can pass those values to `POST /api/v1/jobs/snippet`, poll the returned job, -and download the completed artifact from -`GET /api/v1/artifacts/{artifact_id}/content`. MCP clients use the equivalent -`create_clip`, `get_job`, and `get_artifact_download` sequence; video bytes are -read only when the client opens the returned resource link. +- Omit `--media-id` to rank evidence across every video in the active snapshot. +- Add `--media-id ` to restrict `search` or `query` to one video. +- Run `vidxp media list` to rediscover filenames and IDs. +- Run `vidxp index list` to see which registered videos are searchable. +- Re-indexing one video replaces only that video’s generation. +- Use separate named repositories when collections must not be searched + together. -Index only selected capabilities or adjust the time-based scene sampling rate: +Index selected capabilities or change scene detail: ```bash -vidxp index create --modality scene --scene-sample-fps 1 +vidxp index create \ + --modality scene \ + --scene-sample-fps 1 ``` -Repeat `--modality` to combine `dialogue`, `scene`, and `actor`. -Scene sampling is normalized to the source frame rate; lower-FPS videos use -every available frame without duplicating frames. `--frame-stride` remains the -independent actor/legacy visual sampling control. -Scene indexes created before time-based sampling must be rebuilt before adding -or replacing media under the new sampling profile. -Indexing, artifact rendering, and model preparation run as durable background -jobs. Commands wait by default; add `--detach` to return after queueing, then -inspect or control the job separately: +Scene sampling is time-based and normalized to the source frame rate. +Lower-frame-rate videos use every available frame rather than duplicating +frames. + +### Jobs and downloadable clips + +Long-running commands wait by default. Add `--detach`, then inspect the durable +job: ```bash vidxp jobs list @@ -225,170 +201,120 @@ vidxp jobs cancel vidxp jobs retry ``` -Run `vidxp --help` or any command followed by `--help` for the complete command -reference. - -Use named repositories to keep index locations and devices centrally -configured: +Create and export a result clip: ```bash -vidxp repositories add team --index-dir ./indexes/team --device cuda --use -vidxp repositories list +vidxp artifacts snippet 30 45 --json +vidxp artifacts download ./clip.mp4 ``` -## Browser interface +Run `vidxp --help` or `vidxp --help` for the complete command +reference. + +## MCP for local agents -Install the `frontend` extra and start: +Install the local worker and MCP extra in the same environment, prepare the +models, and print client-ready configuration: ```bash -vidxp ui +uv pip install --torch-backend cpu "vidxp[local-worker,mcp]" +vidxp init +vidxp prepare +vidxp mcp-config ``` -The command uses the active named repository, starts a local Streamlit server, -and remains active until stopped. -The interface can upload a video, start or cancel indexing, restore saved -progress after a page reload, and search the capabilities available in the -completed index. - -## HTTP API +`vidxp mcp-config` prints a complete `mcpServers` JSON object containing the +absolute `vidxp-mcp` executable path. Copy the object into any stdio MCP client. -Install the server profile and run the app factory: +Verify the handshake, tool discovery, repository paths, and a read-only status +call: ```bash -uv sync --frozen --extra server -uv run vidxp-api +vidxp-mcp --check --repository default ``` -The local default binds to `127.0.0.1:8000` without network authentication. -Its OpenAPI document and interactive reference are available at -`/openapi.json` and `/docs`. - -The API process is a model-free control plane. Under `/api/v1`, it exposes -bounded media import and delivery, capability metadata, index status, durable -job submission and control, artifact delivery, and authenticated readiness. -Indexing and other model work execute only through DBOS workers. Remote search -and grounded natural-language queries are submitted as durable jobs so model -work never runs in the API/MCP process. `POST /api/v1/jobs/query` uses the same -typed command and result as `vidxp query` and the MCP `query_video` tool. - -Large remote media uses an upload intent plus the deployment profile's `tusd` -service. Create the intent at `/api/v1/media/uploads`, then use the returned -creation URL and `Upload-Metadata` with a normal tus client. Poll the intent URL -until it supplies a `media_id`. The multipart compatibility endpoint remains -capped at 256 MiB. Media imports, upload intents, job creation, and job retries -require an opaque `Idempotency-Key`; keys are scoped to the stable repository -identity, authenticated subject, and operation. +Agents can discover media, index videos, search across the active snapshot, +ask grounded questions, create clips, poll jobs, and retrieve artifact +download links. MCP does not carry video bytes or expose server filesystem +paths. + +## HTTP API + +For a local API backed by the same local worker: ```bash -curl -F "upload=@samplevideo.mp4" \ - -H "Idempotency-Key: import-samplevideo-2026-07-28" \ - http://127.0.0.1:8000/api/v1/media -curl -X POST http://127.0.0.1:8000/api/v1/jobs/index \ - -H "Content-Type: application/json" \ - -H "Idempotency-Key: 12345678-1234-4234-8123-123456789abc" \ - -d '{"media_id":"","modalities":["scene"],"scene_sample_fps":1.0}' +uv pip install --torch-backend cpu "vidxp[local-worker,server]" +vidxp init +vidxp prepare +vidxp-api ``` -Non-loopback and server-mode deployments must configure static bearer or OIDC -authentication. Static mode requires a token of at least 32 characters. OIDC -mode validates a fixed issuer, audience, asymmetric algorithm allowlist, JWKS -URL, token lifetime, subject, and configured baseline scopes through PyJWT. -Routes then enforce repository-wide `vidxp.read`, `vidxp.write`, or -`vidxp.admin` authorization. These are shared-repository permissions, not -per-media ownership rules. - -```text -VIDXP_MODE=server -VIDXP_RUNTIME_BACKEND=cpu -VIDXP_HTTP_BIND_HOST=0.0.0.0 -VIDXP_HTTP_AUTH_MODE=static -VIDXP_HTTP_STATIC_BEARER_TOKEN= -VIDXP_HTTP_TRUSTED_HOSTS=["api.example.com"] -VIDXP_MCP_ALLOWED_HOSTS=["api.example.com"] -``` +The default server binds to `127.0.0.1:8000`. -Server mode is supported through the bundled Compose topology and always connects -to its internal `postgres` and `chroma` services. Database and Chroma endpoints are -not user-configurable settings. +| Reference | URL | +|---|---| +| Interactive OpenAPI | `http://127.0.0.1:8000/docs` | +| OpenAPI document | `http://127.0.0.1:8000/openapi.json` | +| Health | `http://127.0.0.1:8000/health` | +| Readiness | `http://127.0.0.1:8000/ready` | +| Streamable HTTP MCP | `http://127.0.0.1:8000/mcp` | -Use `Authorization: Bearer ` for every route except `/health` and -`/ready`. Configure `VIDXP_HTTP_ALLOWED_ORIGINS` only for browser origins that -must call `/api/*`; MCP has its own Host and Origin policy. Authenticated profiles -keep the interactive `/docs` UI disabled, while the protected `/openapi.json` -contract remains available to authenticated clients. +The API exposes media ingestion and delivery, capability discovery, durable +jobs, index state, search/query jobs, and artifacts under `/api/v1`. Remote +uploads larger than the multipart limit use the server deployment’s resumable +tus endpoint. -## MCP +Non-loopback deployments must use static bearer or OIDC authentication. Use +the supported [Coolify/Compose topology](docs/deployment/coolify.md) instead of +turning a local process into an ad-hoc public server. -The server profile exposes a curated Streamable HTTP endpoint at `/mcp`. Its -tools list capabilities, inspect index state, submit idempotent indexing and -search jobs, poll jobs, and explicitly cancel jobs. The MCP adapter calls the -same application and durable-job services as the CLI and API; it does not mirror -OpenAPI or call the HTTP API internally. +## Containers and server deployment -Remote video bytes still use HTTP/tus. Upload and resume the video, wait for a -`media_id`, and then pass that ID to `start_indexing`. MCP never carries video -bytes, base64 video, or server file paths. The `start_indexing` command accepts -the same `scene_sample_fps` field as the HTTP API. +Stable releases publish three Linux/amd64 images from the same release commit: -Static deployments use the configured bearer token as an MCP request header. -OIDC deployments must also set the canonical resource URL: +| Image | Tag | Contains | Intended use | +|---|---|---|---| +| Local | `ghcr.io/grayhatdevelopers/vidxp:` and `latest` | CPU local worker, Streamlit, FFmpeg | One-machine browser app with `compose.yaml` | +| Control | `ghcr.io/grayhatdevelopers/vidxp:-control` | FastAPI, remote MCP, migrations, hooks | Public API/MCP control plane | +| Worker | `ghcr.io/grayhatdevelopers/vidxp:-worker` | CPU providers and HTTP Chroma client | Model-backed server jobs | -```text -VIDXP_MCP_PUBLIC_URL=https://api.example.com/mcp -VIDXP_MCP_ALLOWED_HOSTS=["api.example.com"] -VIDXP_MCP_ALLOWED_ORIGINS=[] -``` +`compose.coolify.yaml` combines the immutable control and worker images with +pinned PostgreSQL, Chroma, tusd, and an optional Ollama service. It is the +supported server topology for Coolify and ordinary Docker Compose: -For adjacent local agents, install the MCP extra and use stdio: +- one API/MCP service; +- one CPU worker; +- resumable remote uploads; +- persistent database, vector, media, and model volumes; and +- static bearer or OIDC authentication. -```bash -pipx install "vidxp[local-worker,mcp]" -vidxp mcp-config -``` +Follow the [Coolify deployment guide](docs/deployment/coolify.md) for required +secrets, hostnames, health checks, proxy headers, and backup boundaries. -`vidxp mcp-config` prints import-ready `mcpServers` JSON containing the resolved -absolute `vidxp-mcp` executable path and `--repository default`. Copy the whole -object into a stdio MCP client's JSON configuration. `vidxp-mcp --print-config` -emits the same JSON, while -`vidxp-mcp --help` includes a ready-to-copy example alongside all server -options. +## Desktop application -Verify the installed MCP adapter, selected repository, protocol handshake, tool -discovery, and a read-only index-status tool call without leaving a server -running: +The Tauri desktop application provides a guided first-run installer and then +launches the same local browser interface: -```bash -vidxp-mcp --check --repository default -``` +- checks FFmpeg before downloading Python or VidXP; +- asks before running a supported system package manager; +- installs an app-owned Python/runtime profile; +- optionally prepares selected models; and +- activates the runtime only after `vidxp doctor` passes. -The stdio process owns its local repository/job lifecycle and exits cleanly when -the client closes the transport. +Desktop packaging is currently a preview built from source; signed installer +publication is still under release validation. See +[Desktop application](docs/desktop.md). -MCP agents discover registered filenames and IDs with `list_media`, then inspect -active indexed membership with `get_index_status`. For `search_moments` and -`query_video`, setting `command.media_id` restricts results to that video; -omitting it searches or queries every media item in the active index snapshot. +## Python API -## Container - -Stable releases are available from GitHub Container Registry. Start the local -interface with persistent index and model storage by running: +VidXP can also be embedded as a Python indexing/retrieval layer. Install only +the capability extras your application uses: ```bash -docker compose up +uv pip install --torch-backend cpu "vidxp[scene]" ``` -See the [installation guide](INSTALLATION_GUIDE.md#run-the-container) for model -preparation, configuration, and direct `docker run` usage. - -For the prebuilt API/worker deployment, see the -[Coolify deployment guide](docs/deployment/coolify.md). - -## Use VidXP as a Python package - -The programmatic API supports isolated multi-video runs, supplied timestamped -transcripts, resumable per-video checkpoints, and metadata-rich top-k results. - ```python from vidxp.core import IndexConfig, VideoSource from vidxp.core.runner import run_index @@ -403,108 +329,77 @@ config = IndexConfig( ) run_index( - [ - VideoSource(video_id="video-1", path="videos/first.mp4"), - VideoSource(video_id="video-2", path="videos/second.mp4"), - ], + [VideoSource(video_id="video-1", path="videos/first.mp4")], config, ) -results = search_scene("a person enters a taxi", config=config, top_k=5) -for hit in results.hits: +for hit in search_scene("a person enters a taxi", config=config, top_k=5).hits: print(hit.video_id, hit.start, hit.end, hit.score) ``` -The [Python indexing and retrieval contract](docs/benchmarking/core_contract.md) -documents configuration, stored metadata, result fields, and run layout. - -## Recommended specs +See the [Python indexing contract](docs/benchmarking/core_contract.md) for +configuration, checkpoints, result schemas, and run layout. -> Coming soon +## Models, downloads, and local data ---- +Pinned model downloads are disclosed before preparation: -## Roadmap +| Capability | Models | Approximate download | +|---|---|---:| +| Dialogue | Qwen3 Embedding 0.6B + faster-whisper large-v3-turbo | 2.64 GiB | +| Scene | SigLIP2 base patch16-224 | 1.43 GiB | +| Actor | OpenCV Zoo YuNet + SFace | 37 MiB | -VidXP is an evolving beta. We'd love to hear your feedback and where you'd like to see the project go. +Actual installed and cached disk use can be higher. `vidxp prepare` calculates +what is missing on the current machine and asks before downloading it. -| Area | Current foundation | Direction | -|---|---|---| -| Search results | Top result in the CLI; structured top-k Python results | Rich ranked results, metadata, previews, and filtering across interfaces | -| Temporal search | Frame and transcript-phrase timestamps | Better time ranges, scene boundaries, aggregation, and ranking | -| Video collections | Validated managed-media catalog and persistent multi-video snapshots | Remote resumable ingestion and richer library management | -| Actor workflows | Face clustering and highlighted video export | Cluster browsing, labeling, actor search, and stronger tracking | -| Speaker context | Timestamped dialogue search | Active-speaker detection and links between speech and visible people | -| Product experience | CLI and browser indexing/search | Clearer progress, result navigation, recovery, and long-running job controls | -| Evaluation | DiDeMo and HiREST baselines | Combined and component benchmarks, beginning with a LongVALE pilot | - -## Models and local data +Local repositories and models are independent of the shell’s current +directory and Python environment: -| Capability | Model | +| Platform | Default data root | |---|---| -| Dialogue embeddings | `Qwen/Qwen3-Embedding-0.6B` | -| Transcription | `dropbox-dash/faster-whisper-large-v3-turbo` | -| Scene search | `google/siglip2-base-patch16-224` | -| Actor detection | OpenCV Zoo YuNet | -| Actor recognition | OpenCV Zoo SFace | - -Local installs use the operating system's per-user VidXP data directory, -independent of the shell's current directory and the Python virtual environment: - -```text -VidXP/ - repositories/ - default/ - models/ -``` +| Windows | `%LOCALAPPDATA%\VidXP` | +| macOS | `~/Library/Application Support/VidXP` | +| Linux | `${XDG_DATA_HOME:-~/.local/share}/VidXP` | -On Windows the default root is `%LOCALAPPDATA%\VidXP`; on macOS it is -`~/Library/Application Support/VidXP`; and on Linux it is -`${XDG_DATA_HOME:-~/.local/share}/VidXP`. Named-repository configuration is a -small separate file in the operating system's standard user configuration -directory (for example, `%APPDATA%\VidXP\repositories.json` on Windows). +The default repository is under `repositories/default/`; models are under +`models/`. Use `--data-dir` for one CLI invocation or `VIDXP_DATA_DIR` for all +local entry points. Docker uses its declared volumes instead. -Use the global `--data-dir` option for a `vidxp` invocation, or set -`VIDXP_DATA_DIR` for the CLI, browser UI, local MCP, and locally launched server: +## Current release boundaries -```bash -vidxp --data-dir /path/to/vidxp-data ui -``` +- CPU is the supported default. GPU execution is explicitly deferred. +- Model preparation is explicit; first indexing/search requests never begin + hidden downloads. +- The desktop app is a preview until signed installers are published. +- The server Compose topology is a single-node, single-repository deployment; + multi-replica failover and hosted database substitutions are not claimed. +- Actor identity is within-video face grouping, not real-world identity + recognition. -`VIDXP_INDEX_DIR` and `VIDXP_MODEL_CACHE` remain advanced overrides for those -individual locations. Docker and Compose use their explicitly configured -volumes instead of the host's per-user directory. - -Each local import is streamed into managed repository storage, validated with -`ffprobe`, and published through the repository catalog before it can be -indexed. Each successful indexing run creates an immutable generation and -atomically publishes a multi-media snapshot. Actor overlay videos are immutable -cataloged artifacts. Re-indexing replaces only that media item's active -generation; removing or clearing media publishes a new snapshot without -deleting retained generations or media. Failed and cancelled runs do not -replace the active snapshot. Provider revisions and weight checksums are pinned -in capability specs and recorded in each generation manifest. - -## Documentation and project links - -- [Installation and troubleshooting](INSTALLATION_GUIDE.md) -- [Benchmarking status and results](docs/benchmarking/README.md) -- [Adding a capability](docs/adding-a-capability.md) -- [Changelog](CHANGELOG.md) -- [Issue tracker](https://github.com/grayhatdevelopers/vidxp/issues) -- [MIT license](LICENSE) +## Documentation +| Use VidXP | Build or evaluate VidXP | +|---|---| +| [Installation and troubleshooting](INSTALLATION_GUIDE.md) | [Contribution guide](docs/CONTRIBUTING.md) | +| [Coolify deployment](docs/deployment/coolify.md) | [Adding a capability](docs/adding-a-capability.md) | +| [Desktop preview](docs/desktop.md) | [Benchmarking](docs/benchmarking/README.md) | +| [Changelog](CHANGELOG.md) | [Release process](docs/releasing.md) | ## Contributing -See [CONTRIBUTING.md](./docs/CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs. AI/vibe-coded PRs welcome! +Contributions are welcome. Please read the +[contribution guide](docs/CONTRIBUTING.md) before opening a pull request. ## Credits -Built by Grayhat Developers PVT Ltd. 2026. Maintained by the community. +Built by Grayhat Developers PVT Ltd. and maintained by the community. -Email: info@grayhat.studio +- Email: info@grayhat.studio +- [Issue tracker](https://github.com/grayhatdevelopers/vidxp/issues) +- [Discord](https://grayhat.studio/discord) +- [MIT license](LICENSE) - + VidXP contributors diff --git a/changes/+product-interfaces.feature.md b/changes/+product-interfaces.feature.md new file mode 100644 index 0000000..a7396b3 --- /dev/null +++ b/changes/+product-interfaces.feature.md @@ -0,0 +1 @@ +Make the same video workflow available through a user-friendly CLI, browser UI, Python API, local and remote MCP, a versioned HTTP API, a desktop setup preview, and an all-in-one local container. diff --git a/changes/+public-transports.security.md b/changes/+public-transports.security.md new file mode 100644 index 0000000..17f9036 --- /dev/null +++ b/changes/+public-transports.security.md @@ -0,0 +1 @@ +Protect public HTTP and MCP transports with static bearer or OIDC authentication, scoped authorization, trusted Host and Origin policies, bounded request bodies, safe error envelopes, and confined media/artifact delivery. diff --git a/changes/+runtime-setup.feature.md b/changes/+runtime-setup.feature.md new file mode 100644 index 0000000..614207f --- /dev/null +++ b/changes/+runtime-setup.feature.md @@ -0,0 +1 @@ +Add explicit FFmpeg and model preparation with disclosed download sizes, confirmation, live progress, per-user application-data defaults, actionable readiness checks, and no hidden first-request downloads. diff --git a/changes/+server-deployment.feature.md b/changes/+server-deployment.feature.md new file mode 100644 index 0000000..be2ce75 --- /dev/null +++ b/changes/+server-deployment.feature.md @@ -0,0 +1 @@ +Add a production-oriented single-node Compose/Coolify topology with separate control and CPU-worker images, PostgreSQL, Chroma, resumable tus uploads, persistent volumes, health gates, and optional explicitly prepared grounded-query models. diff --git a/changes/+video-library.feature.md b/changes/+video-library.feature.md new file mode 100644 index 0000000..b9b7e32 --- /dev/null +++ b/changes/+video-library.feature.md @@ -0,0 +1 @@ +Turn VidXP into a persistent multi-video search library with managed media, immutable index snapshots, dialogue/scene/actor retrieval, cross-video ranking, grounded questions, durable jobs, and downloadable overlay or clip artifacts. diff --git a/changes/19.bugfix.md b/changes/19.bugfix.md deleted file mode 100644 index 2c79f5a..0000000 --- a/changes/19.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Prevent dependency updates and label changes from spawning redundant CI runs. diff --git a/changes/README.md b/changes/README.md index c264b27..92d7d79 100644 --- a/changes/README.md +++ b/changes/README.md @@ -1,15 +1,17 @@ # Changelog fragments -Every pull request with a user-visible change must add one short fragment here. -Use the pull request number and the most specific type: +Every pull request with a user-visible change normally adds one short fragment +here. Describe the difference from the latest public release—not every +intermediate implementation step. Use the pull request number and the most +specific type: ```text changes/..md ``` Supported types are `breaking`, `feature`, `bugfix`, `deprecation`, `docs`, and -`security`. Write one sentence for users in the imperative voice and do not add -a heading or the version number. +`security`. Write for users in the imperative voice and do not add a heading or +the version number. Example: @@ -21,9 +23,16 @@ changes/123.feature.md Add named repositories for selecting shared index locations and devices. ``` +Use `bugfix` only for behavior that was wrong in the latest public release. If +an unreleased feature changes while it is being built, update or consolidate +its pending feature note instead of stacking fictional public fixes. Do not +create one fragment per internal commit. + Dependency updates marked `dependencies` do not require a fragment. Other internal changes may omit one when a maintainer applies `skip-changelog` and -the pull request explains why. +the pull request explains why. Maintainers may use an issue-less +`changes/+..md` fragment when consolidating a release; normal pull +requests should use their numeric identifier. Towncrier renders the pending fragments for prerelease notes, then collects and removes them when a stable release is created. Do not edit `CHANGELOG.md` diff --git a/changes/actor-cluster-pagination.bugfix.md b/changes/actor-cluster-pagination.bugfix.md deleted file mode 100644 index 1e75e3a..0000000 --- a/changes/actor-cluster-pagination.bugfix.md +++ /dev/null @@ -1,2 +0,0 @@ -Materialize actor-cluster summaries during indexing so cluster pages read only the -requested page instead of rescanning every retained face detection. diff --git a/changes/actor-detection-pagination.bugfix.md b/changes/actor-detection-pagination.bugfix.md deleted file mode 100644 index 38d80e8..0000000 --- a/changes/actor-detection-pagination.bugfix.md +++ /dev/null @@ -1,2 +0,0 @@ -Advance actor-detection pages with stable cursors instead of rescanning earlier -detections. diff --git a/changes/actor-jobs.bugfix.md b/changes/actor-jobs.bugfix.md deleted file mode 100644 index 8d87694..0000000 --- a/changes/actor-jobs.bugfix.md +++ /dev/null @@ -1,2 +0,0 @@ -Keep actor-overlay commands limited to the stable composite cluster identifier -and bind durable execution to the immutable snapshot selected at submission. diff --git a/changes/api-openapi-auth.bugfix.md b/changes/api-openapi-auth.bugfix.md deleted file mode 100644 index e8efab1..0000000 --- a/changes/api-openapi-auth.bugfix.md +++ /dev/null @@ -1,2 +0,0 @@ -Declare bearer authentication in OpenAPI and expose the schema only to -authenticated clients when HTTP authentication is enabled. diff --git a/changes/api-readiness.bugfix.md b/changes/api-readiness.bugfix.md deleted file mode 100644 index c693946..0000000 --- a/changes/api-readiness.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Include dependency and owned-worker health in runtime readiness checks. diff --git a/changes/api-schema-validation.bugfix.md b/changes/api-schema-validation.bugfix.md deleted file mode 100644 index 1e009b8..0000000 --- a/changes/api-schema-validation.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Bound search queries and actor identifiers in the shared request schemas. diff --git a/changes/application-boundary.bugfix.md b/changes/application-boundary.bugfix.md deleted file mode 100644 index f0ecf35..0000000 --- a/changes/application-boundary.bugfix.md +++ /dev/null @@ -1,4 +0,0 @@ -Application adapters now share one control-plane implementation for capability, -media, artifact, and shallow index-status operations. Local CLI composition now -loads model and workflow services only when a command uses them and closes opened -workflow clients through the Typer command lifecycle. diff --git a/changes/artifact-catalog.feature.md b/changes/artifact-catalog.feature.md deleted file mode 100644 index e8f367d..0000000 --- a/changes/artifact-catalog.feature.md +++ /dev/null @@ -1,8 +0,0 @@ -Add managed actor-overlay artifacts: - -- publish actor overlay videos under opaque artifact IDs -- bind overlays to their media and index-generation identity -- validate rendered video before marking an artifact ready -- enforce the requested output profile before publication -- resolve source playback and artifact delivery through confined internal handles -- keep storage keys and local paths out of shared command and result schemas diff --git a/changes/benchmark-cli.bugfix.md b/changes/benchmark-cli.bugfix.md deleted file mode 100644 index 51510ea..0000000 --- a/changes/benchmark-cli.bugfix.md +++ /dev/null @@ -1,2 +0,0 @@ -Keep the DiDeMo benchmark command available when the optional HiREST subtitle -parser is not installed. diff --git a/changes/benchmark-dependencies.bugfix.md b/changes/benchmark-dependencies.bugfix.md deleted file mode 100644 index 6d53c63..0000000 --- a/changes/benchmark-dependencies.bugfix.md +++ /dev/null @@ -1,2 +0,0 @@ -Validate benchmark runtime dependencies at invocation and report the exact optional -extras needed for DiDeMo and HiREST. diff --git a/changes/coolify-deployment.feature.md b/changes/coolify-deployment.feature.md deleted file mode 100644 index 2785fca..0000000 --- a/changes/coolify-deployment.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add purpose-built control and worker images plus a prebuilt-image Coolify Compose deployment. diff --git a/changes/coolify-shutdown.bugfix.md b/changes/coolify-shutdown.bugfix.md deleted file mode 100644 index ec8d90b..0000000 --- a/changes/coolify-shutdown.bugfix.md +++ /dev/null @@ -1,2 +0,0 @@ -Give DBOS workers and tusd enough Compose shutdown grace time to complete their -configured drains, and document the one-repository-per-stack storage boundary. diff --git a/changes/cursor-pagination.bugfix.md b/changes/cursor-pagination.bugfix.md deleted file mode 100644 index f72b79a..0000000 --- a/changes/cursor-pagination.bugfix.md +++ /dev/null @@ -1,2 +0,0 @@ -Reject malformed, noncanonical, or overflowing opaque cursors and freeze descending -job-list pagination so concurrent submissions do not shift later pages. diff --git a/changes/desktop.feature.md b/changes/desktop.feature.md deleted file mode 100644 index d4d8219..0000000 --- a/changes/desktop.feature.md +++ /dev/null @@ -1,7 +0,0 @@ -### Desktop installer - -- Added a minimal Tauri desktop installer and supervisor that installs exact - capability extras from PyPI into an app-owned uv runtime. -- Added atomic runtime activation, optional model preparation, platform - app-data paths, loopback-only UI launch, and graceful local-worker shutdown. -- Kept FFmpeg packaging as an explicit provenance and licensing gate. diff --git a/changes/durable-jobs.feature.md b/changes/durable-jobs.feature.md deleted file mode 100644 index 3dd7161..0000000 --- a/changes/durable-jobs.feature.md +++ /dev/null @@ -1,7 +0,0 @@ -Add durable background jobs: - -- run indexing, snippets, actor overlays, and model preparation through DBOS workflows -- use the same typed job, progress, cancellation, and result contracts locally and on servers -- persist local jobs in SQLite and support PostgreSQL-backed worker deployments -- keep CPU and GPU work on explicit queues with concurrency controlled by DBOS -- supervise the detached local worker without creating a second job-state store diff --git a/changes/durable-read-snapshots.bugfix.md b/changes/durable-read-snapshots.bugfix.md deleted file mode 100644 index b4546a0..0000000 --- a/changes/durable-read-snapshots.bugfix.md +++ /dev/null @@ -1,3 +0,0 @@ -Pin durable search and actor-overlay jobs to the exact immutable index snapshot -selected when the job is submitted. Actor jobs retain the stable composite -cluster identifier rather than duplicating its media and generation fields. diff --git a/changes/durable-search-compatibility.bugfix.md b/changes/durable-search-compatibility.bugfix.md deleted file mode 100644 index ce2c1c3..0000000 --- a/changes/durable-search-compatibility.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Version fused search workflows separately while keeping legacy atomic-search jobs readable, recoverable, and retryable. diff --git a/changes/durable-search.feature.md b/changes/durable-search.feature.md deleted file mode 100644 index f4768fb..0000000 --- a/changes/durable-search.feature.md +++ /dev/null @@ -1,2 +0,0 @@ -Run model-backed scene and dialogue searches through the shared durable worker -boundary for CLI, HTTP, and Streamlit clients. diff --git a/changes/explicit-model-readiness.bugfix.md b/changes/explicit-model-readiness.bugfix.md deleted file mode 100644 index d05e85e..0000000 --- a/changes/explicit-model-readiness.bugfix.md +++ /dev/null @@ -1,7 +0,0 @@ -Report missing model artifacts in doctor and runtime readiness, restrict -downloads to explicit preparation jobs with live progress, and skip dialogue -cleanly for videos without audio. - -Disclose pinned model download/cache sizes and require UI or CLI confirmation -before preparation. Use bounded HTTP downloads instead of Xet transfers that -can remain parked at zero bytes. diff --git a/changes/frontend-capability-details.bugfix.md b/changes/frontend-capability-details.bugfix.md deleted file mode 100644 index 0b635f8..0000000 --- a/changes/frontend-capability-details.bugfix.md +++ /dev/null @@ -1,2 +0,0 @@ -Resolve query-capable modalities from detailed capability metadata so the -browser UI can render search controls after indexing completes. diff --git a/changes/frontend-search-clarity.bugfix.md b/changes/frontend-search-clarity.bugfix.md deleted file mode 100644 index aa9de83..0000000 --- a/changes/frontend-search-clarity.bugfix.md +++ /dev/null @@ -1,3 +0,0 @@ -Submit browser searches with Enter, hide internal queue terminology, and -describe scene results as visual-similarity matches rather than precise -first-occurrence answers. diff --git a/changes/frontend-search-form.bugfix.md b/changes/frontend-search-form.bugfix.md deleted file mode 100644 index d1daf04..0000000 --- a/changes/frontend-search-form.bugfix.md +++ /dev/null @@ -1,2 +0,0 @@ -Keep the browser search form submit control available before its first -submission and validate empty queries after Streamlit sends the form values. diff --git a/changes/gpu-evaluation.documentation.md b/changes/gpu-evaluation.documentation.md deleted file mode 100644 index e0ad24e..0000000 --- a/changes/gpu-evaluation.documentation.md +++ /dev/null @@ -1,4 +0,0 @@ -### GPU worker evaluation - -- Recorded the supported CUDA 12.6 dependency intersection, capability routing, - readiness contract, and validation gates without pulling images or models. diff --git a/changes/http-api.feature.md b/changes/http-api.feature.md deleted file mode 100644 index bff28c5..0000000 --- a/changes/http-api.feature.md +++ /dev/null @@ -1,8 +0,0 @@ -Add a versioned thin HTTP API: - -- expose shared capability, index-status, media, artifact, readiness, and - durable-job contracts under `/api/v1` -- compose a model-free control-plane facade instead of constructing the model - runtime, index execution backend, or renderers in the API process -- publish a `vidxp-api` app-factory command without constructing services at - module import diff --git a/changes/http-auth.security.md b/changes/http-auth.security.md deleted file mode 100644 index be745c4..0000000 --- a/changes/http-auth.security.md +++ /dev/null @@ -1,9 +0,0 @@ -Protect the HTTP control plane: - -- support loopback, constant-time static bearer, and cached - PyJWT/JWKS-backed OIDC profiles -- enforce repository-wide read, write, and admin scopes after authentication -- return one safe typed error envelope with correlation IDs, including host, - CORS, validation, and unexpected failures -- enforce trusted hosts, scoped CORS, streamed request-body limits, exact - issuer/audience checks, and JWK-bound asymmetric algorithms diff --git a/changes/http-jobs.feature.md b/changes/http-jobs.feature.md deleted file mode 100644 index 5240b00..0000000 --- a/changes/http-jobs.feature.md +++ /dev/null @@ -1,8 +0,0 @@ -Expose durable work through the HTTP control plane: - -- submit indexing, artifact generation, and model preparation only through the - existing DBOS job service -- derive workflow IDs from opaque repository-, subject-, and operation-scoped - idempotency keys -- make HTTP retries idempotent while preserving the original validated job - request diff --git a/changes/http-media.feature.md b/changes/http-media.feature.md deleted file mode 100644 index 77f0272..0000000 --- a/changes/http-media.feature.md +++ /dev/null @@ -1,9 +0,0 @@ -Add HTTP media transfer primitives: - -- stream small multipart uploads through the shared ingestion service with a - 256 MiB ceiling -- persist subject- and repository-scoped upload idempotency records -- deliver authorized media and artifacts with strong checksum ETags and - Starlette range responses -- keep large resumable uploads assigned to the separate `tusd` deployment - phase diff --git a/changes/index-snapshots.feature.md b/changes/index-snapshots.feature.md deleted file mode 100644 index ebb414b..0000000 --- a/changes/index-snapshots.feature.md +++ /dev/null @@ -1,6 +0,0 @@ -Add durable multi-media index snapshots: - -- build each media item as an immutable generation with an authoritative manifest -- atomically switch the active snapshot only after generation validation -- preserve the previous snapshot when indexing fails or is cancelled -- add CLI support for stable media IDs, incremental re-indexing, removal, and clearing diff --git a/changes/large-local-import.feature.md b/changes/large-local-import.feature.md deleted file mode 100644 index 854c3ea..0000000 --- a/changes/large-local-import.feature.md +++ /dev/null @@ -1,2 +0,0 @@ -Add a local-path video import control so desktop and local UI users can bypass -the browser upload limit for large media. diff --git a/changes/local-runtime-smoke.bugfix.md b/changes/local-runtime-smoke.bugfix.md deleted file mode 100644 index dcfed34..0000000 --- a/changes/local-runtime-smoke.bugfix.md +++ /dev/null @@ -1,3 +0,0 @@ -Allow the detached local worker enough time to initialize, make API help -side-effect free, preserve repeated runtime capability options, and expose -cataloged videos in a fresh UI session. diff --git a/changes/local-runtime-usability.bugfix.md b/changes/local-runtime-usability.bugfix.md deleted file mode 100644 index 2d553ee..0000000 --- a/changes/local-runtime-usability.bugfix.md +++ /dev/null @@ -1,2 +0,0 @@ -Remove the undocumented local free-memory refusal, keep the Windows worker -console hidden, and prevent duplicate video previews during indexing startup. diff --git a/changes/local-storage-durability.bugfix.md b/changes/local-storage-durability.bugfix.md deleted file mode 100644 index 71ba186..0000000 --- a/changes/local-storage-durability.bugfix.md +++ /dev/null @@ -1,3 +0,0 @@ -Managed media and artifact publication now synchronizes each created parent, -both sides of cross-directory replacement, and every managed removal on -platforms that support directory `fsync`. diff --git a/changes/local-worker-lifecycle.bugfix.md b/changes/local-worker-lifecycle.bugfix.md deleted file mode 100644 index fd8933b..0000000 --- a/changes/local-worker-lifecycle.bugfix.md +++ /dev/null @@ -1,17 +0,0 @@ -Local execution processes now receive an explicit non-secret settings -projection through a one-use, owner-readable bootstrap file. HTTP -credentials are excluded from both inherited environment variables and -process arguments. - -Worker hardening also adds: - -- startup backoff after a failed launch -- live bounded rotation for repository-scoped worker logs -- cleanup of timed-out worker processes -- executable, build, provider and execution-configuration identity checks - before reusing a worker -- child-side identity verification and DBOS environment isolation -- stale credential-bootstrap cleanup and an explicit local-worker stop command -- readiness checks against the owned local executor -- fail-fast API startup when the owned local executor cannot start -- preserved internal workflow tracebacks while public job errors remain typed diff --git a/changes/mcp-discovery.feature.md b/changes/mcp-discovery.feature.md deleted file mode 100644 index 5ef70d7..0000000 --- a/changes/mcp-discovery.feature.md +++ /dev/null @@ -1,2 +0,0 @@ -Let MCP clients discover registered media and durable jobs, inspect media -metadata, and retry failed work through the shared application services. diff --git a/changes/mcp-media-usability.feature.md b/changes/mcp-media-usability.feature.md deleted file mode 100644 index 3f05ffd..0000000 --- a/changes/mcp-media-usability.feature.md +++ /dev/null @@ -1 +0,0 @@ -MCP setup now prints import-ready local client JSON, while CLI, MCP, and HTTP discovery clearly distinguish one-video media ID filters from cross-media active-snapshot search. diff --git a/changes/mcp-reserved-path.security.md b/changes/mcp-reserved-path.security.md deleted file mode 100644 index 62d0560..0000000 --- a/changes/mcp-reserved-path.security.md +++ /dev/null @@ -1,2 +0,0 @@ -Protect MCP paths with static bearer or SDK-native OIDC resource-server -authentication plus independent request-size, Host, and Origin policies. diff --git a/changes/mcp-self-check-index-list.feature.md b/changes/mcp-self-check-index-list.feature.md deleted file mode 100644 index 7fb0c24..0000000 --- a/changes/mcp-self-check-index-list.feature.md +++ /dev/null @@ -1 +0,0 @@ -Local installs can now self-check the MCP protocol surface and list active indexed media with their registered filenames and metadata. diff --git a/changes/mcp-server.feature.md b/changes/mcp-server.feature.md deleted file mode 100644 index 8d8fc19..0000000 --- a/changes/mcp-server.feature.md +++ /dev/null @@ -1,3 +0,0 @@ -Add a curated MCP SDK v2 adapter with remote Streamable HTTP, local stdio, -typed structured tools, shared authorization, durable job polling, and -protocol-native errors. diff --git a/changes/media-catalog.feature.md b/changes/media-catalog.feature.md deleted file mode 100644 index cf34bb5..0000000 --- a/changes/media-catalog.feature.md +++ /dev/null @@ -1,6 +0,0 @@ -Add managed local media catalogs: - -- stream and hash local imports without loading whole videos into memory -- validate media with ffprobe before publishing a ready catalog entry -- index registered media by opaque ID instead of public filesystem path -- list media through a bounded cursor page shared by every adapter diff --git a/changes/media-runtime-init.feature.md b/changes/media-runtime-init.feature.md deleted file mode 100644 index 8b6fa77..0000000 --- a/changes/media-runtime-init.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add an explicit `vidxp init` FFmpeg setup contract and run the same preflight before desktop Python or package installation. diff --git a/changes/media-scoped-search.bugfix.md b/changes/media-scoped-search.bugfix.md deleted file mode 100644 index f275778..0000000 --- a/changes/media-scoped-search.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Keep interactive and public searches within the requested media item while preserving immutable snapshot scope. diff --git a/changes/model-providers.feature.md b/changes/model-providers.feature.md deleted file mode 100644 index e1bcbd2..0000000 --- a/changes/model-providers.feature.md +++ /dev/null @@ -1,2 +0,0 @@ -Replace legacy CPU model providers with pinned SigLIP2, Qwen3 Embedding, -faster-whisper, YuNet, and SFace integrations. diff --git a/changes/model-unavailable-error.bugfix.md b/changes/model-unavailable-error.bugfix.md deleted file mode 100644 index b35c85f..0000000 --- a/changes/model-unavailable-error.bugfix.md +++ /dev/null @@ -1,2 +0,0 @@ -Report unavailable model artifacts separately from missing Python -dependencies, with an actionable preparation hint. diff --git a/changes/multimodal-query.feature.md b/changes/multimodal-query.feature.md deleted file mode 100644 index d9500f9..0000000 --- a/changes/multimodal-query.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add durable multimodal search and grounded natural-language queries across the CLI, browser UI, HTTP API, and MCP server. diff --git a/changes/oidc-transport.security.md b/changes/oidc-transport.security.md deleted file mode 100644 index 75d40ba..0000000 --- a/changes/oidc-transport.security.md +++ /dev/null @@ -1,4 +0,0 @@ -OIDC issuer and JWKS endpoints must use HTTPS outside explicit loopback -development addresses. Unsafe URL characters that different HTTP parsers can -interpret inconsistently are rejected without rewriting the exact issuer -identifier used for token validation. diff --git a/changes/publication-foundation.bugfix.md b/changes/publication-foundation.bugfix.md deleted file mode 100644 index 9069b52..0000000 --- a/changes/publication-foundation.bugfix.md +++ /dev/null @@ -1,5 +0,0 @@ -Simplify the unreleased single-repository schema, harden local and remote Chroma -storage contracts, validate FFmpeg dependencies before media work, and make -desktop/server release artifacts resolve and publish from the exact validated -release commit. -Stream timestamped CLI progress and print actionable dependency remediation. diff --git a/changes/remote-ingestion.feature.md b/changes/remote-ingestion.feature.md deleted file mode 100644 index b411f0c..0000000 --- a/changes/remote-ingestion.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add authenticated resumable upload intents, private tusd hooks, durable media import, recovery, retention, and atomic per-principal quotas. diff --git a/changes/scene-sampling.feature.md b/changes/scene-sampling.feature.md deleted file mode 100644 index fe50dee..0000000 --- a/changes/scene-sampling.feature.md +++ /dev/null @@ -1,2 +0,0 @@ -Sample scenes at a media-normalized rate with CLI, API, MCP, and UI controls -while keeping actor cadence independent and rejecting incompatible old indexes. diff --git a/changes/server-persistence.feature.md b/changes/server-persistence.feature.md deleted file mode 100644 index c0b2a7d..0000000 --- a/changes/server-persistence.feature.md +++ /dev/null @@ -1 +0,0 @@ -Use PostgreSQL for the server catalog and immutable snapshot metadata, DBOS in its own schema, and Chroma's HTTP client for vectors. diff --git a/changes/server-storage-readiness.bugfix.md b/changes/server-storage-readiness.bugfix.md deleted file mode 100644 index a374419..0000000 --- a/changes/server-storage-readiness.bugfix.md +++ /dev/null @@ -1,2 +0,0 @@ -Validate server workers against the remote Chroma client profile instead of -requiring the embedded Chroma distribution. diff --git a/changes/snippet-artifacts.feature.md b/changes/snippet-artifacts.feature.md deleted file mode 100644 index bf53f6d..0000000 --- a/changes/snippet-artifacts.feature.md +++ /dev/null @@ -1,5 +0,0 @@ -Add managed video snippets: - -- create source-preserving or broadly compatible MP4 snippets by media ID -- reject intervals outside the source or configured duration limit -- reuse ready output for the same media checksum, interval, and profile diff --git a/changes/standard-local-storage.bugfix.md b/changes/standard-local-storage.bugfix.md deleted file mode 100644 index 7dbfdac..0000000 --- a/changes/standard-local-storage.bugfix.md +++ /dev/null @@ -1,5 +0,0 @@ -Store the default local repository and prepared models beneath the operating -system's per-user VidXP data directory instead of the shell's current directory -across CLI, browser UI, local MCP/server processes, and desktop operation. -Stop the owned local worker when those long-running local interfaces shut down -so closed UI/API/MCP processes do not leave repository workers behind. diff --git a/changes/transport-terminals.bugfix.md b/changes/transport-terminals.bugfix.md deleted file mode 100644 index 403ad51..0000000 --- a/changes/transport-terminals.bugfix.md +++ /dev/null @@ -1,3 +0,0 @@ -Stop polling background UI jobs after success, failure, cancellation, or -disappearance, persist their terminal result for display, and keep retryable -job-backend outages non-terminal. diff --git a/changes/workflow-compatibility.bugfix.md b/changes/workflow-compatibility.bugfix.md deleted file mode 100644 index 9952b44..0000000 --- a/changes/workflow-compatibility.bugfix.md +++ /dev/null @@ -1,2 +0,0 @@ -Bind DBOS workflow compatibility to the installed implementation digest as well as -the package release, preventing changed worker code from recovering older jobs. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 8805bff..189c12b 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,126 +1,251 @@ -# Contribution Guidelines +# Contributing to VidXP -Thanks for contributing to VidXP (Video eXPlain). +Thanks for helping improve VidXP. Contributions may target the local product, +public interfaces, model capabilities, deployment, documentation, or +benchmarks. -## Project layout +## Before you start -| File / path | Role | -|-------------|------| -| `src/vidxp/cli.py` | Typer commands and installed `vidxp` entry point | -| `src/vidxp/application.py` | Reusable application boundary for CLI and future adapters | -| `src/vidxp/capabilities/` | Capability registry, schemas, operations, dependencies, and optional CLI modules | -| `src/vidxp/repositories.py` | Persistent named local-index configuration | -| `src/vidxp/frontend.py` | Streamlit interface launched by `vidxp ui` | -| `src/vidxp/core/` | Capability-neutral storage, media, run state, and execution contracts | -| `src/vidxp/benchmarks/` | Benchmark-specific loaders, prediction adapters, and evaluator calls | -| `pyproject.toml` | Package metadata and Python dependencies | -| `docs/` | Installation-linked guidance, benchmark research, and contribution notes | -| `benchmark_runs/` | Isolated programmatic and benchmark runs (generated; do not commit) | -| Per-user VidXP data root | Generated local repository and prepared models; outside the checkout by default | +- Open an issue before a large cross-capability or architecture change. +- Keep pull requests focused on one user-visible outcome. +- Preserve the shared application contracts: CLI, UI, HTTP, and MCP should not + grow separate implementations of the same operation. +- State whether a storage/model change requires existing repositories to be + rebuilt. +- GPU support is deferred. Do not silently make CUDA the default or add a new + GPU installation path without the documented validation gates. -The full local CLI/UI index uses up to three collections: -`dialogue`, `scene`, and `actor`. Runs containing -selected capabilities create only the collections they need. +## Development setup -## Setup +### 1. Clone and install the complete contributor environment -Follow the [installation guide](../INSTALLATION_GUIDE.md) or install from the -package metadata directly. +```bash +git clone https://github.com/grayhatdevelopers/vidxp.git +cd vidxp +uv sync --frozen \ + --extra local-worker \ + --extra frontend \ + --extra mcp \ + --extra server \ + --extra test \ + --extra benchmarks +``` + +The repository lock selects CPU PyTorch on Linux and Windows. + +### 2. Initialize the media runtime + +```bash +uv run --no-sync vidxp init +``` + +This verifies FFmpeg, ffprobe, `libx264`, and `aac`. It may offer an explicit, +confirmed system package-manager command; Python dependency installation never +changes system packages. + +### 3. Verify the environment ```bash -uv sync --frozen --extra local-worker --extra frontend --extra benchmarks +uv run --no-sync vidxp --version +uv run --no-sync vidxp doctor +uv run --no-sync pytest -q ``` -Verify the environment: +`doctor` may report that model artifacts have not been prepared; that is +expected in a fresh contributor environment. Dependency, import, FFmpeg, or +codec failures are not expected. Prepare only the modality needed for manual +testing: ```bash -vidxp --version -vidxp doctor +uv run --no-sync vidxp prepare --modalities scene +uv run --no-sync vidxp doctor --modalities scene ``` -CLI: `vidxp --help` -UI: `vidxp ui` - -Models default to CPU and are downloaded only by explicit `vidxp prepare` work. -The default repository and model directory are siblings beneath the operating -system's per-user VidXP data root. For deliberately checkout-local development, -pass `--data-dir ./.vidxp` before the command. If a model identifier changes, -update the setup documentation and record the exact identifier in benchmark -results. - -## Where to put work - -- Capability-specific indexing, retrieval, models, schemas, and dependencies: - the matching folder under `src/vidxp/capabilities/`. -- Shared storage, media handling, run state, and execution mechanics: - `src/vidxp/core/`. -- Transport-neutral application operations: `src/vidxp/application.py`. -- Command-line behavior: `src/vidxp/cli.py`. -- Upload and search UX: `src/vidxp/frontend.py`; keep product logic in the - shared application and core modules. -- Official benchmark formats and evaluator calls: `src/vidxp/benchmarks/`. -- Capability dependencies: that capability's `requirements.txt`; wire a new - install extra into `pyproject.toml`. -- Product direction: the roadmap in the main [README](../README.md). - -Prefer small, focused pull requests. If you change how embeddings or metadata -are stored, state whether an existing VidXP repository must be rebuilt. - -## Before you open a PR - -1. Run the automated test suite relevant to the change. -2. Index a short sample video, then try dialogue, scene, and—if touched—actor - search. -3. If you changed the Streamlit app, smoke-test upload, indexing, cancellation, - reload, and search. -4. Confirm that a failed or incomplete generation leaves the previous active - snapshot searchable. -5. For media or artifact changes, verify that public models contain IDs and - metadata only; storage keys and local paths remain behind their ports. -6. Do not commit model weights, generated indexes, benchmark runs, or local - sample media. - -Run the complete automated suite with: +Models and normal local repositories use the operating system’s per-user VidXP +data directory. They are not written into the checkout unless you explicitly +override `--data-dir`. + +## Project map + +| Product/application | Interfaces and deployment | Execution and storage | +|---|---|---| +| `application.py`, `control_plane.py`, `application_models.py` | `cli.py`, `cli_commands/`, `frontend.py` | `job_service.py`, `workflow_*`, `execution.py` | +| `capabilities/` | `api.py`, `api_routes/`, `mcp.py` | `core/`, `infrastructure/`, `ports.py` | +| `media_service.py`, `artifact_service.py`, `query_service.py` | `Dockerfile`, `compose*.yaml`, `desktop/` | `migrations/`, `repository_layout.py`, `settings.py` | + +Supporting areas: + +| Path | Purpose | +|---|---| +| `tests/` | Unit, contract, adapter, integration, and regression tests | +| `src/vidxp/benchmarks/` | Benchmark loaders, adapters, and evaluator calls | +| `docs/benchmarking/` | Benchmark scope, validation, and published results | +| `docs/deployment/` | Server/Coolify and deferred GPU design | +| `src/vidxp/requirements/` | Composable optional dependency groups | +| `changes/` | Pending public release notes | + +Generated environments, model weights, media, indexes, artifacts, +`benchmark_runs/`, build outputs, and local data do not belong in commits. + +## Architecture rules + +### Put logic at the correct boundary + +- Capability-specific models, indexing, retrieval, schemas, and dependencies: + `src/vidxp/capabilities//`. +- Transport-neutral operations and result models: the application/control + plane. +- CLI, Streamlit, HTTP, and MCP: thin input/output adapters. +- Filesystem, Chroma, PostgreSQL, DBOS, FFmpeg, and process supervision: + infrastructure/ports. +- Benchmark-specific data formats and evaluator behavior: benchmarks only. + +### Preserve public contracts + +- Public commands/results use stable IDs and metadata, not storage keys or + local filesystem paths. +- Local and server adapters call the same typed application operations. +- Long-running work crosses the durable job boundary. +- Model downloads happen only through explicit preparation. +- Media and artifact publication remains atomic. +- A failed or cancelled generation must not replace the previous active + snapshot. +- Server control processes remain model-free; provider work belongs in workers. +- Local defaults remain under the per-user VidXP data root, not the current + working directory. + +### Adding or changing a capability + +Follow [Adding a capability](adding-a-capability.md). At minimum: + +- declare the definition and typed operation schemas; +- keep provider dependencies in that capability’s `requirements.txt`; +- pin model identity, revision, checksum, license metadata, and disclosed size; +- wire the package extra in `pyproject.toml`; +- test provider readiness without downloading models; and +- document whether old indexes must be rebuilt. + +## Validation + +Run the smallest relevant checks while iterating, then the complete applicable +gate before opening a pull request. + +| Change | Minimum validation | +|---|---| +| Python logic or contracts | Targeted pytest file + full `pytest -q` | +| CLI | Command help, success path, failure path, JSON output | +| Streamlit | Full-page render, form submit, job progress/terminal state, reload | +| HTTP API | Shared application test + route/auth/error contract | +| MCP | Tool contract + `vidxp-mcp --check` | +| Media/artifacts | Real ffprobe/FFmpeg smoke + path/ID confinement | +| Models/providers | Dependency check, prepared/unprepared states, short real-media smoke | +| Desktop | Rust tests, JavaScript syntax, setup/close lifecycle | +| Docker/Compose | Dockerfile targets, `docker compose config`, health checks | +| Documentation | Commands, relative links, headings, and rendered tables | + +Common commands: ```bash -uv run --frozen pytest +uv run --no-sync ruff check . +uv run --no-sync pytest -q +node --check desktop/web/app.js +docker compose config --quiet ``` +Desktop validation requires the pinned uv sidecar before Rust tests: + +```bash +npm --prefix desktop install +npm --prefix desktop run sidecar:windows +cargo test --manifest-path desktop/src-tauri/Cargo.toml +``` + +Use `npm --prefix desktop run sidecar:unix` on macOS or Linux. Validate +`compose.coolify.yaml` only with a complete deployment test environment; its +required image, secret, hostname, and upload variables intentionally make a +bare `docker compose config` fail. + +Model-backed manual smoke tests should use a short sample and only the +modalities affected by the change: + +```bash +uv run --no-sync vidxp media import samplevideo.mp4 --json +uv run --no-sync vidxp index create --modality scene +uv run --no-sync vidxp search scene "a person enters the room" +``` + +Do not describe mocked adapter tests as end-to-end validation. If a test does +not exercise a real process, provider, codec, database, browser, or protocol +boundary, say so. + ## Pull requests -- Clear title and a few bullets on what / why. -- Note any new env vars, model downloads, or breaking index format changes. -- Link a related issue when there is one. +Use a clear title and a compact description: + +- what users can do after the change; +- why the change is needed; +- important design or compatibility decisions; +- new dependencies, environment variables, downloads, or migrations; +- validation actually performed; and +- screenshots or sample output for user-interface changes. -### Changelog fragments +Use Conventional Commit prefixes for commits, for example: -Add one `changes/..md` file for every user-visible pull -request. Use one of these types: +```text +feat(mcp): add clip artifact discovery +fix(frontend): keep search form submittable +docs: clarify local installation profiles +``` -- `breaking` for incompatible behavior or API changes. -- `feature` for new behavior. -- `bugfix` for corrected behavior. -- `deprecation` for behavior scheduled for removal. -- `docs` for user-facing documentation improvements. -- `security` for security fixes or hardening users should know about. +## Release-note fragments -The fragment should be one sentence written for users. Do not include a heading, -version number, commit message, or implementation details. See -[`changes/README.md`](../changes/README.md) for an example. +Every user-visible pull request normally adds one +`changes/..md` file. Supported types are: + +| Type | Use it for | +|---|---| +| `breaking` | Incompatible public behavior or API changes | +| `feature` | New user-visible behavior | +| `bugfix` | Behavior that was wrong in the latest public release | +| `deprecation` | Public behavior scheduled for removal | +| `docs` | Material user-facing documentation changes | +| `security` | Security fixes or hardening users should know about | + +Write for users, not for the implementation history. + +- Describe the delta from the latest public release. +- Do not label a correction to an unreleased feature as a separate public bug + fix. Update/consolidate the feature’s pending note instead. +- Do not create one fragment per internal commit. +- Keep implementation details, test repairs, and intermediate refactors out of + release notes unless they change a public contract. +- Use concise bullets when one release-level feature contains several related + capabilities. + +Example: + +```markdown +Add local and remote MCP support with media discovery, cross-video search, +durable job control, and downloadable clip resources. +``` -Dependency updates carrying GitHub's `dependencies` label do not require a -fragment. For other internal-only maintenance, explain the reason in the pull -request and ask a maintainer to apply `skip-changelog`. CI requires a fragment -unless one of those labels is present. +Dependency-only and internal maintenance changes may omit a fragment when the +pull request is labeled appropriately. Towncrier assembles pending fragments +into the stable changelog; do not edit released sections of `CHANGELOG.md`. -Towncrier renders pending fragments as prerelease notes, then collects them into -`CHANGELOG.md` and removes them when a stable release is made. Do not edit the -changelog or package version in a feature pull request. +## Documentation style -## Questions +- Lead with the outcome, then the command or decision. +- Prefer short sections, bullets, and comparison tables over dense paragraphs. +- Keep the README product-focused and the installation guide procedural. +- Put deployment internals in `docs/deployment/`. +- Mark previews, deferred work, and unsupported topologies explicitly. +- Never publish a command sequence that skips a required install, + initialization, preparation, or activation step. -Follow [Adding a capability](adding-a-capability.md) for the complete extension -contract. Open an issue before large cross-capability refactors so scope stays -aligned with the roadmap. +## Getting help -Maintainers should follow the [release process](releasing.md). +- Open an [issue](https://github.com/grayhatdevelopers/vidxp/issues) for a bug + or scoped proposal. +- Use [Discord](https://grayhat.studio/discord) for contributor discussion. +- Maintainers should follow the [release process](releasing.md). diff --git a/docs/deployment/coolify.md b/docs/deployment/coolify.md index 1c3aac4..1113e8b 100644 --- a/docs/deployment/coolify.md +++ b/docs/deployment/coolify.md @@ -50,6 +50,43 @@ Video bytes do not travel through MCP. Create and resume the upload through the HTTP/tus endpoints, wait for its `media_id`, and then use that ID with `start_indexing`. +## Prepare worker models + +Start the stack, then explicitly prepare the model set before accepting +indexing requests. Submit preparation through the authenticated API so the +durable job executes on the worker and writes to the shared model volume: + +| Selected models | Maximum pinned download | +|---|---:| +| Dialogue + scene + actor | Approximately 4.11 GiB | + +The request below is the operator's explicit authorization for those +downloads. Ensure the `model-cache` volume has enough additional capacity: + +```bash +curl --fail-with-body \ + --request POST \ + "https://${VIDXP_PUBLIC_API_HOST}/api/v1/jobs/model-preparation" \ + --header "Authorization: Bearer ${VIDXP_HTTP_STATIC_BEARER_TOKEN}" \ + --header "Idempotency-Key: initial-cpu-models-v1" \ + --header "Content-Type: application/json" \ + --data '{"modalities":["dialogue","scene","actor"],"capability_options":{}}' +``` + +The `202 Accepted` response contains the durable `job_id` and a `Location` +header. Poll that location until the job succeeds: + +```bash +curl --fail-with-body \ + --header "Authorization: Bearer ${VIDXP_HTTP_STATIC_BEARER_TOKEN}" \ + "https://${VIDXP_PUBLIC_API_HOST}/api/v1/jobs/" +``` + +The Streamable HTTP MCP `prepare_models` and `get_job` tools expose the same +operation for an authenticated agent client. Check +`/api/v1/runtime/readiness` afterward; `/ready` covers control-plane +availability and does not claim that every optional model is prepared. + ## Optional grounded query model Grounded retrieval works without a language model and returns timestamped From 880da785825e95c46bfe050763be42afb02e844f Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 30 Jul 2026 04:09:13 +0500 Subject: [PATCH 36/46] docs: restore release detail and simplify setup --- INSTALLATION_GUIDE.md | 102 ++++++++------- README.md | 160 ++++++++++-------------- changes/+artifacts.feature.md | 8 ++ changes/+benchmarking.feature.md | 1 + changes/+durable-workflows.feature.md | 9 ++ changes/+model-providers.breaking.md | 8 ++ changes/+product-interfaces.feature.md | 10 +- changes/+public-transports.security.md | 10 +- changes/+release-publication.feature.md | 6 + changes/+runtime-setup.feature.md | 13 +- changes/+server-deployment.feature.md | 10 +- changes/+video-library.feature.md | 10 +- docs/CONTRIBUTING.md | 58 +++++---- 13 files changed, 239 insertions(+), 166 deletions(-) create mode 100644 changes/+artifacts.feature.md create mode 100644 changes/+benchmarking.feature.md create mode 100644 changes/+durable-workflows.feature.md create mode 100644 changes/+model-providers.breaking.md create mode 100644 changes/+release-publication.feature.md diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md index bdb1254..166ece8 100644 --- a/INSTALLATION_GUIDE.md +++ b/INSTALLATION_GUIDE.md @@ -22,21 +22,17 @@ Local model work requires a capability or worker extra. ## Requirements -| Requirement | Supported | -|---|---| -| Python | 3.11 through 3.14 | -| Windows | x86-64 | -| Linux | x86-64 | -| macOS | Apple Silicon, macOS 14 or newer | -| Local runtime | CPU | -| Media tools | FFmpeg, ffprobe, `libx264`, and `aac` | -| GPU | Deferred; not a supported installation profile | +- Python 3.11 through 3.14. +- Windows x86-64, Linux x86-64, or Apple Silicon macOS 14 and newer. +- FFmpeg, ffprobe, `libx264`, and `aac` for media operations. +- CPU execution. **GPU remains explicitly deferred and is not a supported + installation profile.** Python wheels never install operating-system packages. `vidxp init` is the explicit, guided FFmpeg setup command; `vidxp doctor` is always read-only. Native installation uses -[uv](https://docs.astral.sh/uv/getting-started/installation/) and begins with -`uv --version`; Docker users do not need a host Python or uv installation. +[uv 0.12 or newer](https://docs.astral.sh/uv/getting-started/installation/). +Docker users do not need a host Python or uv installation. ## Fastest local setup: Docker @@ -71,34 +67,39 @@ intentionally want to remove all persisted VidXP data. ## Native local setup -The native sequence uses uv for Python selection, environment creation, and -CPU-aware PyTorch resolution. +uv installs VidXP as an isolated command-line tool, selects Python, and +resolves CPU PyTorch. Users do not need to create or activate a virtual +environment. -### 1. Create an isolated environment +### 1. Install the profile you need ```bash -uv venv --python 3.14 +uv --version +uv tool install --python 3.14 --torch-backend cpu "vidxp[local-worker,frontend]" ``` -| Windows PowerShell | macOS or Linux | +The command above installs the complete local browser app. Replace its bracketed +extras when you need another surface: + +| Goal | Install this package profile | |---|---| -| `.venv\Scripts\Activate.ps1` | `source .venv/bin/activate` | +| CLI indexing | `vidxp[local-worker]` | +| Browser UI | `vidxp[local-worker,frontend]` | +| Local stdio MCP | `vidxp[local-worker,mcp]` | +| Local API + remote MCP | `vidxp[local-worker,server]` | +| UI + API + both MCP transports | `vidxp[local-worker,frontend,server]` | -### 2. Install the profile you need +For example, install the local MCP profile with: -| Goal | Command | -|---|---| -| CLI worker | `uv pip install --torch-backend cpu "vidxp[local-worker]"` | -| Browser UI | `uv pip install --torch-backend cpu "vidxp[local-worker,frontend]"` | -| Local stdio MCP | `uv pip install --torch-backend cpu "vidxp[local-worker,mcp]"` | -| Local API + remote MCP | `uv pip install --torch-backend cpu "vidxp[local-worker,server]"` | -| UI + API + both MCP transports | `uv pip install --torch-backend cpu "vidxp[local-worker,frontend,server]"` | +```bash +uv tool install --python 3.14 --torch-backend cpu "vidxp[local-worker,mcp]" +``` -The explicit CPU backend prevents Linux and Windows from resolving the much -larger CUDA-enabled PyTorch distribution. Apple Silicon uses its native PyPI -wheel. +`--torch-backend cpu` prevents an unintended CUDA dependency set on Linux and +keeps the supported runtime consistent across platforms. If `vidxp` is not +found after installation, run `uv tool update-shell` and reopen the terminal. -### 3. Initialize FFmpeg +### 2. Initialize FFmpeg ```bash vidxp init @@ -136,7 +137,7 @@ uvx vidxp init Because the paths are stored in per-user configuration, a later permanent installation can reuse initialization performed by `uvx`. -### 4. Prepare models +### 3. Prepare models ```bash vidxp prepare @@ -168,7 +169,21 @@ vidxp prepare --modalities scene --yes Indexing, API jobs, and MCP tools never turn the first request into a hidden model download. -### 5. Verify +#### Upgrading from the previous model stack + +This release replaces: + +- WhisperX `large-v2` and `all-MiniLM-L6-v2` with faster-whisper + `large-v3-turbo` and Qwen3 Embedding 0.6B; +- OpenAI CLIP `ViT-B/32` with SigLIP2 base patch16-224; and +- `face_recognition`/dlib with OpenCV Zoo YuNet and SFace. + +The resulting embeddings, actor thresholds, and provider manifests are not +compatible with indexes built by the earlier stack. Prepare the new models, +then re-index the registered videos you want in the active snapshot. Keep the +old repository until the replacement index has been validated. + +### 4. Verify ```bash vidxp doctor @@ -185,14 +200,12 @@ vidxp doctor --modalities scene vidxp doctor --modalities dialogue,actor ``` -### 6. Start the selected surface +### 5. Start the selected surface -| Surface | Command | -|---|---| -| CLI | `vidxp --help` | -| Browser UI | `vidxp ui` | -| Local HTTP API + remote MCP | `vidxp-api` | -| Local stdio MCP | `vidxp-mcp` | +- CLI: `vidxp --help` +- Browser UI: `vidxp ui` +- Local HTTP API and remote MCP: `vidxp-api` +- Local stdio MCP: `vidxp-mcp` ## First CLI index @@ -401,9 +414,8 @@ single-location overrides. Docker uses its declared volumes instead. ### `vidxp` is not found -- Activate the virtual environment created above. -- For a uv tool installation, run `uv tool update-shell`, restart the shell, - and try again. +- Run `uv tool update-shell`, restart the shell, and try again. +- Confirm the installation with `uv tool list`. ### FFmpeg or a codec is missing @@ -416,10 +428,11 @@ Review the displayed package-manager command before approving it. Rerun ### Linux or Windows starts resolving NVIDIA packages -Recreate the environment and retain the explicit CPU option: +Reinstall the managed tool with the explicit CPU option: ```bash -uv pip install --torch-backend cpu "vidxp[local-worker,frontend]" +uv tool install --force --python 3.14 --torch-backend cpu \ + "vidxp[local-worker,frontend]" ``` Do not use plain `pip install "vidxp[local-worker]"` on Linux unless you have @@ -432,7 +445,8 @@ hardlink to each other. Installation falls back to copying and remains valid. Suppress the warning when that layout is intentional: ```bash -uv sync --link-mode copy +uv tool install --link-mode copy --python 3.14 --torch-backend cpu \ + "vidxp[local-worker,frontend]" ``` or set `UV_LINK_MODE=copy`. diff --git a/README.md b/README.md index 45912dd..1de2bcd 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,15 @@

    VidXP

    - Search video by what was said, what appeared, and who was on screen. + Search video by what was said, what appeared on screen, and recurring faces.

    - Local-first video indexing for people, applications, and AI agents. + A local-first video indexing and search engine for people, applications, and AI agents. +

    + +

    + CLI · Browser UI · Desktop preview · Python API · HTTP API · MCP · Docker

    @@ -22,90 +26,70 @@ Discord

    -## What VidXP does +## Why VidXP -VidXP turns one video or a whole local collection into a persistent, -searchable index. Models run on the worker rather than in the browser, API, or -MCP process, and model downloads happen only after explicit confirmation. +Finding one moment should not require scrubbing through an entire video. +VidXP builds a persistent index from three kinds of evidence: -| Dialogue | Scenes | Actors | -|---|---|---| -| Transcribes speech and indexes timestamped phrases | Samples video at a source-aware cadence and embeds frames | Detects faces and groups similar appearances within a video | -| Search by meaning, not only exact words | Search with descriptions such as “a red car at night” | Inspect clusters and render highlighted actor-overlay videos | +- **Dialogue:** transcribes speech and searches timestamped phrases by meaning, + not only exact words. +- **Scenes:** samples at a source-aware cadence and finds visually similar + moments from descriptions such as “a red car at night.” +- **Actors:** detects faces, groups recurring appearances within each video, + and can render a highlighted overlay for a selected cluster. -The same repository is available through every supported surface: +Index one video or a whole collection. Search across every indexed video for +the best matches, or provide a `media_id` to stay within one video. -| CLI | Browser and desktop | HTTP API and MCP | Docker | -|---|---|---|---| -| Import, index, search, query, jobs, and clips | Guided local workflow with visible preparation and job progress | Typed control plane for applications and agents | Local all-in-one image or separated server control/worker images | +Use the same repository in the form that fits the job: -Other product-level behavior includes: +- People can use the CLI, browser interface, or desktop preview. +- Applications can embed the Python layer or call the versioned HTTP API. +- Agents can connect through local stdio MCP or authenticated remote MCP. +- Deployments can use the all-in-one local image or separated control and + CPU-worker images. -- multi-video snapshots with cross-video top-k search; -- optional `media_id` filtering for one-video search; -- durable indexing, search, model-preparation, and artifact jobs; -- managed media, actor overlays, and downloadable video clips; -- grounded natural-language answers that retain timestamped evidence; and -- named repositories for separate searchable collections. +VidXP also provides durable jobs, atomic multi-video snapshots, named +repositories, grounded answers with timestamped evidence, and downloadable +video clips or actor overlays. [![VidXP browser interface](./docs/images/video-screenshot.jpeg)](https://www.linkedin.com/feed/update/urn:li:activity:7343569473720725505/) -## Start here: local browser app +## Quick start -This is the native end-user sequence. It installs the CPU worker and browser -interface from PyPI into an isolated virtual environment. Install -[uv](https://docs.astral.sh/uv/getting-started/installation/) first, then verify -it is available: +Install [uv 0.12 or newer](https://docs.astral.sh/uv/getting-started/installation/), +then let uv create and manage VidXP's isolated tool environment: ```bash uv --version +uv tool install --python 3.14 --torch-backend cpu "vidxp[local-worker,frontend]" ``` -### 1. Create and activate the environment - -```bash -uv venv --python 3.14 -``` - -| Windows PowerShell | macOS or Linux | -|---|---| -| `.venv\Scripts\Activate.ps1` | `source .venv/bin/activate` | - -### 2. Install VidXP - -```bash -uv pip install --torch-backend cpu "vidxp[local-worker,frontend]" -``` - -### 3. Initialize and prepare +No virtual environment needs to be created or activated manually. If the +installed command is not immediately found, run `uv tool update-shell` once +and reopen the terminal. ```bash vidxp init vidxp prepare vidxp doctor -``` - -- `init` verifies FFmpeg, ffprobe, `libx264`, and `aac`. If FFmpeg is missing, - it shows the exact supported package-manager command and asks before running - it. -- `prepare` lists each missing model, download size, cache location, and total - additional space before asking for confirmation. -- `doctor` is read-only. It installs and downloads nothing. - -### 4. Launch - -```bash vidxp ui ``` -Open `http://localhost:8501` if it does not open automatically. VidXP stores -its repository and model cache in the operating system’s per-user application -data directory, not in the directory where the command was run. +- `init` checks FFmpeg and offers a confirmed package-manager command when it + is missing. +- `prepare` discloses the missing models, download sizes, cache location, and + maximum additional space before asking. +- `doctor` is read-only and downloads nothing. +- `ui` opens the browser interface at `http://localhost:8501`. + +Media, indexes, jobs, artifacts, and models live in the operating system's +per-user VidXP data directory—not the directory where the command was run. -See the [installation guide](INSTALLATION_GUIDE.md) for a CLI-only worker, -MCP, a local API, Docker, macOS-specific installation, and troubleshooting. +See the [installation guide](INSTALLATION_GUIDE.md) for CLI-only, MCP, API, +Docker, desktop, embedding, and server deployment profiles. -## Start here: Docker +### Docker instead The local image contains the CPU worker, browser UI, and FFmpeg, but no model weights. @@ -123,36 +107,21 @@ media, indexes, jobs, and models across container replacement. ## Installation profiles -Install only the surfaces that belong in the same process environment. - -| Profile or extra | Adds | Use it for | -|---|---|---| -| Base `vidxp` | Lightweight CLI, typed contracts, repository configuration, `init` | Version/configuration commands or building a custom client; not local indexing | -| `storage` | Embedded Chroma and host monitoring | Custom local storage integrations | -| `dialogue` | Storage, faster-whisper, sentence-transformers | Dialogue-only local indexing | -| `scene` | Storage, PyTorch, Transformers, OpenCV, Pillow | Scene-only local indexing | -| `actor` | Storage, OpenCV, YuNet/SFace support | Actor-only local indexing | -| `all` | Dialogue, scene, and actor providers | A custom full worker without the query-model client | -| `local-worker` | All providers plus the optional grounded-query client | Recommended complete local worker | -| `frontend` | Streamlit | Browser UI; combine with a local worker | -| `mcp` | MCP SDK | Local stdio MCP; combine with a local worker | -| `server` | FastAPI, remote MCP, PostgreSQL control-plane dependencies | API/MCP control image or a local API when combined with `local-worker` | -| `server-worker` | Server storage client plus every CPU provider | Separated server worker image | -| `benchmarks` | DiDeMo and HiREST adapter dependencies | Evaluation and benchmark development | -| `test` | Pytest and HTTP test client | Contributor validation only | - -Common combinations: - -| Goal | Package selection | Start command | -|---|---|---| -| Local CLI worker | `vidxp[local-worker]` | `vidxp --help` | -| Local browser app | `vidxp[local-worker,frontend]` | `vidxp ui` | -| Local stdio MCP | `vidxp[local-worker,mcp]` | `vidxp-mcp` | -| Local API + remote MCP | `vidxp[local-worker,server]` | `vidxp-api` | -| Everything local | `vidxp[local-worker,frontend,server]` | `vidxp ui` or `vidxp-api` | +Install only the surfaces you intend to run together. + +| Goal | Package profile | What it adds | Start | +|---|---|---|---| +| CLI indexing | `vidxp[local-worker]` | Dialogue, scene, actor, storage, local worker | `vidxp --help` | +| Browser app | `vidxp[local-worker,frontend]` | Complete local worker + Streamlit | `vidxp ui` | +| Local agent tools | `vidxp[local-worker,mcp]` | Complete local worker + stdio MCP | `vidxp-mcp` | +| Local application server | `vidxp[local-worker,server]` | Complete local worker + HTTP/remote MCP | `vidxp-api` | +| UI and server | `vidxp[local-worker,frontend,server]` | Browser, API, and both MCP transports | `vidxp ui` or `vidxp-api` | Python package installation never changes operating-system packages. Run -`vidxp init` explicitly for guided FFmpeg setup. +`vidxp init` explicitly for guided FFmpeg setup. The +[installation guide](INSTALLATION_GUIDE.md#optional-dependency-extras) +documents individual capability, storage, server-worker, benchmark, and test +extras. ## Index and search from the CLI @@ -217,7 +186,7 @@ Install the local worker and MCP extra in the same environment, prepare the models, and print client-ready configuration: ```bash -uv pip install --torch-backend cpu "vidxp[local-worker,mcp]" +uv tool install --python 3.14 --torch-backend cpu "vidxp[local-worker,mcp]" vidxp init vidxp prepare vidxp mcp-config @@ -243,7 +212,7 @@ paths. For a local API backed by the same local worker: ```bash -uv pip install --torch-backend cpu "vidxp[local-worker,server]" +uv tool install --python 3.14 --torch-backend cpu "vidxp[local-worker,server]" vidxp init vidxp prepare vidxp-api @@ -309,7 +278,8 @@ publication is still under release validation. See ## Python API VidXP can also be embedded as a Python indexing/retrieval layer. Install only -the capability extras your application uses: +the capability extras your application uses inside that application's own +Python environment: ```bash uv pip install --torch-backend cpu "vidxp[scene]" @@ -353,6 +323,12 @@ Pinned model downloads are disclosed before preparation: Actual installed and cached disk use can be higher. `vidxp prepare` calculates what is missing on the current machine and asks before downloading it. +These providers replace the earlier WhisperX `large-v2` + +`all-MiniLM-L6-v2`, OpenAI CLIP `ViT-B/32`, and +`face_recognition`/dlib stack. Their embeddings, face thresholds, and provider +manifests are not index-compatible. Re-index videos created with an earlier +VidXP release after preparing the new models. + Local repositories and models are independent of the shell’s current directory and Python environment: diff --git a/changes/+artifacts.feature.md b/changes/+artifacts.feature.md new file mode 100644 index 0000000..a781a4f --- /dev/null +++ b/changes/+artifacts.feature.md @@ -0,0 +1,8 @@ +Add managed video artifacts: + +- create source-preserving or broadly compatible MP4 clips from a media ID while rejecting ranges outside the source or configured duration limit +- reuse a ready clip for the same source checksum, interval, and output profile +- render actor overlays from stable composite cluster IDs bound to an immutable snapshot +- validate generated video and the requested output profile before atomic publication +- durably synchronize managed publication and removal on platforms that support directory `fsync` +- expose opaque artifact metadata and authorized downloads through CLI, HTTP, and MCP without leaking repository paths diff --git a/changes/+benchmarking.feature.md b/changes/+benchmarking.feature.md new file mode 100644 index 0000000..2ac5350 --- /dev/null +++ b/changes/+benchmarking.feature.md @@ -0,0 +1 @@ +Add optional DiDeMo and HiREST benchmark commands with dataset adapters, evaluator integration, scene-cadence controls, and invocation-time checks that name the exact missing benchmark extra instead of breaking unrelated commands. diff --git a/changes/+durable-workflows.feature.md b/changes/+durable-workflows.feature.md new file mode 100644 index 0000000..7bb07b7 --- /dev/null +++ b/changes/+durable-workflows.feature.md @@ -0,0 +1,9 @@ +Run long operations through durable, inspectable jobs: + +- execute indexing, fused search, grounded questions, model preparation, snippets, and actor overlays through versioned DBOS workflows +- use SQLite-backed local jobs and PostgreSQL-backed server jobs with the same typed progress, cancellation, retry, error, and result contracts +- pin search and actor-overlay work to the exact immutable snapshot selected at submission +- freeze descending job pages so concurrent submissions do not shift later cursors +- materialize actor-cluster summaries and advance actor-detection cursors without rescanning all retained detections for every page +- bind workflow recovery to the package version and implementation digest while keeping legacy atomic-search records readable and recoverable +- supervise detached local workers without creating a second job-state store diff --git a/changes/+model-providers.breaking.md b/changes/+model-providers.breaking.md new file mode 100644 index 0000000..897ec46 --- /dev/null +++ b/changes/+model-providers.breaking.md @@ -0,0 +1,8 @@ +Re-index videos created with the previous provider stack: + +- replace WhisperX `large-v2` and `all-MiniLM-L6-v2` with pinned faster-whisper `large-v3-turbo` and Qwen3 Embedding 0.6B for dialogue +- replace OpenAI CLIP `ViT-B/32` with pinned SigLIP2 base patch16-224 for scene retrieval +- replace `face_recognition`/dlib with pinned OpenCV Zoo YuNet and SFace for face detection and within-video grouping +- record model revisions, weight checksums, licenses, precision, runtime identity, and expected download sizes in the index/model contracts + +The new embeddings, face thresholds, and provider manifests are intentionally incompatible with old indexes. diff --git a/changes/+product-interfaces.feature.md b/changes/+product-interfaces.feature.md index a7396b3..8036c30 100644 --- a/changes/+product-interfaces.feature.md +++ b/changes/+product-interfaces.feature.md @@ -1 +1,9 @@ -Make the same video workflow available through a user-friendly CLI, browser UI, Python API, local and remote MCP, a versioned HTTP API, a desktop setup preview, and an all-in-one local container. +Expose the shared VidXP application through each supported product surface: + +- provide CLI commands for media, index membership, cross-video search, grounded questions, jobs, repositories, actors, and artifacts +- provide a browser UI that reloads cataloged media in fresh sessions, derives search/query controls from capability metadata, confirms model downloads, shows live job progress, submits search with Enter, stops polling terminal jobs, and describes visual-similarity results accurately +- provide a Python indexing and retrieval layer for applications +- provide a versioned HTTP API with OpenAPI, media transfer, readiness, durable jobs, and artifact delivery +- provide local stdio and remote Streamable HTTP MCP with import-ready client configuration, a real protocol self-check, media/index discovery, job polling/retry, and clip download links +- provide a Tauri desktop setup preview that preflights FFmpeg before Python/package downloads, owns its runtime, supervises the same local UI and worker, and leaves FFmpeg bundling behind an explicit provenance/licensing gate +- provide an all-in-one local container using the same repository and worker behavior diff --git a/changes/+public-transports.security.md b/changes/+public-transports.security.md index 17f9036..43a1520 100644 --- a/changes/+public-transports.security.md +++ b/changes/+public-transports.security.md @@ -1 +1,9 @@ -Protect public HTTP and MCP transports with static bearer or OIDC authentication, scoped authorization, trusted Host and Origin policies, bounded request bodies, safe error envelopes, and confined media/artifact delivery. +Protect public HTTP and MCP transports: + +- support constant-time static bearer authentication or cached asymmetric OIDC/JWKS validation with exact issuer, audience, algorithm, HTTPS, and unsafe-URL-character checks +- enforce repository read, write, and admin scopes after authentication +- declare bearer authentication in protected OpenAPI and keep authenticated schemas private +- enforce independent trusted Host, Origin/CORS, request-size, query-length, actor-identifier, and streamed-upload bounds +- return typed public errors with correlation IDs while retaining internal workflow tracebacks +- derive opaque repository-, subject-, and operation-scoped idempotency keys for retries +- deliver authorized media and artifacts through confined handles with checksum ETags and range responses, without exposing storage keys or filesystem paths diff --git a/changes/+release-publication.feature.md b/changes/+release-publication.feature.md new file mode 100644 index 0000000..d4a0369 --- /dev/null +++ b/changes/+release-publication.feature.md @@ -0,0 +1,6 @@ +Align public release artifacts: + +- stamp the Python package, Tauri package, desktop runtime manifest, and native app versions through semantic release +- build PyPI distributions and local/control/worker container images from the exact validated release commit +- verify the minimal wheel separately from optional capability, UI, MCP, server, benchmark, and test extras +- keep the repository README's relative assets while rewriting them only in the temporary PyPI build diff --git a/changes/+runtime-setup.feature.md b/changes/+runtime-setup.feature.md index 614207f..72fd855 100644 --- a/changes/+runtime-setup.feature.md +++ b/changes/+runtime-setup.feature.md @@ -1 +1,12 @@ -Add explicit FFmpeg and model preparation with disclosed download sizes, confirmation, live progress, per-user application-data defaults, actionable readiness checks, and no hidden first-request downloads. +Make local setup and execution explicit and observable: + +- add `vidxp init` to verify FFmpeg, ffprobe, `libx264`, and `aac`, and request consent before a supported package-manager command +- make `vidxp prepare` disclose every missing pinned model, its expected bytes, the cache location, total additional space, and live byte progress before downloading +- use bounded HTTP model downloads instead of Xet transfers that can remain parked at zero bytes +- keep `vidxp doctor` read-only and report Python, provider, codec, worker, storage, and prepared-model readiness separately +- prevent indexing, API, or MCP first requests from silently downloading models +- place repositories and model caches in the operating system's per-user VidXP data root instead of the shell's current directory +- sample scenes by time and source frame rate with independent actor cadence and CLI, UI, API, and MCP controls +- skip dialogue cleanly for videos without audio instead of failing the entire index +- avoid an arbitrary free-memory refusal, allow realistic worker startup time, hide the Windows worker console, prevent duplicate indexing previews, and stop owned workers when the local UI, API, or MCP process exits +- pass local-worker settings through a one-use owner-readable bootstrap without HTTP credentials, with startup backoff, bounded log rotation, stale-process cleanup, and executable/provider identity checks diff --git a/changes/+server-deployment.feature.md b/changes/+server-deployment.feature.md index be2ce75..4a8a44b 100644 --- a/changes/+server-deployment.feature.md +++ b/changes/+server-deployment.feature.md @@ -1 +1,9 @@ -Add a production-oriented single-node Compose/Coolify topology with separate control and CPU-worker images, PostgreSQL, Chroma, resumable tus uploads, persistent volumes, health gates, and optional explicitly prepared grounded-query models. +Add a production-oriented single-node Compose/Coolify deployment: + +- publish separate model-free control and CPU-provider worker images from the same validated release commit +- persist catalogs and immutable snapshot metadata in PostgreSQL, DBOS state in its own schema, vectors in Chroma, and media/models in named volumes +- accept streamed multipart uploads up to 256 MiB and large authenticated resumable tus uploads with private hooks, recovery, retention, atomic reservation, and per-principal quota enforcement +- gate startup on migrations, Chroma connectivity, API/worker health, and explicit model preparation +- keep PostgreSQL, Chroma, hooks, Ollama, and upload internals off the public network +- document the supported one-node, one-worker, one-repository-per-stack boundary and graceful worker/tusd shutdown behavior +- support optional grounded-query generation only after the operator explicitly configures and prepares an evaluated model diff --git a/changes/+video-library.feature.md b/changes/+video-library.feature.md index b9b7e32..b17ae48 100644 --- a/changes/+video-library.feature.md +++ b/changes/+video-library.feature.md @@ -1 +1,9 @@ -Turn VidXP into a persistent multi-video search library with managed media, immutable index snapshots, dialogue/scene/actor retrieval, cross-video ranking, grounded questions, durable jobs, and downloadable overlay or clip artifacts. +Build a persistent multi-video library: + +- stream, hash, and ffprobe local imports before publishing them under opaque media IDs +- store each indexed video as an immutable generation and atomically update the active multi-video snapshot only after validation +- preserve the previous searchable snapshot when indexing fails or is cancelled +- re-index, remove, or clear individual media without rebuilding unrelated videos +- rank top-k results across the active collection or restrict search and grounded questions with a `media_id` +- list registered and actively indexed videos with bounded, stable cursor pages that reject malformed, noncanonical, or overflowing cursors +- import large local files without routing their bytes through the browser upload control diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 189c12b..795bb6a 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -67,22 +67,20 @@ override `--data-dir`. ## Project map -| Product/application | Interfaces and deployment | Execution and storage | -|---|---|---| -| `application.py`, `control_plane.py`, `application_models.py` | `cli.py`, `cli_commands/`, `frontend.py` | `job_service.py`, `workflow_*`, `execution.py` | -| `capabilities/` | `api.py`, `api_routes/`, `mcp.py` | `core/`, `infrastructure/`, `ports.py` | -| `media_service.py`, `artifact_service.py`, `query_service.py` | `Dockerfile`, `compose*.yaml`, `desktop/` | `migrations/`, `repository_layout.py`, `settings.py` | - -Supporting areas: - -| Path | Purpose | -|---|---| -| `tests/` | Unit, contract, adapter, integration, and regression tests | -| `src/vidxp/benchmarks/` | Benchmark loaders, adapters, and evaluator calls | -| `docs/benchmarking/` | Benchmark scope, validation, and published results | -| `docs/deployment/` | Server/Coolify and deferred GPU design | -| `src/vidxp/requirements/` | Composable optional dependency groups | -| `changes/` | Pending public release notes | +- Product operations and contracts live in `application.py`, + `control_plane.py`, `application_models.py`, and the media, artifact, and + query services. +- Capability-specific indexing and retrieval live in `capabilities/`. +- CLI, Streamlit, HTTP, and MCP adapters live in `cli.py`, `cli_commands/`, + `frontend.py`, `api.py`, `api_routes/`, and `mcp.py`. +- Durable execution and storage live in `job_service.py`, `workflow_*`, + `execution.py`, `core/`, `infrastructure/`, and `ports.py`. +- Deployment and desktop packaging live in `Dockerfile`, `compose*.yaml`, and + `desktop/`. +- Tests are under `tests/`; benchmark adapters and documentation are under + `src/vidxp/benchmarks/` and `docs/benchmarking/`. +- Optional dependency groups are under `src/vidxp/requirements/`; pending + public release notes are under `changes/`. Generated environments, model weights, media, indexes, artifacts, `benchmark_runs/`, build outputs, and local data do not belong in commits. @@ -180,7 +178,19 @@ boundary, say so. ## Pull requests -Use a clear title and a compact description: +The repository's +[pull request template](../.github/pull_request_template.md) is the required +starting point. GitHub loads it automatically for new pull requests. Complete +all three sections: + +- **Summary:** describe the user-facing outcome and important compatibility or + migration behavior. +- **Validation:** list the exact commands and real boundaries exercised. Do not + replace results with “tests passed.” +- **Changelog:** add the correct fragment, or explain why the change is + dependency-only/internal and should receive `skip-changelog`. + +Keep the description compact, but include: - what users can do after the change; - why the change is needed; @@ -202,14 +212,12 @@ docs: clarify local installation profiles Every user-visible pull request normally adds one `changes/..md` file. Supported types are: -| Type | Use it for | -|---|---| -| `breaking` | Incompatible public behavior or API changes | -| `feature` | New user-visible behavior | -| `bugfix` | Behavior that was wrong in the latest public release | -| `deprecation` | Public behavior scheduled for removal | -| `docs` | Material user-facing documentation changes | -| `security` | Security fixes or hardening users should know about | +- `breaking` for incompatible public behavior or API changes. +- `feature` for new user-visible behavior. +- `bugfix` for behavior that was wrong in the latest public release. +- `deprecation` for public behavior scheduled for removal. +- `docs` for material user-facing documentation changes. +- `security` for security fixes or hardening users should know about. Write for users, not for the implementation history. From 9ef2cbc175f1d6975246084b498ed801ffb442d4 Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 30 Jul 2026 09:58:00 +0500 Subject: [PATCH 37/46] fix(benchmarks): preserve multi-video run identity --- changes/+benchmarking.feature.md | 2 +- docs/benchmarking/README.md | 18 ++--- docs/benchmarking/adapter_validation.md | 95 +++++++++++++++++++++---- docs/benchmarking/benchmark_catalog.md | 15 ++-- docs/benchmarking/core_contract.md | 42 +++++++---- docs/benchmarking/research_papers.md | 2 +- docs/benchmarking/results.md | 56 +++++++++++---- docs/benchmarking/runtime_validation.md | 25 ++++++- src/vidxp/benchmarks/cli.py | 52 ++++++++++++++ src/vidxp/benchmarks/common.py | 26 +++++++ src/vidxp/benchmarks/didemo.py | 13 +++- src/vidxp/benchmarks/hirest.py | 13 +++- src/vidxp/core/runner.py | 7 +- tests/test_benchmark_cli.py | 27 +++++++ tests/test_benchmarks.py | 55 +++++++++++++- tests/test_runner.py | 71 ++++++++++++++++++ 16 files changed, 452 insertions(+), 67 deletions(-) diff --git a/changes/+benchmarking.feature.md b/changes/+benchmarking.feature.md index 2ac5350..ebbc315 100644 --- a/changes/+benchmarking.feature.md +++ b/changes/+benchmarking.feature.md @@ -1 +1 @@ -Add optional DiDeMo and HiREST benchmark commands with dataset adapters, evaluator integration, scene-cadence controls, and invocation-time checks that name the exact missing benchmark extra instead of breaking unrelated commands. +Add optional DiDeMo and HiREST benchmark commands with pinned official evaluators, resumable generation-aware indexes, opaque internal media IDs, documented media substitutions, scene-cadence controls, and invocation-time checks that name the exact missing benchmark extra instead of breaking unrelated commands. Retain the legacy full-corpus baselines and record bounded real SigLIP2/Qwen3 provider smokes separately. diff --git a/docs/benchmarking/README.md b/docs/benchmarking/README.md index bdc0925..13ccd1f 100644 --- a/docs/benchmarking/README.md +++ b/docs/benchmarking/README.md @@ -13,8 +13,8 @@ installation and product usage, start with the main | Area | Status | What it means | |---|---|---| | Shared benchmark support | Complete | Stable IDs, time ranges, metadata, top-k retrieval, isolated runs, checkpoints, and prediction files are implemented | -| DiDeMo visual localization | Complete | Full official test run completed over 4,021 queries and 1,037 videos | -| HiREST transcript localization | Validation complete | All 193 official validation pairs were scored; 776 released test predictions are unscored because their public answer bounds are placeholders | +| DiDeMo visual localization | Legacy full result + current smoke | The legacy CLIP stack completed 4,021 official test queries over 1,037 videos; the current SigLIP2 stack passed a one-annotation real execution smoke | +| HiREST transcript localization | Legacy full result + current smoke | The legacy MiniLM stack scored all 193 validation pairs; current Qwen3 passed a two-video real execution smoke; 776 released test predictions remain unscored because their public bounds are placeholders | | LongVALE combined evaluation | Next | Build the visual-plus-speech adapter and validate one evaluation archive before scheduling the full run | | Actor clustering | Data-gated | The preferred BBT/Buffy evaluation still requires lawful access to the source episodes | @@ -44,12 +44,14 @@ they are not the current task list. No single published benchmark covers dialogue retrieval, scene retrieval, and actor clustering together. -The completed DiDeMo and HiREST runs establish separate visual and -transcript-based baselines. LongVALE is the next combined test because it asks a -system to find described events in long videos using visual, speech, and general -audio evidence. VidXP can currently contribute visual and speech evidence; it -does not recognize general sounds such as music, alarms, or barking. Any -LongVALE result must keep that limitation visible. +The retained full DiDeMo and HiREST results establish separate legacy-provider +visual and transcript baselines. Current SigLIP2 and Qwen3 checks establish +adapter/runtime compatibility only; they do not yet provide full-corpus quality +comparisons. LongVALE is the next combined test because it asks a system to find +described events in long videos using visual, speech, and general audio +evidence. VidXP can currently contribute visual and speech evidence; it does +not recognize general sounds such as music, alarms, or barking. Any LongVALE +result must keep that limitation visible. ## Evidence rules diff --git a/docs/benchmarking/adapter_validation.md b/docs/benchmarking/adapter_validation.md index d314e2b..8cb2f18 100644 --- a/docs/benchmarking/adapter_validation.md +++ b/docs/benchmarking/adapter_validation.md @@ -4,10 +4,15 @@ This ledger records the implementation and executable validation of the first two benchmark adapters. Subset runs are smoke tests of the complete data, indexing, serialization, and evaluator path. Their metrics are not paper scores. -For the current scores, metric definitions, and plain-language interpretation, +For the recorded results, metric definitions, and plain-language interpretation, start with [current benchmark results](results.md). This ledger is the technical reproduction record. +The full-corpus scores below are the retained 2026-07-27 legacy-provider +results. The 2026-07-30 SigLIP2/Qwen3 runs are bounded execution smokes, not +replacement quality scores. Both generations used the same physical laptop; +the model and protocol differences are recorded explicitly. + ## Pinned official artifacts | Benchmark | Artifact | Revision | SHA-256 | @@ -76,8 +81,9 @@ command, working directory, compatibility note, output, and return code. released SRT cues do not contain word timestamps, phrase bounds are interpolated linearly within the real cue bounds and disclosed in the run manifest. -5. Submit the timestamped phrases to the dialogue-only VidXP core. The run uses - MiniLM and does not load WhisperX or decode video. +5. Submit the timestamped phrases to the dialogue-only VidXP core. The legacy + full run used MiniLM; the current smoke used Qwen3 Embedding. Neither path + loads a transcription model or decodes video. 6. Search each prompt only within its known video and retrieve every stored dialogue phrase. Project phrase scores onto one-second bins, assign uncovered seconds an explicit absence penalty, and rank duration-relative windows by @@ -109,9 +115,10 @@ logic remain unchanged, and the shim is disclosed in `evaluator.log`. These choices were made only on official validation data. Test annotations were not used to select either setting. -For DiDeMo, a declared 15-annotation validation subset over four downloadable -official videos was indexed at frame stride `30`. Both alternatives used the -same stored CLIP frame scores and the pinned official evaluator: +For the legacy DiDeMo run, a declared 15-annotation validation subset over four +downloadable official videos was indexed at frame stride `30`. Both +alternatives used the same stored CLIP frame scores and the pinned official +evaluator: | Within-chunk pooling | Rank@1 | Rank@5 | mIoU | |---|---:|---:|---:| @@ -124,11 +131,11 @@ seconds inside the human-selected chunk, but mean pooling diluted it enough to rank chunk 3 first. This is why the change is an aggregation correction rather than a label-specific test patch. -For HiREST, all 193 official validation moment pairs were indexed from released -ASR. The released cues contain no word timestamps, so the repaired core first -created the configured five-word phrases using linear interpolation inside each -real cue. The same stored MiniLM scores were then evaluated over this declared -duration-fraction grid: +For the legacy HiREST run, all 193 official validation moment pairs were +indexed from released ASR. The released cues contain no word timestamps, so the +repaired core first created the configured five-word phrases using linear +interpolation inside each real cue. The same stored MiniLM scores were then +evaluated over this declared duration-fraction grid: | Window fraction | R@0.5 | R@0.7 | |---:|---:|---:| @@ -182,6 +189,66 @@ size match the archive record. The manifest therefore classifies this as an official test result with a documented media substitution, not an untouched official-media run. +## 2026-07-30 current-provider regression runs + +These checks used the current CPU providers after the product model swap. They +were intentionally bounded after the integration path was proven; they are not +full benchmark replacements. + +### Hardware and runtime + +| Item | Recorded current setup | +|---|---| +| Laptop | HP ENVY Laptop 16-h0xxx; the same physical hardware used for the legacy runs | +| CPU | Intel Core i7-12700H; 14 cores, 20 logical processors | +| Memory | 15.72 GiB | +| GPU | NVIDIA GeForce RTX 3060 Laptop GPU, 4 GiB; present but unused | +| Runtime | Windows 11; Python 3.14.0; PyTorch 2.13.0+cpu | +| Libraries | Transformers 5.14.1; Sentence Transformers 5.6.1; ChromaDB 1.5.9 | + +HiREST used `Qwen/Qwen3-Embedding-0.6B` at immutable revision +`97b0c614be4d77ee51c0cef4e5f07c00f9eb65b3`. DiDeMo used +`google/siglip2-base-patch16-224` at +`75de2d55ec2d0b4efc50b3e9ad70dba96a7b2fa2`. HiREST consumed the released +SRTs, so the configured faster-whisper model was not loaded or evaluated. + +### Real execution results + +| Run | Declared subset | Result | Runtime evidence | +|---|---|---|---| +| `hirest-qwen3-generation-smoke-20260730` | Two validation prompt/video pairs, including `Make Oatmeal Pancake Mix` / `5V3dI2zp1xA.mp4` | Official evaluator: R@0.5 `50.0`, R@0.7 `50.0`; classification `validation_smoke_test_not_paper_score` | Both media items remained searchable in the run generation; 64.923 recorded stage seconds; 2,593,096-byte store; empty failure log | +| `didemo-siglip2-sample-20260730` | Official test annotation index `0`; one video; 1.0 sample/sec; max chunk pooling | Official evaluator: Rank@1 `0.0`, Rank@5 `1.0`, mIoU `0.0`; classification `smoke_test_not_paper_score` | 29.359 recorded stage seconds; 747,684-byte store; all 21 candidates serialized; empty failure log | + +Prediction SHA-256 values were +`c2cee45eb7802e2c19adb4aec876a8fe2c203c59dde4bb20b41634b83d050afd` +for HiREST and +`4a38ff2c307833393f96fc0af48107a12581ecd9be734d8c0b906b2448a6fd33` +for DiDeMo. The runs were made from commit +`880da785825e95c46bfe050763be42afb02e844f` with the documented uncommitted +benchmark repairs, whose implementation fingerprint was +`afe0ee120f1c7b7daabe6d2c51e320a67a1368158fc5215acc34b7dd3e4e245e`. + +### Defects exposed and fixed + +The first current-provider attempt completed all 193 HiREST indexing +checkpoints but failed before predictions because benchmark records lacked the +generation identity now required by the search contract. Adding a shared +generation then exposed that the multi-video runner deleted the entire +generation before each video, leaving only the final video's records. A +two-video real smoke also caught official dataset filenames being passed into +the product's UUID4-only media ID fields. + +The final adapter behavior now: + +- derives stable UUID4-shaped internal media and generation IDs; +- retains official dataset video names at the file/evaluator boundary; +- removes only the current video's records when retrying inside a generation; +- supports documented DiDeMo media substitutions through the public CLI; and +- covers the generation/media-ID boundary in regression tests. + +The temporary 1,037-video corpus, model-run indexes, repository clones, and ASR +copy were removed after recording the evidence above. + ## Commands Install the optional adapters with the capabilities they evaluate: @@ -197,12 +264,16 @@ vidxp benchmark didemo ` --annotations /data/test_data.json ` --evaluator /utils/eval.py ` --media-directory ` + --media-overrides ` --split test ` --annotation-indices 0,1,2 ` --run-id didemo-smoke ``` Omit `--annotation-indices` for the full official test split. +`--media-overrides` is a JSON object mapping an official annotation filename to +a documented replacement path. Relative replacement paths resolve beside the +JSON file, and the run manifest records that a substitution was used. For HiREST, a smoke pair file is a JSON list: @@ -252,7 +323,7 @@ They also retain `ground_truth.subset.json`, the core completion marker, and per-video checkpoints. Validation runs add `metrics.json`; held-out HiREST test runs add `submission.summary.json`. Empty failure logs are created deliberately. -## 2026-07-27 executable smoke results +## 2026-07-27 legacy-provider executable smoke results | Adapter | Declared subset | Actual path exercised | Official evaluator result | |---|---|---|---| diff --git a/docs/benchmarking/benchmark_catalog.md b/docs/benchmarking/benchmark_catalog.md index 71aee01..58cd92d 100644 --- a/docs/benchmarking/benchmark_catalog.md +++ b/docs/benchmarking/benchmark_catalog.md @@ -70,18 +70,18 @@ protocol. It does not mean that a similar published score is already comparable. | Category | Benchmark or protocol | Capability actually measured | What a VidXP result would tell us | What it would **not** tell us | Current status | | --- | --- | --- | --- | --- | --- | -| Dialogue | [TVR `sub-only` queries](https://github.com/jayleicn/TVRetrieval) | Subtitle-related paraphrastic query to ranked video intervals, both known-video and corpus-wide | Whether WhisperX/MiniLM transcript evidence can identify and temporally localize relevant TV moments; a subtitle-only run isolates embedding and retrieval | Verbatim quote lookup, scene understanding, actor clustering, generic audio, or end-to-end ASR quality when supplied subtitles are used | Engineering A; raw TV media gated | -| Visual | [TVR `video-only` queries](https://github.com/jayleicn/TVRetrieval) | Visual-language query to ranked video intervals | Whether frame/clip CLIP evidence can retrieve visual moments across a TV corpus | Dialogue retrieval, actor identity, or performance on non-TV domains | Engineering A; raw TV media gated | +| Dialogue | [TVR `sub-only` queries](https://github.com/jayleicn/TVRetrieval) | Subtitle-related paraphrastic query to ranked video intervals, both known-video and corpus-wide | Whether the configured transcript embedding path can identify and temporally localize relevant TV moments; a subtitle-only run isolates embedding and retrieval | Verbatim quote lookup, scene understanding, actor clustering, generic audio, or end-to-end ASR quality when supplied subtitles are used | Engineering A; raw TV media gated | +| Visual | [TVR `video-only` queries](https://github.com/jayleicn/TVRetrieval) | Visual-language query to ranked video intervals | Whether the configured frame/clip embedding path can retrieve visual moments across a TV corpus | Dialogue retrieval, actor identity, or performance on non-TV domains | Engineering A; raw TV media gated | | Combined | [TVR `video+sub` queries](https://github.com/jayleicn/TVRetrieval) | Queries annotators judged to need both visual and subtitle evidence | Whether fixed late fusion improves corpus moment retrieval when both implemented paths contain useful evidence | Generic sound understanding, actor clustering, learned cross-modal reasoning, or intended `video+sub` coverage from a dialogue-only ablation | Engineering A with fixed fusion; raw TV media gated | -| Speech-backed instructional | [HiREST](https://github.com/j-min/HiREST) | Instructional goal to ranked videos, one relevant interval, moment segmentation, and step captioning | Whether VidXP's chunking, MiniLM embeddings, vector index, and interval selection retrieve semantically relevant spoken procedural content | Verbatim dialogue search, entertainment-video generalization, actors, WhisperX accuracy in released-ASR mode, or full video retrieval when negative candidates lack ASR | Known-video validation complete; released test predictions unscored because public bounds are placeholders; full video retrieval gated | +| Speech-backed instructional | [HiREST](https://github.com/j-min/HiREST) | Instructional goal to ranked videos, one relevant interval, moment segmentation, and step captioning | Whether VidXP's chunking, dialogue embeddings, vector index, and interval selection retrieve semantically relevant spoken procedural content | Verbatim dialogue search, entertainment-video generalization, actors, transcription accuracy in released-ASR mode, or full video retrieval when negative candidates lack ASR | Legacy MiniLM validation complete; current Qwen3 two-video smoke complete; released test predictions unscored because public bounds are placeholders; full video retrieval gated | | Narrated retrieval | [QuerYD](https://www.robots.ox.ac.uk/~vgg/data/queryd/) | Paragraph text↔video retrieval and narration text↔localized-clip ranking over supplied ground-truth proposals | Whether VidXP visual representations rank the correct narrated video or oracle segment proposal once source media are processed | In-scene conversational dialogue, overlapping speech, unrestricted boundary prediction, or VidXP quality from the released Collaborative Experts features | Protocol/reference artifacts ready; true VidXP run raw-media gated; narration audio unresolved | | Ranked search | [TVR-Ranking](https://huggingface.co/axgroup/TVR-Ranking) | Graded ranking of multiple relevant corpus moments for imprecise queries | Whether VidXP orders several partially relevant moments usefully, not merely whether its top result overlaps one answer | ASR quality when subtitles are supplied, actors, or generalization outside TVR | Engineering A after TVR; media/license gated | | Speech-backed whole-video | [How2R](https://aclanthology.org/2020.emnlp-main.161/) | Instructional-video retrieval using video plus aligned speech/subtitles, introduced with HERO | Whether VidXP can rank instructional clips from transcript and scene evidence under HERO's retrieval setup | Conversational dialogue, temporal boundary prediction, actors, or immediate executability before artifacts/licenses are rechecked | Relevant benchmark; current artifact/access status unresolved | | Spoken retrieval | [TREC Podcasts](https://trecpodcasts.github.io/) | Semantic query to graded, timestamped long-form spoken segments | Whether VidXP can rank relevant spoken passages across a large audio corpus | Visual retrieval, video timing beyond fixed segments, or actor clustering | Reference-only; corpus access closed | | Spoken occurrence | [NIST OpenKWS](https://www.nist.gov/document/openkws13-evalplan-v4pdf) | Detection of every exact spoken keyword occurrence with calibrated accept/reject decisions | Miss, false-alarm, timing, threshold-calibration, indexing-time, and search-time behavior for exact terms | Semantic/paraphrase dialogue retrieval, scene search, or actor performance | Engineering A; Babel/LDC data gated | | Spoken/visual archive | [SAVA, MediaEval 2015](https://ceur-ws.org/Vol-1436/Paper11.pdf) | Spoken-plus-visual query to unrestricted BBC archive intervals | Whether combined evidence retrieves useful broadcast segments | Actor clustering or reproducibility on presently obtainable public media | Official task-overview paper verified; blocked/reference-only | -| Multilingual temporal | [mTVR](https://aclanthology.org/2021.acl-short.92/) | English/Chinese multilingual TV moment retrieval | Whether a multilingual retrieval stack preserves temporal retrieval across those two languages | Urdu support, current MiniLM multilingual quality, or actor clustering | Reference; not an intentional current claim | -| Visual moment | [DiDeMo](https://github.com/LisaAnne/LocalizingMoments) | Natural-language query to one of 21 fixed moments in a known video | Whether CLIP frame scores and deterministic five-second aggregation rank the human-selected moment | Corpus-wide search, variable boundary quality, dialogue, or actors | Adapter and full official test run complete | +| Multilingual temporal | [mTVR](https://aclanthology.org/2021.acl-short.92/) | English/Chinese multilingual TV moment retrieval | Whether a multilingual retrieval stack preserves temporal retrieval across those two languages | Urdu support, current Qwen3 multilingual quality, or actor clustering | Reference; not an intentional current claim | +| Visual moment | [DiDeMo](https://github.com/LisaAnne/LocalizingMoments) | Natural-language query to one of 21 fixed moments in a known video | Whether configured scene-embedding scores and deterministic five-second aggregation rank the human-selected moment | Corpus-wide search, variable boundary quality, dialogue, or actors | Legacy CLIP full official test complete; current SigLIP2 one-annotation smoke complete | | Visual moment/highlight | [QVHighlights](https://github.com/jayleicn/moment_detr) | Query to ranked variable-duration moments and saliency on an official two-second clip grid | Whether zero-shot visual similarity finds relevant intervals and assigns useful clip-level saliency | Dialogue or actor performance; parity with supervised temporal models; validity of a point prediction under tIoU | Engineering A; storage/compute gated; exact test-label release must be named | | Visual moment | [Charades-STA](https://github.com/jiyanggao/TALL) | Query to ranked intervals in short indoor activity videos | Whether fixed-grid CLIP scoring localizes described actions in short videos | Corpus search, speech, actors, or broad-domain generalization | Engineering A; data agreement gated; original and filtered splits must not be mixed | | Whole-video visual | [MSR-VTT](https://www.microsoft.com/en-us/research/publication/msr-vtt-a-large-video-description-dataset-for-bridging-video-and-language/) | Text-to-whole-video ranking under a declared retrieval split | Whether pooled VidXP scene embeddings retrieve the correct short video from a corpus | Timestamp localization, dialogue retrieval, actors, or comparability across the 9k/1k and 7k/1k/2k conventions | Engineering A; media preparation and exact split required | @@ -113,7 +113,7 @@ protocol. It does not mean that a similar published score is already comparable. | Face verification provenance | [LFW / dlib protocol](https://github.com/ageitgey/face_recognition) | Same/different identity verification on still-image pairs | Only the provenance and generic discrimination of the underlying face embedding | VidXP actor clustering, face detection coverage, temporal continuity, or unknown-K performance | Not an actor benchmark; provenance only | | Combined temporal | [LongVALE](https://github.com/ttgeng233/LongVALE) | Vision, speech, and generic-audio event grounding in long videos, with one interval per event query | Whether a frozen VidXP interval proposal and scene/speech fusion localize multimodal events | Actor clustering or full omni-modal coverage because VidXP lacks generic-audio recognition | Engineering A/medium; artifacts reachable; compliance, interval, and runtime gates remain | | Audiovisual retrieval | [FLARE](https://flarebench.github.io/) | Caption-to-clip/video retrieval plus clip-level model-simulated visual-only, audio-only, and joint query retrieval | Where VidXP's scene, speech, and fixed-fusion clip rankings succeed or fail across evidence types | Actor performance, human-authored-query generalization, or full generic-sound capability | Engineering A/medium; ready/watchlist preprint | -| Large-corpus event | [MultiVENT 2.0](https://huggingface.co/datasets/hltcoe/MultiVENT2.0) | Multilingual event-centric ranked-video retrieval using visual, speech/ASR, embedded-text/OCR, and human-description metadata evidence | Whether existing VidXP visual, speech, and metadata paths scale to a large heterogeneous corpus; unsupported OCR becomes a measurable weakness | Timestamp localization, actor clustering, generic acoustic-event retrieval, or multilingual adequacy of current MiniLM | Engineering A with large operational adapter; approximately 1.93 TB gated | +| Large-corpus event | [MultiVENT 2.0](https://huggingface.co/datasets/hltcoe/MultiVENT2.0) | Multilingual event-centric ranked-video retrieval using visual, speech/ASR, embedded-text/OCR, and human-description metadata evidence | Whether existing VidXP visual, speech, and metadata paths scale to a large heterogeneous corpus; unsupported OCR becomes a measurable weakness | Timestamp localization, actor clustering, generic acoustic-event retrieval, or multilingual adequacy of the current Qwen3 embedding provider | Engineering A with large operational adapter; approximately 1.93 TB gated | | Large-corpus shot | [TRECVID AVS / V3C](https://www-nlpir.nist.gov/projects/tv2025/avs.html) | Natural-language query to up to 1,000 ranked master shots, measured with mean xinfAP | Whether VidXP scene retrieval scales to over a million pooled/judged shots and returns useful corpus rankings | Dialogue or actor performance, or comparable latency across different hardware | Archived 2024/2025 protocol; agreement and approximately 1.6 TB gated | | Multimodal whole-video | [MUVR](https://github.com/debby-0527/MUVR) | Paired query-video plus detailed text to ranked short normalized videos; pure-text and pure-video are ablations | Whether VidXP ranks relevant videos for a declared pure-text ablation or a reusable-CLIP visual slice | Timestamp localization, audio/dialogue understanding, actors, or equivalence to the defining paired-query task | Ablation slice adaptable; lower priority | | Audiovisual whole-video | [VALOR-32K](https://github.com/TXH-mercury/VALOR) | Bidirectional audiovisual-text retrieval and audiovisual captioning over 32,000 ten-second clips with human captions | Whether VidXP's visual/speech or fixed audiovisual representation ranks captioned clips on a standard released split | Temporal localization, actor clustering, long-video search, or full parity without a generic-audio model | Benchmark and official artifacts verified; media links/source rights must be checked | @@ -520,7 +520,8 @@ The evaluator, not VidXP, should compute the published metrics. path, not a standalone reproduction package. That path additionally needs its base model/projector/stage weights, CUDA environment, and evaluator. In all cases, those features bypass VidXP and cannot substitute for running VidXP's - CLIP/WhisperX/MiniLM encoders on the raw evaluation videos. + configured scene/transcription/dialogue providers on the raw evaluation + videos. - **Operations:** raw evaluation media are now packaged directly, so YouTube survival is no longer the access gate. The raw MP4 payload expands to 43,662,117,997 bytes (40.664 GiB); retaining both raw ZIPs and extracted MP4s diff --git a/docs/benchmarking/core_contract.md b/docs/benchmarking/core_contract.md index c405e07..6a2897e 100644 --- a/docs/benchmarking/core_contract.md +++ b/docs/benchmarking/core_contract.md @@ -49,6 +49,7 @@ config = IndexConfig( dataset="example", split="test", run_id="scene-1fps", + generation_id="123456781234423481234567890abcde", enabled_modalities=("scene",), capability_options={ "scene": {"batch_size": 32, "sample_fps": 1.0}, @@ -58,18 +59,24 @@ config = IndexConfig( manifest = run_index( [ - VideoSource(video_id="video-001", path="videos/001.mp4"), - VideoSource(video_id="video-002", path="videos/002.mp4"), + VideoSource( + video_id="223456781234423481234567890abcde", + path="videos/001.mp4", + ), + VideoSource( + video_id="323456781234423481234567890abcde", + path="videos/002.mp4", + ), ], config, ) ``` -Released timestamped ASR can be indexed without WhisperX or video decoding: +Released timestamped ASR can be indexed without running a transcription model or decoding video: ```python source = VideoSource( - video_id="video-001", + video_id="223456781234423481234567890abcde", transcript=( {"text": "first timestamped span", "start": 0.0, "end": 2.4}, {"text": "second timestamped span", "start": 2.4, "end": 5.0}, @@ -80,20 +87,22 @@ config = IndexConfig( dataset="example", split="test", run_id="released-asr", + generation_id="423456781234423481234567890abcde", enabled_modalities=("dialogue",), ) run_index([source], config) ``` -Scene-only runs do not load WhisperX or face recognition. Supplied-transcript +Scene-only runs do not load a transcription or actor model. Supplied-transcript dialogue runs load the dialogue encoder but do not decode video. Actor-only runs -do not load CLIP or WhisperX. CLIP inference, dialogue encoding, and Chroma writes -use their configured batch sizes. Cancellation is cooperative and is checked -between batches. The Streamlit process exposes cancellation for indexing workers -it started and reports that the current batch must finish first. The shared file -lock also lets the UI detect an active CLI or separate-process run, although one -process cannot cancel a run owned by another process. +do not load scene or transcription models. Scene inference, dialogue encoding, +and Chroma writes use their configured batch sizes. Cancellation is cooperative +and is checked between batches. The Streamlit process exposes cancellation for +indexing workers it started and reports that the current batch must finish +first. The shared file lock also lets the UI detect an active CLI or +separate-process run, although one process cannot cancel a run owned by another +process. Scene and actor indexing share one video probe and one sampled-frame stream when both are enabled. Each materialized frame is converted to RGB once and then fed @@ -113,14 +122,17 @@ to the cadence. Manifests distinguish `source_frames_advanced`, unique ## Stored identity and metadata -Every record has a deterministic escaped source ID: +Generation-aware records have a deterministic escaped source ID: ```text -run_id:video_id:modality:local_id +generation_id:run_id:video_id:modality:local_id ``` Each component is encoded before joining, so a colon or Unicode character inside -an official ID cannot collide with the separator. +source metadata cannot collide with the separator. Product media and generation +IDs are lowercase UUID4 hex. Dataset adapters retain official video keys at +their input/evaluator boundary and deterministically map them to valid internal +IDs. - Dialogue records store text, start/end, phrase ID, video ID, modality, source ID, dataset, split, and run ID. @@ -152,7 +164,7 @@ for hit in result.hits: ) ``` -Passing `video_id="video-001"` restricts retrieval to one video; omitting it +Passing a media UUID through `video_id` restricts retrieval to one video; omitting it searches the run corpus. Results are deterministically ordered by raw distance and then source ID. Scene vectors and, by default, dialogue vectors are normalized before the explicitly configured Chroma distance (`vector_distance`, default diff --git a/docs/benchmarking/research_papers.md b/docs/benchmarking/research_papers.md index 005c9fd..fdc5d2c 100644 --- a/docs/benchmarking/research_papers.md +++ b/docs/benchmarking/research_papers.md @@ -120,7 +120,7 @@ ranks whole videos rather than locating timestamps. | Paper | Typical benchmarks | Relevance | | --- | --- | --- | -| [CLIP: Learning Transferable Visual Models From Natural Language Supervision](https://arxiv.org/abs/2103.00020) | Image-text transfer only; no selected video temporal benchmark | Foundation of the current scene encoder; provenance, not a video benchmark | +| [CLIP: Learning Transferable Visual Models From Natural Language Supervision](https://arxiv.org/abs/2103.00020) | Image-text transfer only; no selected video temporal benchmark | Foundation for the legacy CLIP scene baseline and a relevant SigLIP2 predecessor; provenance, not a video benchmark | | [CLIP4Clip](https://github.com/ArrowLuo/CLIP4Clip) | MSR-VTT, MSVD, LSMDC, ActivityNet, DiDeMo whole-video/clip retrieval | Established CLIP video-text comparator; its DiDeMo/ActivityNet use is not temporal localization | | [Frozen in Time](https://github.com/m-bain/frozen-in-time) | MSR-VTT, DiDeMo, LSMDC, MSVD whole-video retrieval | Retrieval baseline and practical MSR-VTT preparation route; WebVid/image-caption sets are pretraining | | [X-CLIP](https://github.com/xuguohai/X-CLIP) | MSR-VTT, MSVD, LSMDC, DiDeMo, ActivityNet whole-video retrieval | Stronger supervised CLIP-style comparator, not a timestamp baseline | diff --git a/docs/benchmarking/results.md b/docs/benchmarking/results.md index 673f690..ea5e233 100644 --- a/docs/benchmarking/results.md +++ b/docs/benchmarking/results.md @@ -1,4 +1,4 @@ -# Current benchmark results +# Benchmark results This page answers three questions: @@ -9,13 +9,41 @@ This page answers three questions: Detailed artifacts, hashes, commands, and evaluator behavior remain in the [adapter validation ledger](adapter_validation.md). -## Results at a glance +## Evidence at a glance -| Benchmark | Evaluated data | VidXP result | Status | -|---|---|---|---| -| DiDeMo | Official test: 4,021 searches over 1,037 videos | Rank@1 **20.19%**, Rank@5 **55.71%**, average overlap **34.60%** | Valid official test baseline with one documented replacement for a corrupt copy of an official video | -| HiREST | Official validation: 193 known-video searches | R@0.5 **78.24%**, R@0.7 **44.56%** | Validation result; the prediction-window setting was selected on this same validation set | -| HiREST | Released test: 776 known-video searches | Predictions generated, no score | Public test boundaries are placeholders, so local scoring would be meaningless | +| Generation | Benchmark | Evaluated data | Result | What it supports | +|---|---|---|---|---| +| Legacy full | DiDeMo | Official test: 4,021 searches over 1,037 videos | Rank@1 **20.19%**, Rank@5 **55.71%**, mean IoU **34.60%** | Full visual baseline with one documented replacement for a corrupt official media object | +| Legacy full | HiREST | Official validation: 193 known-video searches | R@0.5 **78.24%**, R@0.7 **44.56%** | Validation baseline; the 0.8 prediction-window fraction was selected on this same validation set | +| Legacy full | HiREST | Released test: 776 known-video searches | Predictions generated, not scored | Public test boundaries are placeholders, so local scoring would be meaningless | +| Current smoke | DiDeMo | Official test annotation index `0`; one video | Rank@1 **0**, Rank@5 **1**, mean IoU **0** | Real SigLIP2 execution, serialization, and official-evaluator check only | +| Current smoke | HiREST | Two declared validation pairs over two videos | R@0.5 **50**, R@0.7 **50** | Real Qwen3 execution, multi-video storage, filtered search, serialization, and official-evaluator check only | + +The current-provider rows are deliberately tiny regression runs. Their +percentages are not quality estimates and must not be compared with the full +legacy rows. A current full-corpus score has not been run. + +## Runtime and model generations + +The legacy and current checks used the same physical laptop, as confirmed for +this rerun: + +- HP ENVY Laptop 16-h0xxx; +- Intel Core i7-12700H, 14 cores and 20 logical processors; +- 15.72 GiB system memory; +- NVIDIA GeForce RTX 3060 Laptop GPU with 4 GiB VRAM present but unused; +- CPU-only PyTorch execution. + +| Generation | Dialogue embedding | Scene embedding | Sampling/window | Transcription in these benchmarks | +|---|---|---|---|---| +| Legacy full, 2026-07-27 | `all-MiniLM-L6-v2` | OpenAI CLIP `ViT-B/32` through `clip-anytorch` | HiREST 0.8-duration window; DiDeMo fixed 30-frame stride and max chunk pooling | Released HiREST SRTs; WhisperX `large-v2` was not exercised | +| Current smoke, 2026-07-30 | `Qwen/Qwen3-Embedding-0.6B` at `97b0c614be4d77ee51c0cef4e5f07c00f9eb65b3` | `google/siglip2-base-patch16-224` at `75de2d55ec2d0b4efc50b3e9ad70dba96a7b2fa2` | HiREST same 0.8-duration window; DiDeMo source-aware 1.0 sample/sec and max chunk pooling | Released HiREST SRTs; faster-whisper `large-v3-turbo` was not exercised | + +The surviving legacy run record did not pin immutable model revisions or a +complete package/OS snapshot. The current smoke manifests record Windows 11, +Python 3.14.0, PyTorch 2.13.0+cpu, Transformers 5.14.1, and +Sentence Transformers 5.6.1. This limitation is stated instead of inventing +legacy revision metadata after the fact. ## Multimodal comparison contract @@ -66,13 +94,13 @@ overlap = 8 / 12 = 0.67 ## DiDeMo -### Comparison +### Legacy full-result comparison | Method | Rank@1 | Rank@5 | Mean IoU | |---|---:|---:|---:| | Chance | 3.75% | 22.50% | 22.64% | | Common-position guess | 19.40% | 66.38% | 26.65% | -| **VidXP** | **20.19%** | **55.71%** | **34.60%** | +| **VidXP legacy CLIP baseline** | **20.19%** | **55.71%** | **34.60%** | | Published MCN trained on DiDeMo | 28.10% | 78.21% | 41.08% | The common-position guess ignores the search text and video content. It ranks @@ -84,7 +112,7 @@ DiDeMo-trained MCN system remains better on all three measurements. ### Honest conclusion -VidXP produces meaningful visual localization, but its candidate ranking is not +The legacy VidXP stack produces meaningful visual localization, but its candidate ranking is not yet competitive with the published trained system. Improving how scene evidence is combined across nearby frames is the clearest DiDeMo improvement target. @@ -95,12 +123,12 @@ and its checksum are recorded in the ## HiREST -### Comparison +### Legacy full-result comparison | Method on the same 193 validation pairs | R@0.5 | R@0.7 | |---|---:|---:| | Return almost the entire video without using the query | 68.91% | 23.83% | -| **VidXP transcript search** | **78.24%** | **44.56%** | +| **VidXP legacy MiniLM transcript search** | **78.24%** | **44.56%** | | Improvement | **+9.33 points** | **+20.73 points** | VidXP turns the transcript matches into a score over the video's timeline and @@ -120,7 +148,9 @@ or superiority over published HiREST systems because: - the released test answers contain placeholder time ranges and cannot be scored locally. -The result is a useful validation baseline, not a final held-out paper result. +The result is a useful legacy validation baseline, not a final held-out paper +result. The current two-video Qwen3 smoke establishes compatibility only; it +does not supersede this score. ## Next benchmark: LongVALE diff --git a/docs/benchmarking/runtime_validation.md b/docs/benchmarking/runtime_validation.md index f18b4ae..ffa368f 100644 --- a/docs/benchmarking/runtime_validation.md +++ b/docs/benchmarking/runtime_validation.md @@ -4,7 +4,30 @@ This ledger records executable checks for the benchmark-ready core. It is separate from unit-test coverage and from benchmark results. A smoke result here must not be reported as a paper score. -## 2026-07-27 Chunk 1 closure +## 2026-07-30 current-provider benchmark closure + +Two real, bounded runs validated the current CPU providers and official +evaluator paths after the model swap: + +| Check | Executed path | Observed result | +|---|---|---| +| HiREST multi-video smoke | Two released-ASR validation pairs; Qwen3 Embedding 0.6B; Chroma; filtered known-video search; 0.8-duration ranking; pinned evaluator | Both media remained indexed and searchable; two predictions serialized; R@0.5/R@0.7 `50/50`; empty failure log | +| DiDeMo sample | Official test annotation index `0`; SigLIP2; source-aware 1.0 sample/sec; max chunk pooling; pinned evaluator | One prediction containing all 21 candidates serialized; Rank@5 `1.0`; empty failure log | +| Targeted regression suite | Runner, benchmark adapters/CLI, search, storage unit and integration tests | 67 tests and 4 subtests passed | + +The percentages are smoke-subset outputs, not current full-corpus quality +scores. The runs used the same HP ENVY/i7-12700H/15.72 GiB laptop as the legacy +results. PyTorch 2.13.0+cpu did not use the installed RTX 3060 Laptop GPU. + +The executable pass found and fixed three integration defects before closure: + +- benchmark records lacked the now-required generation identity; +- multi-video retries deleted an entire shared generation instead of only the + current video's records; and +- official dataset filenames were still being supplied to UUID4-only product + media fields. + +## 2026-07-27 legacy-provider Chunk 1 closure ### Failure that triggered the pass diff --git a/src/vidxp/benchmarks/cli.py b/src/vidxp/benchmarks/cli.py index 132cbd3..d9592ee 100644 --- a/src/vidxp/benchmarks/cli.py +++ b/src/vidxp/benchmarks/cli.py @@ -110,6 +110,46 @@ def _pair_file(path: Path | None) -> list[tuple[str, str]] | None: return pairs +def _media_override_file(path: Path | None) -> dict[str, Path] | None: + if path is None: + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise typer.BadParameter( + "The DiDeMo media override file must contain valid readable JSON.", + param_hint="--media-overrides", + ) from exc + if not isinstance(payload, dict) or not payload: + raise typer.BadParameter( + "The DiDeMo media override file must be a non-empty JSON object.", + param_hint="--media-overrides", + ) + overrides: dict[str, Path] = {} + for video_name, replacement in payload.items(): + if ( + not isinstance(video_name, str) + or not video_name.strip() + or not isinstance(replacement, str) + or not replacement.strip() + ): + raise typer.BadParameter( + "Each DiDeMo media override must map a video name to a path.", + param_hint="--media-overrides", + ) + candidate = Path(replacement) + if not candidate.is_absolute(): + candidate = path.parent / candidate + candidate = candidate.resolve() + if not candidate.is_file(): + raise typer.BadParameter( + f"DiDeMo replacement media was not found: {candidate}", + param_hint="--media-overrides", + ) + overrides[video_name] = candidate + return overrides + + @app.command("didemo") def didemo_command( ctx: typer.Context, @@ -134,6 +174,17 @@ def didemo_command( ) ), ] = None, + media_overrides: Annotated[ + Path | None, + typer.Option( + exists=True, + dir_okay=False, + help=( + "JSON object mapping an official video name to documented " + "replacement media. Relative paths resolve beside the file." + ), + ), + ] = None, chunk_pooling: Annotated[ Literal["max", "mean"], typer.Option( @@ -165,6 +216,7 @@ def didemo_command( run_id=run_id, output_root=output_root, annotation_indices=_annotation_indices(annotation_indices), + media_overrides=_media_override_file(media_overrides), scene_sample_fps=scene_sample_fps, split=split, chunk_pooling=chunk_pooling, diff --git a/src/vidxp/benchmarks/common.py b/src/vidxp/benchmarks/common.py index 5df276a..743aa1b 100644 --- a/src/vidxp/benchmarks/common.py +++ b/src/vidxp/benchmarks/common.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json import os import subprocess @@ -9,6 +10,31 @@ from vidxp.core.manifest import sha256_file, utc_now, write_json_atomic +def _benchmark_uuid4(*parts: str) -> str: + digest = bytearray( + hashlib.sha256("\0".join(parts).encode("utf-8")).digest()[:16] + ) + digest[6] = (digest[6] & 0x0F) | 0x40 + digest[8] = (digest[8] & 0x3F) | 0x80 + return digest.hex() + + +def benchmark_generation_id( + benchmark: str, + split: str, + run_id: str, +) -> str: + """Return a stable UUID4-shaped identity for a resumable benchmark run.""" + + return _benchmark_uuid4("generation", benchmark, split, run_id) + + +def benchmark_media_id(benchmark: str, official_video_id: str) -> str: + """Map an official dataset video key to VidXP's opaque media ID shape.""" + + return _benchmark_uuid4("media", benchmark, official_video_id) + + def verify_artifact( path: str | Path, *, diff --git a/src/vidxp/benchmarks/didemo.py b/src/vidxp/benchmarks/didemo.py index d4cb021..ed260cf 100644 --- a/src/vidxp/benchmarks/didemo.py +++ b/src/vidxp/benchmarks/didemo.py @@ -11,6 +11,8 @@ from vidxp.benchmarks.common import ( append_failure, + benchmark_generation_id, + benchmark_media_id, ensure_adapter_outputs, record_adapter_manifest, run_logged_evaluator, @@ -258,7 +260,7 @@ def _video_sources( ) -> list[VideoSource]: return [ VideoSource( - video_id=video_name, + video_id=benchmark_media_id("didemo", video_name), path=resolve_media( media_directory, video_name, @@ -285,7 +287,10 @@ def _generate_predictions( } predictions = [] for annotation in annotations: - video_id = str(annotation["video"]) + video_id = benchmark_media_id( + "didemo", + str(annotation["video"]), + ) result = search_scene( str(annotation["description"]), config=config, @@ -392,6 +397,7 @@ def run_didemo( }, device=device, output_root=output_root, + generation_id=benchmark_generation_id("didemo", split, run_id), ) run_directory = config.run_directory registry = create_capability_registry( @@ -476,6 +482,9 @@ def run_didemo( "chunk_pooling": chunk_pooling, "media_override_count": len(media_overrides or {}), "media_override_video_ids": sorted(media_overrides or {}), + "media_id_mapping": ( + "deterministic_uuid4_from_benchmark_and_official_video_id" + ), "result_classification": _result_classification( split=split, full_split=annotation_indices is None, diff --git a/src/vidxp/benchmarks/hirest.py b/src/vidxp/benchmarks/hirest.py index c564a0b..c4dcf81 100644 --- a/src/vidxp/benchmarks/hirest.py +++ b/src/vidxp/benchmarks/hirest.py @@ -13,6 +13,8 @@ from vidxp.benchmarks.common import ( append_failure, + benchmark_generation_id, + benchmark_media_id, ensure_adapter_outputs, record_adapter_manifest, run_logged_evaluator, @@ -297,7 +299,7 @@ def _transcript_sources( ) sources.append( VideoSource( - video_id=video, + video_id=benchmark_media_id("hirest", video), source_name=asr_path.name, transcript=parse_srt(asr_path), checksum=sha256_file(asr_path), @@ -326,11 +328,12 @@ def _generate_predictions( } predictions: dict[str, dict[str, dict[str, list[float]]]] = {} for prompt, video in ordered_pairs: + media_id = benchmark_media_id("hirest", video) hits = search_dialogue( prompt, config=config, - top_k=dialogue_counts[video], - video_id=video, + top_k=dialogue_counts[media_id], + video_id=media_id, query_id=f"{prompt}\0{video}", runtime=runtime, storage=storage, @@ -439,6 +442,7 @@ def run_hirest( enabled_modalities=("dialogue",), device=device, output_root=output_root, + generation_id=benchmark_generation_id("hirest", split, run_id), ) run_directory = config.run_directory registry = create_capability_registry( @@ -521,6 +525,9 @@ def run_hirest( "temporal_window_fraction": temporal_window_fraction, "transcription_provider": "supplied-transcript", "video_decode_used": False, + "media_id_mapping": ( + "deterministic_uuid4_from_benchmark_and_official_video_id" + ), } if split == "test": summary = { diff --git a/src/vidxp/core/runner.py b/src/vidxp/core/runner.py index 0531726..8c47099 100644 --- a/src/vidxp/core/runner.py +++ b/src/vidxp/core/runner.py @@ -302,7 +302,12 @@ def set_stage(value: str) -> None: ) stage = "preparing_storage" if config.generation_id is not None: - storage.delete_generation(config.generation_id) + for modality in config.enabled_modalities: + storage.delete_records( + modality, + video_id=video_id, + filters={"generation_id": config.generation_id}, + ) else: for modality in config.enabled_modalities: storage.delete_video(modality, video_id) diff --git a/tests/test_benchmark_cli.py b/tests/test_benchmark_cli.py index f1fc655..4b0b7e6 100644 --- a/tests/test_benchmark_cli.py +++ b/tests/test_benchmark_cli.py @@ -57,9 +57,16 @@ def test_benchmark_uses_global_device_and_json_output(self): annotations = root / "annotations.json" evaluator = root / "evaluator.py" media = root / "media" + replacement = root / "replacement.webm" + overrides = root / "media-overrides.json" annotations.write_text("[]", encoding="utf-8") evaluator.write_text("", encoding="utf-8") media.mkdir() + replacement.write_bytes(b"replacement") + overrides.write_text( + json.dumps({"broken.mp4": replacement.name}), + encoding="utf-8", + ) with ( patch.object( cli, @@ -100,6 +107,8 @@ def test_benchmark_uses_global_device_and_json_output(self): str(media), "--run-id", "run-1", + "--media-overrides", + str(overrides), "--scene-sample-fps", "2", ], @@ -109,6 +118,10 @@ def test_benchmark_uses_global_device_and_json_output(self): self.assertEqual(json.loads(response.stdout)["rank_at_1"], 0.5) self.assertEqual(run.call_args.kwargs["device"], "cuda") self.assertEqual(run.call_args.kwargs["scene_sample_fps"], 2.0) + self.assertEqual( + run.call_args.kwargs["media_overrides"], + {"broken.mp4": replacement.resolve()}, + ) def test_missing_adapter_dependencies_fail_with_exact_extra_hint(self): registry = Mock() @@ -186,6 +199,20 @@ def test_invalid_hirest_pair_json_is_reported_as_input_error(self): self.assertIn("valid readable JSON", str(raised.exception)) self.assertEqual(raised.exception.param_hint, "--pairs") + def test_invalid_didemo_media_override_is_reported_as_input_error(self): + with TemporaryDirectory() as directory: + overrides = Path(directory) / "media-overrides.json" + overrides.write_text( + json.dumps({"broken.mp4": "missing.webm"}), + encoding="utf-8", + ) + + with self.assertRaises(typer.BadParameter) as raised: + benchmark_cli._media_override_file(overrides) + + self.assertIn("was not found", str(raised.exception)) + self.assertEqual(raised.exception.param_hint, "--media-overrides") + def test_hirest_rejects_closed_temporal_fraction_before_dependencies(self): runner = CliRunner() service = Mock() diff --git a/tests/test_benchmarks.py b/tests/test_benchmarks.py index 640fa18..e603a9a 100644 --- a/tests/test_benchmarks.py +++ b/tests/test_benchmarks.py @@ -1,11 +1,16 @@ import hashlib import json import unittest +from uuid import UUID from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import patch -from vidxp.benchmarks.common import verify_artifact +from vidxp.benchmarks.common import ( + benchmark_generation_id, + benchmark_media_id, + verify_artifact, +) from vidxp.benchmarks.didemo import ( DIDEMO_MOMENTS, _result_classification as didemo_result_classification, @@ -63,6 +68,34 @@ def timed_hit(start, end, score, rank=1): class BenchmarkCommonTests(unittest.TestCase): + def test_generation_identity_is_stable_and_run_scoped(self): + first = benchmark_generation_id("hirest", "validation", "run-1") + + self.assertEqual( + first, + benchmark_generation_id("hirest", "validation", "run-1"), + ) + self.assertEqual(len(first), 32) + self.assertEqual(UUID(hex=first).version, 4) + self.assertNotEqual( + first, + benchmark_generation_id("hirest", "validation", "run-2"), + ) + self.assertNotEqual( + first, + benchmark_generation_id("didemo", "validation", "run-1"), + ) + + def test_official_video_key_maps_to_stable_media_id(self): + first = benchmark_media_id("hirest", "video.mp4") + + self.assertEqual(first, benchmark_media_id("hirest", "video.mp4")) + self.assertEqual(UUID(hex=first).version, 4) + self.assertNotEqual( + first, + benchmark_media_id("didemo", "video.mp4"), + ) + def test_artifact_checksum_is_verified(self): with TemporaryDirectory() as directory: path = Path(directory) / "artifact.json" @@ -213,11 +246,11 @@ def test_held_out_test_writes_unscored_submission(self): patch( "vidxp.benchmarks.hirest.run_index", return_value={}, - ), + ) as run_index, patch( "vidxp.benchmarks.hirest._generate_predictions", return_value=predictions, - ), + ) as generate_predictions, patch( "vidxp.benchmarks.hirest._evaluate_predictions" ) as evaluate_predictions, @@ -241,6 +274,22 @@ def test_held_out_test_writes_unscored_submission(self): ) self.assertFalse((run_directory / "metrics.json").exists()) evaluate_predictions.assert_not_called() + index_config = run_index.call_args.args[1] + prediction_config = ( + generate_predictions.call_args.kwargs["config"] + ) + self.assertEqual( + index_config.generation_id, + benchmark_generation_id( + "hirest", + "test", + "test-submission", + ), + ) + self.assertEqual( + prediction_config.generation_id, + index_config.generation_id, + ) @patch("vidxp.benchmarks.hirest.run_logged_evaluator") def test_official_evaluator_is_forced_to_utf8( diff --git a/tests/test_runner.py b/tests/test_runner.py index ea290cf..852d67e 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -107,6 +107,17 @@ def clear(self, modalities=None): def delete_video(self, modality, video_id): self.deleted.append((modality, video_id)) + def delete_records( + self, + modality, + *, + video_id, + filters=None, + ): + self.deleted.append( + (modality, video_id, dict(filters or {})) + ) + def size_bytes(self): return self.size @@ -179,6 +190,66 @@ def scene_indexer(source, *, config, **_): [("scene", "video-1"), ("scene", "video-2")], ) + def test_generation_cleanup_is_scoped_to_each_video(self): + with TemporaryDirectory() as directory: + root = Path(directory) + first = root / "first.mp4" + second = root / "second.mp4" + first.write_bytes(b"first") + second.write_bytes(b"second") + config = IndexConfig( + dataset="sample", + split="test", + run_id="run-1", + enabled_modalities=("scene",), + output_root=root, + generation_id="generation-1", + ) + storage = FakeStorage() + + with ( + patch("vidxp.core.runner.require_dependencies"), + patch( + "vidxp.capabilities.visual.index_visuals", + return_value=visual_result( + { + "scene_frames": 1, + "decoded_frames": 1, + "duration": 1.0, + "fps": 1.0, + } + ), + ), + patch( + "vidxp.core.manifest.execution_state", + return_value=EXECUTION_STATE, + ), + ): + run_index( + [ + VideoSource(video_id="video-1", path=first), + VideoSource(video_id="video-2", path=second), + ], + config, + storage=storage, + ) + + self.assertEqual( + storage.deleted, + [ + ( + "scene", + "video-1", + {"generation_id": "generation-1"}, + ), + ( + "scene", + "video-2", + {"generation_id": "generation-1"}, + ), + ], + ) + def test_scene_and_actor_are_dispatched_as_one_visual_pipeline(self): with TemporaryDirectory() as directory: path = Path(directory) / "video.mp4" From 1bafc6a240713438764dcc7d9ed99801302c67ee Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 30 Jul 2026 10:42:57 +0500 Subject: [PATCH 38/46] feat(benchmarks): add guided input preparation --- changes/+benchmarking.feature.md | 2 +- docs/benchmarking/README.md | 1 + docs/benchmarking/adapter_validation.md | 78 ++- docs/benchmarking/runtime_validation.md | 17 + src/vidxp/benchmarks/cli.py | 274 +++++++- src/vidxp/benchmarks/common.py | 12 +- src/vidxp/benchmarks/didemo.py | 19 +- src/vidxp/benchmarks/hirest.py | 8 +- src/vidxp/benchmarks/prepare.py | 879 ++++++++++++++++++++++++ tests/test_benchmark_prepare.py | 385 +++++++++++ tests/test_benchmarks.py | 17 + 11 files changed, 1651 insertions(+), 41 deletions(-) create mode 100644 src/vidxp/benchmarks/prepare.py create mode 100644 tests/test_benchmark_prepare.py diff --git a/changes/+benchmarking.feature.md b/changes/+benchmarking.feature.md index ebbc315..d05175f 100644 --- a/changes/+benchmarking.feature.md +++ b/changes/+benchmarking.feature.md @@ -1 +1 @@ -Add optional DiDeMo and HiREST benchmark commands with pinned official evaluators, resumable generation-aware indexes, opaque internal media IDs, documented media substitutions, scene-cadence controls, and invocation-time checks that name the exact missing benchmark extra instead of breaking unrelated commands. Retain the legacy full-corpus baselines and record bounded real SigLIP2/Qwen3 provider smokes separately. +Add optional DiDeMo and HiREST benchmark commands with guided, resumable preparation of pinned inputs; media and checksum validation; explicit documented substitutions; generation-aware indexes; opaque internal media IDs; scene-cadence controls; and invocation-time checks that name the exact missing benchmark extra instead of breaking unrelated commands. Retain the legacy full-corpus baselines and record bounded real SigLIP2/Qwen3 provider smokes separately. diff --git a/docs/benchmarking/README.md b/docs/benchmarking/README.md index 13ccd1f..cfe692f 100644 --- a/docs/benchmarking/README.md +++ b/docs/benchmarking/README.md @@ -13,6 +13,7 @@ installation and product usage, start with the main | Area | Status | What it means | |---|---|---| | Shared benchmark support | Complete | Stable IDs, time ranges, metadata, top-k retrieval, isolated runs, checkpoints, and prediction files are implemented | +| Guided input preparation | Complete | `vidxp benchmark prepare` estimates and confirms downloads, verifies pinned artifacts, validates DiDeMo media, resumes partial transfers, and prints the runnable benchmark command | | DiDeMo visual localization | Legacy full result + current smoke | The legacy CLIP stack completed 4,021 official test queries over 1,037 videos; the current SigLIP2 stack passed a one-annotation real execution smoke | | HiREST transcript localization | Legacy full result + current smoke | The legacy MiniLM stack scored all 193 validation pairs; current Qwen3 passed a two-video real execution smoke; 776 released test predictions remain unscored because their public bounds are placeholders | | LongVALE combined evaluation | Next | Build the visual-plus-speech adapter and validate one evaluation archive before scheduling the full run | diff --git a/docs/benchmarking/adapter_validation.md b/docs/benchmarking/adapter_validation.md index 8cb2f18..aa92a8c 100644 --- a/docs/benchmarking/adapter_validation.md +++ b/docs/benchmarking/adapter_validation.md @@ -19,13 +19,22 @@ the model and protocol differences are recorded explicitly. |---|---|---|---| | DiDeMo | [`data/val_data.json`](https://github.com/LisaAnne/LocalizingMoments/blob/b6a555c8134581305d0ed4716fbc192860e0b88c/data/val_data.json) | `b6a555c8134581305d0ed4716fbc192860e0b88c` | `b0364cc256553332feb19d46bcc4cd2b09774949fe6c0b25e7ed0ff3c6aefebb` | | DiDeMo | [`data/test_data.json`](https://github.com/LisaAnne/LocalizingMoments/blob/b6a555c8134581305d0ed4716fbc192860e0b88c/data/test_data.json) | `b6a555c8134581305d0ed4716fbc192860e0b88c` | `1891c04ec48b3d364c739594b2b6413806b74bd9027c092d896e7ebb930ff1cd` | -| DiDeMo | [`utils/eval.py`](https://github.com/LisaAnne/LocalizingMoments/blob/b6a555c8134581305d0ed4716fbc192860e0b88c/utils/eval.py) | `b6a555c8134581305d0ed4716fbc192860e0b88c` | `4754bb320564e5d2e7c633e0b660e87feca7f00fa73269e50140e81ffb4ca762` | +| DiDeMo | [`utils/eval.py`](https://github.com/LisaAnne/LocalizingMoments/blob/b6a555c8134581305d0ed4716fbc192860e0b88c/utils/eval.py) | `b6a555c8134581305d0ed4716fbc192860e0b88c` | `9ec3e7a171272eb3551b0eaa7bbe9292131ad5cf34fd5c1e02c0fc4a11234df6` | +| DiDeMo | [`data/yfcc100m_hash.txt`](https://github.com/LisaAnne/LocalizingMoments/blob/b6a555c8134581305d0ed4716fbc192860e0b88c/data/yfcc100m_hash.txt) | `b6a555c8134581305d0ed4716fbc192860e0b88c` | `481d9aaf020624d5915200bcf4752fb46d3e1931167e8b46715a5f342577cc4d` | | HiREST | [`data/splits/all_data_val.json`](https://github.com/j-min/HiREST/blob/deffc169b4e8d51c1589d5512ad05da61e81bcee/data/splits/all_data_val.json) | `deffc169b4e8d51c1589d5512ad05da61e81bcee` | `70d32c5fcdffe66cbf3c732dd274f03378da2082f50c9cec7e67705f529ecb4d` | | HiREST | [`data/splits/all_data_test.json`](https://github.com/j-min/HiREST/blob/deffc169b4e8d51c1589d5512ad05da61e81bcee/data/splits/all_data_test.json) | `deffc169b4e8d51c1589d5512ad05da61e81bcee` | `00219050c022ff2fc89c210ca4db605de6aa13c5c6014e4c678345ade3448a62` | | HiREST | [`data/evaluation/categories.json`](https://github.com/j-min/HiREST/blob/deffc169b4e8d51c1589d5512ad05da61e81bcee/data/evaluation/categories.json) | `deffc169b4e8d51c1589d5512ad05da61e81bcee` | `157623d50f7b8482f55fa1c4efc500539784c0399fb2dd60bb687b4006d85ca1` | -| HiREST | [`evaluate.py`](https://github.com/j-min/HiREST/blob/deffc169b4e8d51c1589d5512ad05da61e81bcee/evaluate.py) | `deffc169b4e8d51c1589d5512ad05da61e81bcee` | `c4b8ba9b572ae4088e90ddc3eec2b2cc4f5b4c1a0153ff6e0843817da89a5ca0` | +| HiREST | [`evaluate.py`](https://github.com/j-min/HiREST/blob/deffc169b4e8d51c1589d5512ad05da61e81bcee/evaluate.py) | `deffc169b4e8d51c1589d5512ad05da61e81bcee` | `871b48dc5ce42fbe1a4b672fe4df88a88ce568d57759dfc971e5aacc5f88f119` | | HiREST | [`ASR.zip`](https://huggingface.co/j-min/HiREST-baseline/resolve/54e2f8da7a4384fec8a137011399f5e104069032/ASR.zip) | `54e2f8da7a4384fec8a137011399f5e104069032` | `0b452d38e30064dc7273a58b7b73ec33e307ff83d30048a472777f56e3a29fbc` | +The table records canonical raw upstream bytes. The adapters also accept the +semantically identical CRLF evaluator hashes produced by a Windows Git +checkout: `4754bb320564e5d2e7c633e0b660e87feca7f00fa73269e50140e81ffb4ca762` +for DiDeMo and +`c4b8ba9b572ae4088e90ddc3eec2b2cc4f5b4c1a0153ff6e0843817da89a5ca0` +for HiREST. The observed hash is recorded rather than silently normalizing a +user-supplied checkout. + The adapters verify these hashes before indexing. Each run manifest also records the artifact paths, URLs, revisions, sizes, and observed hashes. VidXP does not replace the official downloaders; the adapters consume prepared media and ASR. @@ -249,33 +258,37 @@ The final adapter behavior now: The temporary 1,037-video corpus, model-run indexes, repository clones, and ASR copy were removed after recording the evidence above. -## Commands +## Prepare and run -Install the optional adapters with the capabilities they evaluate: +Install the optional adapters and initialize FFmpeg: ```powershell -python -m pip install -e ".[scene,dialogue,benchmarks]" +uv tool install "vidxp[scene,dialogue,benchmarks]" +vidxp init ``` -Run a declared DiDeMo smoke subset by zero-based official annotation indices: +Prepare a declared DiDeMo smoke subset by zero-based official annotation +indices: ```powershell -vidxp benchmark didemo ` - --annotations /data/test_data.json ` - --evaluator /utils/eval.py ` - --media-directory ` - --media-overrides ` - --split test ` - --annotation-indices 0,1,2 ` - --run-id didemo-smoke +vidxp benchmark prepare didemo --split test --annotation-indices 0,1,2 ``` -Omit `--annotation-indices` for the full official test split. -`--media-overrides` is a JSON object mapping an official annotation filename to -a documented replacement path. Relative replacement paths resolve beside the -JSON file, and the run manifest records that a substitution was used. +The command reads the pinned metadata needed to inspect the selection, displays +the maximum additional storage, destination free space, and any documented +replacement, then asks before persisting files. It resumes `.part` downloads, +validates each video with +FFprobe and a decoded frame, writes `preparation-manifest.json`, and prints the +complete benchmark command. Omit `--annotation-indices` for the full official +split. + +The known corrupt Multimedia Commons object is never used or replaced +silently. If its annotation is selected, the plan identifies the archived +Wikimedia original, includes its 94,107,862-byte size, and records the SHA-1, +source URL, and generated `media-overrides.json`. `--yes` confirms the displayed +plan for automation; JSON output requires it. -For HiREST, a smoke pair file is a JSON list: +For a HiREST smoke, a pair file is a JSON list: ```json [ @@ -286,23 +299,26 @@ For HiREST, a smoke pair file is a JSON list: ] ``` -Run the declared pair subset: +Prepare the released-ASR archive and a self-contained copy of that selection: ```powershell -vidxp benchmark hirest ` - --ground-truth /data/splits/all_data_test.json ` - --categories /data/evaluation/categories.json ` - --evaluator /evaluate.py ` - --asr-archive /ASR.zip ` - --asr-directory /ASR ` +vidxp benchmark prepare hirest ` --split test ` - --temporal-window-fraction 0.8 ` - --pairs ` - --run-id hirest-smoke + --pairs .\hirest-smoke-pairs.json ``` -Omit `--pairs` to generate all 776 official test predictions. Test output is -explicitly unscored; use `--split validation` with the pinned validation file +Preparation downloads the pinned annotations, categories, evaluator, and +released ASR archive, then extracts only the selected transcripts. No HiREST +video is downloaded by this adapter. Omit `--pairs` to prepare the complete +selected split. + +By default, prepared data is stored below VidXP's platform-native application +data directory under `benchmarks/`. Use `--output-directory` to +choose another volume. The final line is deliberately a copy/paste command, so +the user does not have to assemble artifact paths manually. + +The generated direct command can omit `--pairs` to produce all 776 official +test predictions. Test output is explicitly unscored; use `--split validation` for locally evaluable official metrics. ## Shared run output diff --git a/docs/benchmarking/runtime_validation.md b/docs/benchmarking/runtime_validation.md index ffa368f..49ce5c7 100644 --- a/docs/benchmarking/runtime_validation.md +++ b/docs/benchmarking/runtime_validation.md @@ -27,6 +27,23 @@ The executable pass found and fixed three integration defects before closure: - official dataset filenames were still being supplied to UUID4-only product media fields. +## 2026-07-30 benchmark-preparation closure + +The guided acquisition path was exercised independently of model loading and +indexing: + +| Check | Executed path | Observed result | +|---|---|---| +| DiDeMo preparation | Official test annotation `0`; pinned raw annotations/evaluator/hash map; one Multimedia Commons video | 5.3 MiB maximum-additional-storage plan shown; download completed; FFprobe and frame decode passed; manifest and runnable command emitted | +| DiDeMo resumability | Repeated the same preparation directory | Zero new files and zero additional bytes; existing checksums and media validation passed | +| HiREST preparation | Complete validation moment set; pinned metadata and released ASR | 7.2 MiB downloaded; the plan now reserves up to 17.2 MB for transcript extraction; 193 selected transcripts extracted; manifest and runnable command emitted | + +Both temporary preparation directories were removed after validation. This pass +also found that the earlier evaluator checksums described CRLF Windows checkout +bytes rather than canonical raw GitHub bytes. Preparation now pins canonical +raw hashes while direct adapter invocation accepts and records either canonical +LF or the known CRLF checkout form. + ## 2026-07-27 legacy-provider Chunk 1 closure ### Failure that triggered the pass diff --git a/src/vidxp/benchmarks/cli.py b/src/vidxp/benchmarks/cli.py index d9592ee..34fa5e2 100644 --- a/src/vidxp/benchmarks/cli.py +++ b/src/vidxp/benchmarks/cli.py @@ -6,17 +6,34 @@ import typer from rich import print as rich_print +from rich.console import Console +from rich.progress import ( + BarColumn, + DownloadColumn, + Progress, + TextColumn, + TimeRemainingColumn, + TransferSpeedColumn, +) from vidxp.benchmarks.didemo import run_didemo from vidxp.benchmarks.hirest import ( HIREST_DEFAULT_WINDOW_FRACTION, run_hirest, ) +from vidxp.benchmarks.prepare import ( + PreparationPlan, + execute_preparation, + plan_didemo, + plan_hirest, +) +from vidxp.app_paths import available_storage_bytes from vidxp.capabilities.registry import create_capability_registry from vidxp.cli_support import ( OutputFormat, effective_output_format, emit_json, + emit_progress, state_from_context, ) from vidxp.dependencies import ( @@ -26,7 +43,124 @@ ) -app = typer.Typer(help="Run official benchmark adapters.") +app = typer.Typer(help="Prepare and run official benchmark adapters.") +prepare_app = typer.Typer( + help="Download, verify, and arrange pinned benchmark inputs." +) +app.add_typer(prepare_app, name="prepare") + + +def _format_bytes(value: int) -> str: + size = float(value) + for unit in ("bytes", "KiB", "MiB", "GiB", "TiB"): + if size < 1024 or unit == "TiB": + precision = 0 if unit == "bytes" else 1 + return f"{size:.{precision}f} {unit}" + size /= 1024 + raise AssertionError("unreachable") + + +def _show_preparation_plan(plan: PreparationPlan) -> None: + typer.secho("Benchmark preparation plan", bold=True) + typer.echo(f" Benchmark: {plan.benchmark} {plan.split}") + typer.echo( + f" Selection: {plan.selected_count} item(s), " + f"{len(plan.selected_video_names)} video(s)" + ) + typer.echo(f" Destination: {plan.root}") + typer.echo( + f" New files: {plan.download_count}; " + "maximum additional storage: " + f"{_format_bytes(plan.additional_bytes)}" + ) + free_bytes = available_storage_bytes(plan.root) + if free_bytes is not None: + typer.echo(f" Free space at destination: {_format_bytes(free_bytes)}") + if free_bytes < plan.additional_bytes: + typer.secho( + " Warning: the destination may not have enough free space. " + "Choose another --output-directory or free space first.", + fg=typer.colors.YELLOW, + ) + replacements = [ + resource + for resource in plan.resources + if resource.replacement_for is not None + ] + for resource in replacements: + typer.secho( + " Documented replacement: " + f"{resource.replacement_for} -> {resource.url}", + fg=typer.colors.YELLOW, + ) + + +def _execute_preparation_plan( + plan: PreparationPlan, + *, + state, + yes: bool, + json_output: bool, +) -> None: + output_format = effective_output_format(state, json_output) + if output_format == OutputFormat.rich: + _show_preparation_plan(plan) + if plan.download_count and not yes: + if output_format == OutputFormat.json: + raise typer.BadParameter( + "Benchmark downloads require --yes with JSON output.", + param_hint="--yes", + ) + typer.confirm( + "Download and prepare these benchmark inputs?", + abort=True, + ) + network_bytes = plan.network_bytes + if ( + state.quiet + or output_format == OutputFormat.json + or network_bytes == 0 + ): + result = execute_preparation( + plan, + ffprobe=state.settings.ffprobe_executable, + ffmpeg=state.settings.ffmpeg_executable, + ) + else: + console = Console(stderr=True) + with Progress( + TextColumn("{task.description}"), + BarColumn(), + DownloadColumn(), + TransferSpeedColumn(), + TimeRemainingColumn(), + console=console, + ) as progress: + task = progress.add_task( + "Downloading benchmark inputs", + total=network_bytes or None, + ) + + def advance(name: str, amount: int) -> None: + progress.update( + task, + description=f"Downloading {name}", + advance=amount, + ) + + result = execute_preparation( + plan, + ffprobe=state.settings.ffprobe_executable, + ffmpeg=state.settings.ffmpeg_executable, + progress=advance, + ) + if output_format == OutputFormat.json: + emit_json(result) + return + typer.secho("Benchmark inputs are ready.", fg=typer.colors.GREEN) + typer.echo(f"Manifest: {plan.manifest_path}") + typer.echo("Run:") + typer.secho(plan.command, bold=True) def _require_benchmark_dependencies( @@ -150,6 +284,144 @@ def _media_override_file(path: Path | None) -> dict[str, Path] | None: return overrides +@prepare_app.command("didemo") +def prepare_didemo_command( + ctx: typer.Context, + split: Annotated[ + Literal["validation", "test"], + typer.Option(help="Official split to prepare."), + ] = "test", + annotation_indices: Annotated[ + str | None, + typer.Option( + help=( + "Comma-separated zero-based annotation indices. " + "Omit to prepare the full selected split." + ) + ), + ] = None, + output_directory: Annotated[ + Path | None, + typer.Option( + file_okay=False, + help=( + "Preparation destination. Defaults to the VidXP application " + "data directory." + ), + ), + ] = None, + yes: Annotated[ + bool, + typer.Option( + "--yes", + "-y", + help="Confirm the displayed download and replacement plan.", + ), + ] = False, + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """Prepare verified DiDeMo artifacts and selected official videos.""" + + state = state_from_context(ctx) + root = ( + output_directory + if output_directory is not None + else state.settings.data_dir / "benchmarks" / "didemo" + ) + if ( + not state.quiet + and effective_output_format(state, json_output) == OutputFormat.rich + ): + emit_progress( + "Inspecting pinned DiDeMo metadata and download sizes..." + ) + plan = plan_didemo( + root=root, + split=split, + annotation_indices=_annotation_indices(annotation_indices), + ffprobe=state.settings.ffprobe_executable, + ffmpeg=state.settings.ffmpeg_executable, + ) + _execute_preparation_plan( + plan, + state=state, + yes=yes, + json_output=json_output, + ) + + +@prepare_app.command("hirest") +def prepare_hirest_command( + ctx: typer.Context, + split: Annotated[ + Literal["validation", "test"], + typer.Option(help="Official split to prepare."), + ] = "validation", + pairs: Annotated[ + Path | None, + typer.Option( + exists=True, + dir_okay=False, + help=( + "Optional JSON list of {prompt, video} pairs for a " + "declared smoke subset." + ), + ), + ] = None, + output_directory: Annotated[ + Path | None, + typer.Option( + file_okay=False, + help=( + "Preparation destination. Defaults to the VidXP application " + "data directory." + ), + ), + ] = None, + yes: Annotated[ + bool, + typer.Option( + "--yes", + "-y", + help="Confirm the displayed download plan.", + ), + ] = False, + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """Prepare verified HiREST artifacts and released transcripts.""" + + state = state_from_context(ctx) + root = ( + output_directory + if output_directory is not None + else state.settings.data_dir / "benchmarks" / "hirest" + ) + if ( + not state.quiet + and effective_output_format(state, json_output) == OutputFormat.rich + ): + emit_progress( + "Inspecting pinned HiREST metadata and download sizes..." + ) + plan = plan_hirest( + root=root, + split=split, + pairs=_pair_file(pairs), + ) + _execute_preparation_plan( + plan, + state=state, + yes=yes, + json_output=json_output, + ) + + @app.command("didemo") def didemo_command( ctx: typer.Context, diff --git a/src/vidxp/benchmarks/common.py b/src/vidxp/benchmarks/common.py index 743aa1b..f9d2853 100644 --- a/src/vidxp/benchmarks/common.py +++ b/src/vidxp/benchmarks/common.py @@ -39,7 +39,7 @@ def verify_artifact( path: str | Path, *, name: str, - expected_sha256: str, + expected_sha256: str | Sequence[str], source: str, revision: str, ) -> dict[str, Any]: @@ -47,9 +47,15 @@ def verify_artifact( if not artifact.is_file(): raise FileNotFoundError(f"{name} not found: {artifact}") actual_sha256 = sha256_file(artifact) - if actual_sha256 != expected_sha256: + accepted = ( + (expected_sha256,) + if isinstance(expected_sha256, str) + else tuple(expected_sha256) + ) + if actual_sha256 not in accepted: raise ValueError( - f"{name} checksum mismatch: expected {expected_sha256}, " + f"{name} checksum mismatch: expected one of " + f"{', '.join(accepted)}, " f"received {actual_sha256}." ) return { diff --git a/src/vidxp/benchmarks/didemo.py b/src/vidxp/benchmarks/didemo.py index ed260cf..11f6d89 100644 --- a/src/vidxp/benchmarks/didemo.py +++ b/src/vidxp/benchmarks/didemo.py @@ -39,6 +39,9 @@ "b0364cc256553332feb19d46bcc4cd2b09774949fe6c0b25e7ed0ff3c6aefebb" ) DIDEMO_EVALUATOR_SHA256 = ( + "9ec3e7a171272eb3551b0eaa7bbe9292131ad5cf34fd5c1e02c0fc4a11234df6" +) +DIDEMO_EVALUATOR_CRLF_SHA256 = ( "4754bb320564e5d2e7c633e0b660e87feca7f00fa73269e50140e81ffb4ca762" ) DIDEMO_MOMENTS = ( @@ -47,8 +50,7 @@ ) -def load_annotations(path: str | Path) -> list[dict[str, Any]]: - payload = json.loads(Path(path).read_text(encoding="utf-8")) +def parse_annotations(payload: Any) -> list[dict[str, Any]]: if not isinstance(payload, list) or not payload: raise ValueError("DiDeMo annotations must be a non-empty JSON list.") required = { @@ -79,7 +81,13 @@ def load_annotations(path: str | Path) -> list[dict[str, Any]]: raise ValueError( f"DiDeMo annotation {index} exceeds num_segments." ) - return payload + return [dict(annotation) for annotation in payload] + + +def load_annotations(path: str | Path) -> list[dict[str, Any]]: + return parse_annotations( + json.loads(Path(path).read_text(encoding="utf-8")) + ) def select_annotations( @@ -242,7 +250,10 @@ def _verified_artifacts( verify_artifact( evaluator_path, name="DiDeMo evaluator", - expected_sha256=DIDEMO_EVALUATOR_SHA256, + expected_sha256=( + DIDEMO_EVALUATOR_SHA256, + DIDEMO_EVALUATOR_CRLF_SHA256, + ), source=( f"{DIDEMO_REPOSITORY}/blob/{DIDEMO_REVISION}" "/utils/eval.py" diff --git a/src/vidxp/benchmarks/hirest.py b/src/vidxp/benchmarks/hirest.py index c4dcf81..15ece16 100644 --- a/src/vidxp/benchmarks/hirest.py +++ b/src/vidxp/benchmarks/hirest.py @@ -53,6 +53,9 @@ "157623d50f7b8482f55fa1c4efc500539784c0399fb2dd60bb687b4006d85ca1" ) HIREST_EVALUATOR_SHA256 = ( + "871b48dc5ce42fbe1a4b672fe4df88a88ce568d57759dfc971e5aacc5f88f119" +) +HIREST_EVALUATOR_CRLF_SHA256 = ( "c4b8ba9b572ae4088e90ddc3eec2b2cc4f5b4c1a0153ff6e0843817da89a5ca0" ) HIREST_DEFAULT_WINDOW_FRACTION = 0.8 @@ -271,7 +274,10 @@ def _verified_artifacts( verify_artifact( evaluator_path, name="HiREST evaluator", - expected_sha256=HIREST_EVALUATOR_SHA256, + expected_sha256=( + HIREST_EVALUATOR_SHA256, + HIREST_EVALUATOR_CRLF_SHA256, + ), source=f"{revision_root}/evaluate.py", revision=HIREST_REVISION, ), diff --git a/src/vidxp/benchmarks/prepare.py b/src/vidxp/benchmarks/prepare.py new file mode 100644 index 0000000..3e861df --- /dev/null +++ b/src/vidxp/benchmarks/prepare.py @@ -0,0 +1,879 @@ +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import tempfile +import urllib.error +import urllib.request +import zipfile +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any, Callable, Literal, Mapping, Sequence + +from vidxp.application_models import ApplicationError, ErrorCategory +from vidxp.benchmarks.didemo import ( + DIDEMO_EVALUATOR_SHA256, + DIDEMO_REVISION, + DIDEMO_TEST_SHA256, + DIDEMO_VALIDATION_SHA256, + parse_annotations, + select_annotations, +) +from vidxp.benchmarks.hirest import ( + HIREST_ASR_SHA256, + HIREST_ASR_URL, + HIREST_CATEGORIES_SHA256, + HIREST_EVALUATOR_SHA256, + HIREST_REVISION, + HIREST_TEST_SHA256, + HIREST_VALIDATION_SHA256, + select_ground_truth, +) +from vidxp.core.manifest import sha256_file, utc_now, write_json_atomic +from vidxp.infrastructure.local_media import FFprobeMediaProbe +from vidxp.media_runtime import inspect_media_runtime + + +DIDEMO_RAW_ROOT = ( + "https://raw.githubusercontent.com/LisaAnne/LocalizingMoments/" + f"{DIDEMO_REVISION}" +) +DIDEMO_HASH_MAP_SHA256 = ( + "481d9aaf020624d5915200bcf4752fb46d3e1931167e8b46715a5f342577cc4d" +) +DIDEMO_AWS_TEMPLATE = ( + "https://multimedia-commons.s3-us-west-2.amazonaws.com/" + "data/videos/mp4/{first}/{second}/{digest}.mp4" +) +DIDEMO_KNOWN_REPLACEMENT_VIDEO = ( + "12090392@N02_13482799053_87ef417396.mov" +) +DIDEMO_KNOWN_REPLACEMENT_URL = ( + "https://upload.wikimedia.org/wikipedia/commons/a/af/" + "Common_Starlings_flying_away_from_a_Marsh_Harrier.webm" +) +DIDEMO_KNOWN_REPLACEMENT_SHA1 = ( + "2aefa90d4256e74cf62e492729c0e0f6d6bede72" +) +DIDEMO_KNOWN_REPLACEMENT_SIZE = 94_107_862 + +HIREST_RAW_ROOT = ( + "https://raw.githubusercontent.com/j-min/HiREST/" + f"{HIREST_REVISION}" +) +HIREST_ASR_UNCOMPRESSED_SIZE = 17_236_665 + +ProgressCallback = Callable[[str, int], None] + + +@dataclass(frozen=True) +class PreparationResource: + name: str + url: str + destination: Path + size_bytes: int + kind: Literal["artifact", "media"] + content: bytes | None = None + expected_sha256: str | None = None + expected_sha1: str | None = None + existing: bool = False + replacement_for: str | None = None + + def public_record(self) -> dict[str, Any]: + return { + "name": self.name, + "url": self.url, + "path": str(self.destination), + "size_bytes": self.size_bytes, + "kind": self.kind, + "expected_sha256": self.expected_sha256, + "expected_sha1": self.expected_sha1, + "existing": self.existing, + "replacement_for": self.replacement_for, + } + + +@dataclass(frozen=True) +class PreparationPlan: + benchmark: Literal["didemo", "hirest"] + split: Literal["validation", "test"] + root: Path + resources: tuple[PreparationResource, ...] + selected_count: int + selected_video_names: tuple[str, ...] + command: str + manifest_path: Path + media_directory: Path | None = None + media_overrides_path: Path | None = None + asr_directory: Path | None = None + extraction_reserve_bytes: int = 0 + + @property + def additional_bytes(self) -> int: + return self.extraction_reserve_bytes + sum( + self._remaining_bytes(resource) for resource in self.resources + ) + + @property + def network_bytes(self) -> int: + return sum( + self._remaining_bytes(resource) + for resource in self.resources + if resource.content is None + ) + + @property + def download_count(self) -> int: + return sum(not resource.existing for resource in self.resources) + + @staticmethod + def _remaining_bytes(resource: PreparationResource) -> int: + if resource.existing: + return 0 + if resource.content is not None: + return resource.size_bytes + partial = resource.destination.with_name( + resource.destination.name + ".part" + ) + partial_size = partial.stat().st_size if partial.is_file() else 0 + if partial_size > resource.size_bytes: + return resource.size_bytes + return resource.size_bytes - partial_size + + +def _application_error( + code: str, + message: str, + *, + details: Mapping[str, Any] | None = None, +) -> ApplicationError: + return ApplicationError( + code, + ErrorCategory.unavailable, + message, + details=dict(details or {}), + ) + + +def _request(url: str, *, method: str = "GET", range_start: int = 0): + headers = {"User-Agent": "VidXP benchmark preparation"} + if range_start: + headers["Range"] = f"bytes={range_start}-" + return urllib.request.Request(url, headers=headers, method=method) + + +def _fetch_bytes(url: str) -> bytes: + try: + with urllib.request.urlopen(_request(url), timeout=60) as response: + return response.read() + except (OSError, urllib.error.URLError) as exc: + raise _application_error( + "benchmark_source_unavailable", + f"Could not read pinned benchmark source: {url}", + details={"url": url, "reason": str(exc)}, + ) from exc + + +def _fetch_verified(url: str, expected_sha256: str, name: str) -> bytes: + content = _fetch_bytes(url) + observed = hashlib.sha256(content).hexdigest() + if observed != expected_sha256: + raise _application_error( + "benchmark_source_checksum_mismatch", + f"{name} did not match its pinned SHA-256.", + details={ + "url": url, + "expected_sha256": expected_sha256, + "observed_sha256": observed, + }, + ) + return content + + +def _remote_size(url: str) -> int: + try: + with urllib.request.urlopen( + _request(url, method="HEAD"), + timeout=30, + ) as response: + value = response.headers.get("Content-Length") + if value and int(value) > 0: + return int(value) + except (OSError, ValueError, urllib.error.URLError): + pass + try: + with urllib.request.urlopen( + _request(url, range_start=0), + timeout=30, + ) as response: + content_range = response.headers.get("Content-Range", "") + if "/" in content_range: + value = int(content_range.rsplit("/", 1)[1]) + if value > 0: + return value + value = response.headers.get("Content-Length") + if value and int(value) > 0: + return int(value) + except (OSError, ValueError, urllib.error.URLError) as exc: + raise _application_error( + "benchmark_download_size_unavailable", + f"Could not determine the download size for {url}", + details={"url": url, "reason": str(exc)}, + ) from exc + raise _application_error( + "benchmark_download_size_unavailable", + f"The benchmark source did not report a download size: {url}", + details={"url": url}, + ) + + +def _sha1_file(path: Path) -> str: + digest = hashlib.sha1() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _valid_artifact( + path: Path, + *, + expected_sha256: str | None = None, + expected_sha1: str | None = None, +) -> bool: + if not path.is_file(): + return False + if expected_sha256 and sha256_file(path) != expected_sha256: + return False + return not expected_sha1 or _sha1_file(path) == expected_sha1 + + +def _validate_media(path: Path, *, ffprobe: str, ffmpeg: str) -> None: + media = FFprobeMediaProbe(ffprobe).probe(path) + seek = min(15.0, max(0.0, media.duration_seconds / 2)) + try: + subprocess.run( + [ + ffmpeg, + "-v", + "error", + "-ss", + str(seek), + "-i", + str(path), + "-frames:v", + "1", + "-f", + "null", + "-", + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + timeout=60, + ) + except OSError as exc: + raise _application_error( + "benchmark_media_runtime_unavailable", + "FFmpeg could not be executed while validating benchmark media.", + details={"ffmpeg": ffmpeg, "reason": str(exc)}, + ) from exc + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + raise ValueError( + f"FFmpeg could not decode a frame from {path}." + ) from exc + + +def _valid_media(path: Path, *, ffprobe: str, ffmpeg: str) -> bool: + if not path.is_file(): + return False + try: + _validate_media(path, ffprobe=ffprobe, ffmpeg=ffmpeg) + except (ApplicationError, OSError, ValueError): + return False + return True + + +def _artifact_resource( + *, + name: str, + url: str, + destination: Path, + expected_sha256: str, + content: bytes, +) -> PreparationResource: + return PreparationResource( + name=name, + url=url, + destination=destination, + size_bytes=len(content), + kind="artifact", + content=content, + expected_sha256=expected_sha256, + existing=_valid_artifact( + destination, + expected_sha256=expected_sha256, + ), + ) + + +def _with_remote_sizes( + resources: Sequence[PreparationResource], +) -> tuple[PreparationResource, ...]: + resolved = list(resources) + pending = { + index: resource + for index, resource in enumerate(resources) + if ( + resource.content is None + and not resource.existing + and resource.size_bytes <= 0 + ) + } + with ThreadPoolExecutor(max_workers=8) as pool: + futures = { + pool.submit(_remote_size, resource.url): index + for index, resource in pending.items() + } + for future in as_completed(futures): + index = futures[future] + resolved[index] = replace( + pending[index], + size_bytes=future.result(), + ) + return tuple(resolved) + + +def _parse_didemo_hashes(content: bytes) -> dict[str, str]: + mapping: dict[str, str] = {} + for line in content.decode("utf-8").splitlines(): + fields = line.strip().split("\t") + if len(fields) == 2 and fields[0] and fields[1]: + mapping[fields[0]] = fields[1] + if not mapping: + raise ValueError("The pinned DiDeMo YFCC hash map is empty.") + return mapping + + +def _didemo_aws_url(video_name: str, hashes: Mapping[str, str]) -> str: + fields = video_name.split("_") + if len(fields) < 3 or not fields[1]: + raise ValueError(f"Unsupported DiDeMo video name: {video_name}") + try: + digest = hashes[fields[1]] + except KeyError as exc: + raise ValueError( + f"No YFCC hash exists for DiDeMo video {video_name}." + ) from exc + return DIDEMO_AWS_TEMPLATE.format( + first=digest[:3], + second=digest[3:6], + digest=digest, + ) + + +def _quote_powershell(value: Path | str) -> str: + return "'" + str(value).replace("'", "''") + "'" + + +def plan_didemo( + *, + root: str | Path, + split: Literal["validation", "test"], + annotation_indices: Sequence[int] | None, + ffprobe: str, + ffmpeg: str, +) -> PreparationPlan: + runtime_status = inspect_media_runtime( + ffprobe=ffprobe, + ffmpeg=ffmpeg, + ) + if not runtime_status.ready: + raise _application_error( + "benchmark_media_runtime_unavailable", + "DiDeMo preparation requires FFmpeg and ffprobe to validate " + "downloaded videos. Run `vidxp init`, then retry.", + details={ + "errors": list(runtime_status.errors), + "remediation": "vidxp init", + }, + ) + destination = Path(root).expanduser().resolve() + split_name = "val" if split == "validation" else "test" + annotation_sha = ( + DIDEMO_VALIDATION_SHA256 + if split == "validation" + else DIDEMO_TEST_SHA256 + ) + annotation_url = f"{DIDEMO_RAW_ROOT}/data/{split_name}_data.json" + evaluator_url = f"{DIDEMO_RAW_ROOT}/utils/eval.py" + hash_url = f"{DIDEMO_RAW_ROOT}/data/yfcc100m_hash.txt" + annotation_content = _fetch_verified( + annotation_url, + annotation_sha, + f"DiDeMo {split} annotations", + ) + evaluator_content = _fetch_verified( + evaluator_url, + DIDEMO_EVALUATOR_SHA256, + "DiDeMo evaluator", + ) + hash_content = _fetch_verified( + hash_url, + DIDEMO_HASH_MAP_SHA256, + "DiDeMo YFCC hash map", + ) + annotations = select_annotations( + parse_annotations(json.loads(annotation_content)), + annotation_indices, + ) + video_names = tuple( + sorted({str(annotation["video"]) for annotation in annotations}) + ) + hashes = _parse_didemo_hashes(hash_content) + annotations_path = destination / "annotations" / f"{split_name}_data.json" + evaluator_path = destination / "evaluator" / "eval.py" + hash_path = destination / "metadata" / "yfcc100m_hash.txt" + media_directory = destination / "media" + resources: list[PreparationResource] = [ + _artifact_resource( + name=f"DiDeMo {split} annotations", + url=annotation_url, + destination=annotations_path, + expected_sha256=annotation_sha, + content=annotation_content, + ), + _artifact_resource( + name="DiDeMo evaluator", + url=evaluator_url, + destination=evaluator_path, + expected_sha256=DIDEMO_EVALUATOR_SHA256, + content=evaluator_content, + ), + _artifact_resource( + name="DiDeMo YFCC hash map", + url=hash_url, + destination=hash_path, + expected_sha256=DIDEMO_HASH_MAP_SHA256, + content=hash_content, + ), + ] + overrides: dict[str, str] = {} + for video_name in video_names: + if video_name == DIDEMO_KNOWN_REPLACEMENT_VIDEO: + replacement_path = ( + destination + / "replacements" + / "common-starlings-flickr-13482799053.webm" + ) + overrides[video_name] = str( + replacement_path.relative_to(destination) + ) + resources.append( + PreparationResource( + name=f"documented replacement for {video_name}", + url=DIDEMO_KNOWN_REPLACEMENT_URL, + destination=replacement_path, + size_bytes=DIDEMO_KNOWN_REPLACEMENT_SIZE, + kind="media", + expected_sha1=DIDEMO_KNOWN_REPLACEMENT_SHA1, + existing=( + _valid_artifact( + replacement_path, + expected_sha1=DIDEMO_KNOWN_REPLACEMENT_SHA1, + ) + and _valid_media( + replacement_path, + ffprobe=ffprobe, + ffmpeg=ffmpeg, + ) + ), + replacement_for=video_name, + ) + ) + continue + path = media_directory / video_name + resources.append( + PreparationResource( + name=video_name, + url=_didemo_aws_url(video_name, hashes), + destination=path, + size_bytes=0, + kind="media", + existing=_valid_media( + path, + ffprobe=ffprobe, + ffmpeg=ffmpeg, + ), + ) + ) + overrides_path = ( + destination / "media-overrides.json" if overrides else None + ) + if overrides_path is not None: + override_content = ( + json.dumps(overrides, indent=2, sort_keys=True) + "\n" + ).encode("utf-8") + override_sha = hashlib.sha256(override_content).hexdigest() + resources.append( + _artifact_resource( + name="DiDeMo media overrides", + url="documented-local-manifest", + destination=overrides_path, + expected_sha256=override_sha, + content=override_content, + ) + ) + resources = list(_with_remote_sizes(resources)) + command = ( + "vidxp benchmark didemo " + f"--annotations {_quote_powershell(annotations_path)} " + f"--evaluator {_quote_powershell(evaluator_path)} " + f"--media-directory {_quote_powershell(media_directory)} " + f"--split {split} " + ) + if annotation_indices is not None: + command += ( + "--annotation-indices " + + ",".join(str(index) for index in annotation_indices) + + " " + ) + if overrides_path is not None: + command += ( + f"--media-overrides {_quote_powershell(overrides_path)} " + ) + command += f"--run-id didemo-{split}-run" + return PreparationPlan( + benchmark="didemo", + split=split, + root=destination, + resources=tuple(resources), + selected_count=len(annotations), + selected_video_names=video_names, + command=command, + manifest_path=destination / "preparation-manifest.json", + media_directory=media_directory, + media_overrides_path=overrides_path, + ) + + +def plan_hirest( + *, + root: str | Path, + split: Literal["validation", "test"], + pairs: Sequence[tuple[str, str]] | None, +) -> PreparationPlan: + destination = Path(root).expanduser().resolve() + split_name = "val" if split == "validation" else "test" + ground_truth_sha = ( + HIREST_VALIDATION_SHA256 + if split == "validation" + else HIREST_TEST_SHA256 + ) + ground_truth_url = ( + f"{HIREST_RAW_ROOT}/data/splits/all_data_{split_name}.json" + ) + categories_url = f"{HIREST_RAW_ROOT}/data/evaluation/categories.json" + evaluator_url = f"{HIREST_RAW_ROOT}/evaluate.py" + ground_truth_content = _fetch_verified( + ground_truth_url, + ground_truth_sha, + f"HiREST {split} ground truth", + ) + categories_content = _fetch_verified( + categories_url, + HIREST_CATEGORIES_SHA256, + "HiREST categories", + ) + evaluator_content = _fetch_verified( + evaluator_url, + HIREST_EVALUATOR_SHA256, + "HiREST evaluator", + ) + ground_truth = json.loads(ground_truth_content) + _, ordered_pairs = select_ground_truth(ground_truth, pairs) + video_names = tuple(sorted({video for _, video in ordered_pairs})) + ground_truth_path = ( + destination / "annotations" / f"all_data_{split_name}.json" + ) + categories_path = destination / "evaluator" / "categories.json" + evaluator_path = destination / "evaluator" / "evaluate.py" + asr_archive_path = destination / "ASR.zip" + asr_directory = destination / "asr" + resources = [ + _artifact_resource( + name=f"HiREST {split} ground truth", + url=ground_truth_url, + destination=ground_truth_path, + expected_sha256=ground_truth_sha, + content=ground_truth_content, + ), + _artifact_resource( + name="HiREST categories", + url=categories_url, + destination=categories_path, + expected_sha256=HIREST_CATEGORIES_SHA256, + content=categories_content, + ), + _artifact_resource( + name="HiREST evaluator", + url=evaluator_url, + destination=evaluator_path, + expected_sha256=HIREST_EVALUATOR_SHA256, + content=evaluator_content, + ), + PreparationResource( + name="HiREST released ASR", + url=HIREST_ASR_URL, + destination=asr_archive_path, + size_bytes=0, + kind="artifact", + expected_sha256=HIREST_ASR_SHA256, + existing=_valid_artifact( + asr_archive_path, + expected_sha256=HIREST_ASR_SHA256, + ), + ), + ] + pairs_path = None + if pairs is not None: + pairs_path = destination / "selection" / "pairs.json" + pairs_content = ( + json.dumps( + [ + {"prompt": prompt, "video": video} + for prompt, video in pairs + ], + indent=2, + ensure_ascii=False, + ) + + "\n" + ).encode("utf-8") + pairs_sha = hashlib.sha256(pairs_content).hexdigest() + resources.append( + _artifact_resource( + name="HiREST selected pairs", + url="user-declared-selection", + destination=pairs_path, + expected_sha256=pairs_sha, + content=pairs_content, + ) + ) + resources = list(_with_remote_sizes(resources)) + command = ( + "vidxp benchmark hirest " + f"--ground-truth {_quote_powershell(ground_truth_path)} " + f"--categories {_quote_powershell(categories_path)} " + f"--evaluator {_quote_powershell(evaluator_path)} " + f"--asr-archive {_quote_powershell(asr_archive_path)} " + f"--asr-directory {_quote_powershell(asr_directory)} " + f"--split {split} " + ) + if pairs_path is not None: + command += f"--pairs {_quote_powershell(pairs_path)} " + command += f"--run-id hirest-{split}-run" + transcripts_ready = all( + (asr_directory / f"{Path(video).stem}.srt").is_file() + and (asr_directory / f"{Path(video).stem}.srt").stat().st_size > 0 + for video in video_names + ) + return PreparationPlan( + benchmark="hirest", + split=split, + root=destination, + resources=tuple(resources), + selected_count=len(ordered_pairs), + selected_video_names=video_names, + command=command, + manifest_path=destination / "preparation-manifest.json", + asr_directory=asr_directory, + extraction_reserve_bytes=( + 0 if transcripts_ready else HIREST_ASR_UNCOMPRESSED_SIZE + ), + ) + + +def _download_resource( + resource: PreparationResource, + *, + ffprobe: str, + ffmpeg: str, + progress: ProgressCallback | None, +) -> str: + if resource.existing: + return "reused" + resource.destination.parent.mkdir(parents=True, exist_ok=True) + if resource.content is not None: + temporary = resource.destination.with_name( + resource.destination.name + ".part" + ) + temporary.write_bytes(resource.content) + else: + temporary = resource.destination.with_name( + resource.destination.name + ".part" + ) + start = temporary.stat().st_size if temporary.is_file() else 0 + if start > resource.size_bytes: + temporary.unlink() + start = 0 + if start < resource.size_bytes: + request = _request(resource.url, range_start=start) + try: + with urllib.request.urlopen(request, timeout=60) as response: + status = getattr(response, "status", response.getcode()) + if start and status != 206: + start = 0 + mode = "ab" if start and status == 206 else "wb" + with temporary.open(mode) as stream: + while chunk := response.read(1024 * 1024): + stream.write(chunk) + if progress is not None: + progress(resource.name, len(chunk)) + except (OSError, urllib.error.URLError) as exc: + raise _application_error( + "benchmark_download_failed", + f"Download failed for {resource.name}.", + details={ + "url": resource.url, + "partial_path": str(temporary), + "reason": str(exc), + }, + ) from exc + if temporary.stat().st_size != resource.size_bytes: + raise ValueError( + f"{resource.name} has {temporary.stat().st_size} bytes; " + f"expected {resource.size_bytes}." + ) + if resource.expected_sha256: + observed = sha256_file(temporary) + if observed != resource.expected_sha256: + raise ValueError( + f"{resource.name} SHA-256 was {observed}; " + f"expected {resource.expected_sha256}." + ) + if resource.expected_sha1: + observed = _sha1_file(temporary) + if observed != resource.expected_sha1: + raise ValueError( + f"{resource.name} SHA-1 was {observed}; " + f"expected {resource.expected_sha1}." + ) + if resource.kind == "media": + _validate_media(temporary, ffprobe=ffprobe, ffmpeg=ffmpeg) + os.replace(temporary, resource.destination) + return "downloaded" + + +def _extract_hirest_asr( + archive: Path, + destination: Path, + video_names: Sequence[str], +) -> int: + destination.mkdir(parents=True, exist_ok=True) + expected = {f"{Path(video).stem}.srt" for video in video_names} + extracted = 0 + with zipfile.ZipFile(archive) as bundle: + members: dict[str, zipfile.ZipInfo] = {} + for info in bundle.infolist(): + name = Path(info.filename).name + if name in expected: + if name in members: + raise ValueError( + f"HiREST ASR archive contains duplicate {name}." + ) + members[name] = info + missing = sorted(expected - set(members)) + if missing: + raise ValueError( + "HiREST ASR archive is missing selected transcripts: " + + ", ".join(missing[:10]) + ) + for name, info in members.items(): + target = destination / name + if target.is_file() and target.stat().st_size > 0: + continue + with ( + bundle.open(info) as source, + tempfile.NamedTemporaryFile( + dir=destination, + prefix=name + ".", + suffix=".part", + delete=False, + ) as temporary, + ): + while chunk := source.read(1024 * 1024): + temporary.write(chunk) + temporary_path = Path(temporary.name) + os.replace(temporary_path, target) + extracted += 1 + return extracted + + +def execute_preparation( + plan: PreparationPlan, + *, + ffprobe: str = "ffprobe", + ffmpeg: str = "ffmpeg", + progress: ProgressCallback | None = None, +) -> dict[str, Any]: + plan.root.mkdir(parents=True, exist_ok=True) + outcomes: dict[str, str] = {} + failures: list[dict[str, str]] = [] + for resource in plan.resources: + try: + outcomes[resource.name] = _download_resource( + resource, + ffprobe=ffprobe, + ffmpeg=ffmpeg, + progress=progress, + ) + except Exception as exc: + failures.append( + {"resource": resource.name, "error": str(exc)} + ) + extracted = 0 + if not failures and plan.benchmark == "hirest": + archive = next( + resource.destination + for resource in plan.resources + if resource.expected_sha256 == HIREST_ASR_SHA256 + ) + try: + extracted = _extract_hirest_asr( + archive, + plan.asr_directory or plan.root / "asr", + plan.selected_video_names, + ) + except Exception as exc: + failures.append({"resource": "HiREST ASR extraction", "error": str(exc)}) + manifest = { + "schema_version": 1, + "status": "failed" if failures else "ready", + "created_at": utc_now(), + "benchmark": plan.benchmark, + "split": plan.split, + "root": str(plan.root), + "selected_count": plan.selected_count, + "selected_video_count": len(plan.selected_video_names), + "planned_max_additional_bytes": plan.additional_bytes, + "resources": [resource.public_record() for resource in plan.resources], + "outcomes": outcomes, + "failures": failures, + "asr_transcripts_extracted": extracted, + "command": plan.command, + } + write_json_atomic(plan.manifest_path, manifest) + if failures: + raise _application_error( + "benchmark_preparation_failed", + "Benchmark preparation did not complete. Partial downloads were " + "kept for a resumable retry.", + details={ + "manifest": str(plan.manifest_path), + "failures": failures, + }, + ) + return manifest diff --git a/tests/test_benchmark_prepare.py b/tests/test_benchmark_prepare.py new file mode 100644 index 0000000..134e67a --- /dev/null +++ b/tests/test_benchmark_prepare.py @@ -0,0 +1,385 @@ +import hashlib +import json +import unittest +import zipfile +from dataclasses import replace +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import Mock, patch + +from typer.testing import CliRunner + +from vidxp import cli +from vidxp.benchmarks.prepare import ( + DIDEMO_KNOWN_REPLACEMENT_VIDEO, + DIDEMO_KNOWN_REPLACEMENT_URL, + PreparationPlan, + PreparationResource, + _didemo_aws_url, + _extract_hirest_asr, + execute_preparation, + plan_didemo, + plan_hirest, +) +from vidxp.composition import LocalApplicationContext +from vidxp.repositories import RepositoryConfig, RepositoryRegistry + + +def _annotation(video: str, annotation_id: int = 1) -> dict: + return { + "annotation_id": annotation_id, + "description": "a visible action", + "num_segments": 6, + "times": [[0, 0]], + "video": video, + } + + +def _resolve_sizes(resources): + return tuple( + replace(resource, size_bytes=resource.size_bytes or 123) + for resource in resources + ) + + +class BenchmarkPreparationTests(unittest.TestCase): + def test_didemo_aws_url_uses_official_yfcc_mapping(self): + url = _didemo_aws_url( + "owner_4253489686_source.m4v", + {"4253489686": "abcdef012345"}, + ) + + self.assertEqual( + url, + "https://multimedia-commons.s3-us-west-2.amazonaws.com/" + "data/videos/mp4/abc/def/abcdef012345.mp4", + ) + + def test_didemo_plan_is_subset_aware_and_copy_paste_ready(self): + first = "owner_4253489686_source.m4v" + second = "owner_7071386095_source.mpg" + annotations = json.dumps( + [_annotation(first), _annotation(second, 2)] + ).encode() + + def fetched(url, _checksum, _name): + if url.endswith("test_data.json"): + return annotations + if url.endswith("yfcc100m_hash.txt"): + return ( + b"4253489686\tabcdef012345\n" + b"7071386095\t123456abcdef\n" + ) + return b"evaluator" + + with ( + TemporaryDirectory() as directory, + patch( + "vidxp.benchmarks.prepare.inspect_media_runtime", + return_value=Mock(ready=True, errors=()), + ), + patch( + "vidxp.benchmarks.prepare._fetch_verified", + side_effect=fetched, + ), + patch( + "vidxp.benchmarks.prepare._with_remote_sizes", + side_effect=_resolve_sizes, + ), + patch( + "vidxp.benchmarks.prepare._valid_media", + return_value=False, + ), + patch( + "vidxp.benchmarks.prepare._valid_artifact", + return_value=False, + ), + ): + plan = plan_didemo( + root=directory, + split="test", + annotation_indices=[1], + ffprobe="ffprobe", + ffmpeg="ffmpeg", + ) + + self.assertEqual(plan.selected_count, 1) + self.assertEqual(plan.selected_video_names, (second,)) + media = [ + resource + for resource in plan.resources + if resource.kind == "media" + ] + self.assertEqual(len(media), 1) + self.assertIn("123/456/123456abcdef.mp4", media[0].url) + self.assertIn("--annotation-indices 1", plan.command) + self.assertIn("--media-directory", plan.command) + + def test_known_didemo_replacement_is_explicit_and_manifested(self): + annotations = json.dumps( + [_annotation(DIDEMO_KNOWN_REPLACEMENT_VIDEO)] + ).encode() + + def fetched(url, _checksum, _name): + if url.endswith("test_data.json"): + return annotations + if url.endswith("yfcc100m_hash.txt"): + return b"13482799053\tdeb3d8c8aba7077b378d16b236b0a5\n" + return b"evaluator" + + with ( + TemporaryDirectory() as directory, + patch( + "vidxp.benchmarks.prepare.inspect_media_runtime", + return_value=Mock(ready=True, errors=()), + ), + patch( + "vidxp.benchmarks.prepare._fetch_verified", + side_effect=fetched, + ), + patch( + "vidxp.benchmarks.prepare._with_remote_sizes", + side_effect=_resolve_sizes, + ), + patch( + "vidxp.benchmarks.prepare._valid_media", + return_value=False, + ), + patch( + "vidxp.benchmarks.prepare._valid_artifact", + return_value=False, + ), + ): + plan = plan_didemo( + root=directory, + split="test", + annotation_indices=[0], + ffprobe="ffprobe", + ffmpeg="ffmpeg", + ) + + replacement = next( + resource + for resource in plan.resources + if resource.replacement_for is not None + ) + self.assertEqual(replacement.url, DIDEMO_KNOWN_REPLACEMENT_URL) + self.assertEqual( + replacement.replacement_for, + DIDEMO_KNOWN_REPLACEMENT_VIDEO, + ) + self.assertIsNotNone(plan.media_overrides_path) + self.assertIn("--media-overrides", plan.command) + + def test_hirest_plan_prepares_released_asr_without_video_downloads(self): + ground_truth = { + "Make tea": { + "tea.mp4": { + "clip": True, + "bounds": [1, 2], + "v_duration": 3, + }, + "other.mp4": { + "clip": True, + "bounds": [0, 1], + "v_duration": 2, + }, + } + } + + def fetched(url, _checksum, _name): + if "all_data_val.json" in url: + return json.dumps(ground_truth).encode() + if url.endswith("categories.json"): + return b"{}" + return b"evaluator" + + with ( + TemporaryDirectory() as directory, + patch( + "vidxp.benchmarks.prepare._fetch_verified", + side_effect=fetched, + ), + patch( + "vidxp.benchmarks.prepare._with_remote_sizes", + side_effect=_resolve_sizes, + ), + patch( + "vidxp.benchmarks.prepare._valid_artifact", + return_value=False, + ), + ): + plan = plan_hirest( + root=directory, + split="validation", + pairs=[("Make tea", "tea.mp4")], + ) + + self.assertEqual(plan.selected_count, 1) + self.assertEqual(plan.selected_video_names, ("tea.mp4",)) + self.assertFalse( + any(resource.kind == "media" for resource in plan.resources) + ) + self.assertGreater(plan.additional_bytes, 17_000_000) + self.assertIn("--asr-archive", plan.command) + self.assertIn("--asr-directory", plan.command) + self.assertIn("--pairs", plan.command) + + def test_hirest_extraction_keeps_only_selected_transcripts(self): + with TemporaryDirectory() as directory: + root = Path(directory) + archive = root / "ASR.zip" + with zipfile.ZipFile(archive, "w") as bundle: + bundle.writestr("ASR/first.srt", "first") + bundle.writestr("ASR/second.srt", "second") + destination = root / "asr" + + extracted = _extract_hirest_asr( + archive, + destination, + ["first.mp4"], + ) + + self.assertEqual(extracted, 1) + self.assertEqual( + (destination / "first.srt").read_text(), + "first", + ) + self.assertFalse((destination / "second.srt").exists()) + + def test_execution_writes_verified_artifacts_and_manifest(self): + with TemporaryDirectory() as directory: + root = Path(directory) + content = b"official" + destination = root / "annotations.json" + plan = PreparationPlan( + benchmark="didemo", + split="test", + root=root, + resources=( + PreparationResource( + name="annotations", + url="https://example.invalid/annotations", + destination=destination, + size_bytes=len(content), + kind="artifact", + content=content, + expected_sha256=hashlib.sha256( + content + ).hexdigest(), + ), + ), + selected_count=1, + selected_video_names=("video.mp4",), + command="vidxp benchmark didemo ...", + manifest_path=root / "preparation-manifest.json", + ) + + result = execute_preparation(plan) + + self.assertEqual(result["status"], "ready") + self.assertEqual(destination.read_bytes(), content) + self.assertTrue(plan.manifest_path.is_file()) + + def test_complete_partial_download_is_verified_without_network_retry(self): + with TemporaryDirectory() as directory: + root = Path(directory) + content = b"resume" + destination = root / "ASR.zip" + partial = destination.with_name(destination.name + ".part") + partial.write_bytes(content) + plan = PreparationPlan( + benchmark="didemo", + split="test", + root=root, + resources=( + PreparationResource( + name="archive", + url="https://example.invalid/ASR.zip", + destination=destination, + size_bytes=len(content), + kind="artifact", + expected_sha256=hashlib.sha256( + content + ).hexdigest(), + ), + ), + selected_count=1, + selected_video_names=(), + command="vidxp benchmark didemo ...", + manifest_path=root / "preparation-manifest.json", + ) + + with patch( + "vidxp.benchmarks.prepare.urllib.request.urlopen" + ) as urlopen: + result = execute_preparation(plan) + + urlopen.assert_not_called() + self.assertEqual(result["status"], "ready") + self.assertEqual(destination.read_bytes(), content) + + +class BenchmarkPreparationCliTests(unittest.TestCase): + def test_prepare_command_prints_generated_run_command(self): + runner = CliRunner() + service = Mock() + with TemporaryDirectory() as directory: + root = Path(directory) + config = root / "repositories.json" + plan = PreparationPlan( + benchmark="didemo", + split="test", + root=root / "prepared", + resources=(), + selected_count=1, + selected_video_names=("video.mp4",), + command="vidxp benchmark didemo --run-id ready", + manifest_path=root / "prepared" / "manifest.json", + ) + with ( + patch.object( + cli, + "create_local_application", + return_value=LocalApplicationContext( + application=service, + jobs=Mock(), + repositories=RepositoryRegistry(config), + repository=RepositoryConfig( + "default", + root / "repository", + device="cpu", + configured=False, + ), + ), + ), + patch( + "vidxp.benchmarks.cli.plan_didemo", + return_value=plan, + ), + patch( + "vidxp.benchmarks.cli.execute_preparation", + return_value={"status": "ready"}, + ), + ): + response = runner.invoke( + cli.app, + [ + "--config", + str(config), + "benchmark", + "prepare", + "didemo", + "--annotation-indices", + "0", + "--yes", + ], + ) + + self.assertEqual(response.exit_code, 0, response.output) + self.assertIn("Benchmark inputs are ready.", response.output) + self.assertIn(plan.command, response.output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_benchmarks.py b/tests/test_benchmarks.py index e603a9a..42303db 100644 --- a/tests/test_benchmarks.py +++ b/tests/test_benchmarks.py @@ -120,6 +120,23 @@ def test_artifact_checksum_is_verified(self): revision="abc123", ) + def test_artifact_accepts_declared_line_ending_variants(self): + with TemporaryDirectory() as directory: + path = Path(directory) / "evaluator.py" + path.write_bytes(b"print('official')\r\n") + raw_sha = hashlib.sha256(b"print('official')\n").hexdigest() + checkout_sha = hashlib.sha256(path.read_bytes()).hexdigest() + + artifact = verify_artifact( + path, + name="test evaluator", + expected_sha256=(raw_sha, checkout_sha), + source="https://example.invalid/evaluator.py", + revision="abc123", + ) + + self.assertEqual(artifact["sha256"], checkout_sha) + class DiDeMoAdapterTests(unittest.TestCase): def test_media_override_is_disclosed_in_result_classification(self): From 182c27c931736168a7b3695ab5ab4103e963bd0e Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 30 Jul 2026 18:01:56 +0500 Subject: [PATCH 39/46] feat: ship managed desktop runtime and release artifacts --- .github/workflows/desktop.yml | 151 ++++++ .github/workflows/release-to-pypi.yml | 19 + .github/workflows/release-to-test-pypi.yml | 38 ++ INSTALLATION_GUIDE.md | 30 +- README.md | 431 ++++++---------- changes/+product-interfaces.feature.md | 2 +- changes/+release-publication.feature.md | 1 + desktop/package.json | 2 +- desktop/runtime-constraints.txt | 499 ++++++++++++++++++ desktop/runtime-manifest.json | 14 +- desktop/scripts/build-desktop.mjs | 32 ++ desktop/src-tauri/Cargo.lock | 92 ++++ desktop/src-tauri/Cargo.toml | 7 +- desktop/src-tauri/icons/icon.icns | Bin 1373347 -> 1373347 bytes desktop/src-tauri/nsis-hooks.nsh | 5 + desktop/src-tauri/src/lib.rs | 572 ++++++++++++++++++--- desktop/src-tauri/src/main.rs | 2 + desktop/src-tauri/tauri.conf.json | 4 +- desktop/web/app.js | 153 ++++-- desktop/web/index.html | 41 +- desktop/web/styles.css | 36 +- docs/architecture/platform.md | 22 +- docs/desktop.md | 107 ++-- docs/releasing.md | 25 +- tests/test_packaging.py | 36 +- 25 files changed, 1823 insertions(+), 498 deletions(-) create mode 100644 .github/workflows/desktop.yml create mode 100644 desktop/runtime-constraints.txt create mode 100644 desktop/scripts/build-desktop.mjs create mode 100644 desktop/src-tauri/nsis-hooks.nsh diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml new file mode 100644 index 0000000..57c199d --- /dev/null +++ b/.github/workflows/desktop.yml @@ -0,0 +1,151 @@ +name: Desktop + +on: + workflow_call: + inputs: + checkout_ref: + description: Commit to build + required: true + type: string + workflow_dispatch: + inputs: + checkout_ref: + description: Commit, branch, or tag to build + required: false + default: "" + type: string + pull_request: + paths: + - "desktop/**" + - "src/vidxp/requirements/**" + - "src/vidxp/capabilities/*/requirements.txt" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/desktop.yml" + push: + branches: + - main + paths: + - "desktop/**" + - "src/vidxp/requirements/**" + - "src/vidxp/capabilities/*/requirements.txt" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/desktop.yml" + +permissions: + contents: read + +jobs: + build: + name: ${{ matrix.label }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - label: Windows x86-64 NSIS + runner: windows-2025 + sidecar_script: sidecar:windows + bundle_glob: desktop/src-tauri/target/release/bundle/nsis/*-setup.exe + artifact: vidxp-desktop-windows-x86_64 + - label: macOS Apple Silicon DMG + runner: macos-15 + sidecar_script: sidecar:unix + bundle_glob: desktop/src-tauri/target/release/bundle/dmg/*.dmg + artifact: vidxp-desktop-macos-aarch64 + - label: Linux x86-64 AppImage + runner: ubuntu-24.04 + sidecar_script: sidecar:unix + bundle_glob: desktop/src-tauri/target/release/bundle/appimage/*.AppImage + artifact: vidxp-desktop-linux-x86_64 + + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.checkout_ref || github.event.inputs.checkout_ref || github.sha }} + + - name: Install Linux desktop build dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential \ + file \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libssl-dev \ + libwebkit2gtk-4.1-dev \ + libxdo-dev + + - uses: actions/setup-node@v7 + with: + node-version: "22" + cache: npm + cache-dependency-path: desktop/package-lock.json + + - uses: astral-sh/setup-uv@v7 + with: + version: "0.12.0" + enable-cache: false + + - uses: dtolnay/rust-toolchain@1.85.0 + + - name: Install locked desktop tooling + run: npm ci + working-directory: desktop + + - name: Verify the desktop runtime constraints + shell: bash + run: | + generated="$RUNNER_TEMP/runtime-constraints.txt" + uv export \ + --frozen \ + --extra local-worker \ + --extra frontend \ + --no-dev \ + --no-emit-project \ + --no-hashes \ + --format requirements-txt \ + --output-file "$generated" + git diff --no-index --ignore-matching-lines='^#' -- \ + desktop/runtime-constraints.txt "$generated" + + - name: Fetch the pinned uv sidecar + run: npm run ${{ matrix.sidecar_script }} + working-directory: desktop + + - name: Test the locked desktop crate + run: cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml + + - name: Build the unsigned desktop installer + run: npm run desktop:build + working-directory: desktop + + - name: Verify the Windows app uses the GUI subsystem + if: runner.os == 'Windows' + shell: pwsh + run: | + $path = "desktop/src-tauri/target/release/vidxp-desktop.exe" + $stream = [System.IO.File]::OpenRead($path) + try { + $reader = [System.IO.BinaryReader]::new($stream) + $stream.Position = 0x3c + $peOffset = $reader.ReadInt32() + $stream.Position = $peOffset + 4 + 20 + 68 + $subsystem = $reader.ReadUInt16() + if ($subsystem -ne 2) { + throw "Expected Windows GUI subsystem 2, found $subsystem." + } + } finally { + $stream.Dispose() + } + + - name: Preserve the desktop installer + uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.artifact }} + path: ${{ matrix.bundle_glob }} + if-no-files-found: error + compression-level: 0 + retention-days: 14 diff --git a/.github/workflows/release-to-pypi.yml b/.github/workflows/release-to-pypi.yml index ae896a3..7c9575d 100644 --- a/.github/workflows/release-to-pypi.yml +++ b/.github/workflows/release-to-pypi.yml @@ -233,6 +233,15 @@ jobs: fi done + build-desktop: + if: needs.release.outputs.released == 'true' + needs: + - release + - validate-release + uses: ./.github/workflows/desktop.yml + with: + checkout_ref: ${{ needs.release.outputs.commit_sha }} + publish-github: if: needs.release.outputs.released == 'true' needs: @@ -240,6 +249,7 @@ jobs: - validate-release - publish-pypi - publish-container + - build-desktop runs-on: ubuntu-latest permissions: contents: write @@ -265,6 +275,12 @@ jobs: name: github-release-notes path: . + - uses: actions/download-artifact@v8 + with: + pattern: vidxp-desktop-* + path: desktop-dist/ + merge-multiple: true + - name: Publish GitHub release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -272,3 +288,6 @@ jobs: semantic-release publish --tag "${{ needs.release.outputs.tag }}" gh release edit "${{ needs.release.outputs.tag }}" \ --notes-file .release-notes.md + gh release upload "${{ needs.release.outputs.tag }}" \ + desktop-dist/* \ + --clobber diff --git a/.github/workflows/release-to-test-pypi.yml b/.github/workflows/release-to-test-pypi.yml index 20180da..803d784 100644 --- a/.github/workflows/release-to-test-pypi.yml +++ b/.github/workflows/release-to-test-pypi.yml @@ -13,6 +13,9 @@ on: - "LICENSE" - "MANIFEST.in" - "utils/**" + - "desktop/**" + - "uv.lock" + - ".github/workflows/desktop.yml" - ".github/workflows/release-to-test-pypi.yml" permissions: @@ -35,7 +38,9 @@ jobs: permissions: contents: write outputs: + commit_sha: ${{ steps.release.outputs.commit_sha }} released: ${{ steps.release.outputs.released }} + tag: ${{ steps.release.outputs.tag }} steps: - uses: actions/checkout@v7 @@ -105,3 +110,36 @@ jobs: with: repository-url: https://test.pypi.org/legacy/ packages-dir: dist/ + + build-desktop: + if: needs.prerelease.outputs.released == 'true' + needs: + - prerelease + - publish-testpypi + uses: ./.github/workflows/desktop.yml + with: + checkout_ref: ${{ needs.prerelease.outputs.commit_sha }} + + publish-desktop: + if: needs.prerelease.outputs.released == 'true' + needs: + - prerelease + - build-desktop + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/download-artifact@v8 + with: + pattern: vidxp-desktop-* + path: desktop-dist/ + merge-multiple: true + + - name: Attach desktop installers to the prerelease + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ needs.prerelease.outputs.tag }}" \ + desktop-dist/* \ + --clobber diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md index 166ece8..38bbde2 100644 --- a/INSTALLATION_GUIDE.md +++ b/INSTALLATION_GUIDE.md @@ -12,7 +12,7 @@ shape needs. | Native browser UI | `vidxp[local-worker,frontend]` | CLI, local worker, Streamlit | | Local agent integration | `vidxp[local-worker,mcp]` | Local worker and stdio MCP | | Local application server | `vidxp[local-worker,server]` | Loopback HTTP API, remote MCP, local worker | -| Desktop preview | Build the Tauri app | Guided app-owned Python/worker/UI runtime | +| Desktop app | Install the native package | Guided app-owned Python and worker runtime with an optional browser interface | | Public/self-hosted service | `compose.coolify.yaml` | API/MCP control plane, CPU worker, PostgreSQL, Chroma, tusd | | Embed one capability | `dialogue`, `scene`, or `actor` extra | Python indexing/retrieval code | @@ -342,22 +342,32 @@ Follow [Coolify deployment](docs/deployment/coolify.md) for: - persistent volumes and backups; and - the optional, explicitly prepared Ollama profile. -## Desktop preview +## Desktop application -The Tauri desktop shell performs setup in this order: +The operating-system package installs the VidXP application itself. On first +launch, the application configures local processing in this order: ```text FFmpeg preflight → explicit package-manager consent when missing -→ app-owned Python and VidXP installation +→ app-owned Python and VidXP runtime provisioning → optional model preparation → full doctor → atomic runtime activation ``` -The NSIS/DMG/AppImage packages do not install FFmpeg themselves. Desktop -packaging is currently a source-built preview; signed installer publication is -still under release validation. Build instructions are in +Capability code is selected independently from the optional browser interface. +Model downloads can be deferred, and the model cache can use a directory chosen +through the operating system's folder picker. Later launches open the browser +interface directly when it was selected. After configuration the Tauri +supervisor stays in the system tray instead of keeping a window open. Closing +the window hides it; **Quit VidXP** from the tray performs the complete +interface and worker shutdown. + +The NSIS/DMG/AppImage packages do not install FFmpeg themselves. The application +uses a native confirmation dialog before running a supported package manager. +Windows SmartScreen and macOS Gatekeeper may require explicit confirmation +until signing is added. Build instructions are in [Desktop application](docs/desktop.md). ## Install from source @@ -400,6 +410,12 @@ VidXP/ models/ ``` +The desktop application uses this same root for repositories and its default +model cache. Its managed Python environments and active-runtime pointer remain +in the identifier-scoped private application-data directory. On Windows, +per-user program files are installed separately under +`%LOCALAPPDATA%\Programs\VidXP`. + Use an alternate root for one CLI invocation: ```bash diff --git a/README.md b/README.md index 1de2bcd..e48e8b2 100644 --- a/README.md +++ b/README.md @@ -11,308 +11,181 @@

    - A local-first video indexing and search engine for people, applications, and AI agents. + A local-first video search engine for people, applications, and AI agents.

    - CLI · Browser UI · Desktop preview · Python API · HTTP API · MCP · Docker + Dialogue search · Scene search · Actor grouping

    PyPI version - Supported Python versions + GHCR container CI status MIT license Discord

    -## Why VidXP +## Find the moment, not the timestamp -Finding one moment should not require scrubbing through an entire video. -VidXP builds a persistent index from three kinds of evidence: +VidXP turns one video or a whole collection into a persistent, searchable +library. Search by meaning instead of filenames, tags, or exact words: -- **Dialogue:** transcribes speech and searches timestamped phrases by meaning, - not only exact words. -- **Scenes:** samples at a source-aware cadence and finds visually similar - moments from descriptions such as “a red car at night.” -- **Actors:** detects faces, groups recurring appearances within each video, - and can render a highlighted overlay for a selected cluster. +- **Dialogue:** find what was said from timestamped transcripts. +- **Scenes:** describe what appeared on screen and retrieve matching moments. +- **Actors:** group recurring faces within a video and export a highlighted + overlay for a selected group. -Index one video or a whole collection. Search across every indexed video for -the best matches, or provide a `media_id` to stay within one video. +Use it to find relatives across years of wedding videos, add semantic video +search to an editing application, or give an AI agent a grounded way to +understand a video library. -Use the same repository in the form that fits the job: - -- People can use the CLI, browser interface, or desktop preview. -- Applications can embed the Python layer or call the versioned HTTP API. -- Agents can connect through local stdio MCP or authenticated remote MCP. -- Deployments can use the all-in-one local image or separated control and - CPU-worker images. - -VidXP also provides durable jobs, atomic multi-video snapshots, named -repositories, grounded answers with timestamped evidence, and downloadable -video clips or actor overlays. +Processing and search run locally after the selected models have been +downloaded. [![VidXP browser interface](./docs/images/video-screenshot.jpeg)](https://www.linkedin.com/feed/update/urn:li:activity:7343569473720725505/) -## Quick start - -Install [uv 0.12 or newer](https://docs.astral.sh/uv/getting-started/installation/), -then let uv create and manage VidXP's isolated tool environment: - -```bash -uv --version -uv tool install --python 3.14 --torch-backend cpu "vidxp[local-worker,frontend]" -``` - -No virtual environment needs to be created or activated manually. If the -installed command is not immediately found, run `uv tool update-shell` once -and reopen the terminal. - -```bash -vidxp init -vidxp prepare -vidxp doctor -vidxp ui -``` - -- `init` checks FFmpeg and offers a confirmed package-manager command when it - is missing. -- `prepare` discloses the missing models, download sizes, cache location, and - maximum additional space before asking. -- `doctor` is read-only and downloads nothing. -- `ui` opens the browser interface at `http://localhost:8501`. - -Media, indexes, jobs, artifacts, and models live in the operating system's -per-user VidXP data directory—not the directory where the command was run. - -See the [installation guide](INSTALLATION_GUIDE.md) for CLI-only, MCP, API, -Docker, desktop, embedding, and server deployment profiles. - -### Docker instead - -The local image contains the CPU worker, browser UI, and FFmpeg, but no model -weights. - -```bash -git clone https://github.com/grayhatdevelopers/vidxp.git -cd vidxp -docker compose pull -docker compose run --rm vidxp vidxp prepare -docker compose up -``` - -The UI is available at `http://localhost:8501`. The `vidxp-data` volume keeps -media, indexes, jobs, and models across container replacement. - -## Installation profiles - -Install only the surfaces you intend to run together. +## Install and run VidXP -| Goal | Package profile | What it adds | Start | -|---|---|---|---| -| CLI indexing | `vidxp[local-worker]` | Dialogue, scene, actor, storage, local worker | `vidxp --help` | -| Browser app | `vidxp[local-worker,frontend]` | Complete local worker + Streamlit | `vidxp ui` | -| Local agent tools | `vidxp[local-worker,mcp]` | Complete local worker + stdio MCP | `vidxp-mcp` | -| Local application server | `vidxp[local-worker,server]` | Complete local worker + HTTP/remote MCP | `vidxp-api` | -| UI and server | `vidxp[local-worker,frontend,server]` | Browser, API, and both MCP transports | `vidxp ui` or `vidxp-api` | +### 1. Native CLI and MCP -Python package installation never changes operating-system packages. Run -`vidxp init` explicitly for guided FFmpeg setup. The -[installation guide](INSTALLATION_GUIDE.md#optional-dependency-extras) -documents individual capability, storage, server-worker, benchmark, and test -extras. - -## Index and search from the CLI - -After initialization and model preparation: +For scripts, local agent tools, and direct control over indexing and search, +install [uv](https://docs.astral.sh/uv/getting-started/installation/), then: ```bash -vidxp media import samplevideo.mp4 --json -vidxp index create -vidxp search dialogue "the bread just came out of the oven" -vidxp search scene "a yellow taxi on a city street" --top-k 5 -vidxp query "What happens after the taxi arrives?" -``` +# Install isolated CPU runtime +uv tool install --python 3.14 --torch-backend cpu "vidxp[local-worker,mcp]" -The import command copies and validates the video in managed storage and -returns a stable `media_id`. +# Check required system tools +vidxp init -- Omit `--media-id` to rank evidence across every video in the active snapshot. -- Add `--media-id ` to restrict `search` or `query` to one video. -- Run `vidxp media list` to rediscover filenames and IDs. -- Run `vidxp index list` to see which registered videos are searchable. -- Re-indexing one video replaces only that video’s generation. -- Use separate named repositories when collections must not be searched - together. +# Review and download models +vidxp prepare -Index selected capabilities or change scene detail: +# Verify the installation +vidxp doctor -```bash -vidxp index create \ - --modality scene \ - --scene-sample-fps 1 +# Print MCP client config +vidxp mcp-config ``` -Scene sampling is time-based and normalized to the source frame rate. -Lower-frame-rate videos use every available frame rather than duplicating -frames. +The CLI works without MCP. To include the local browser app as well, install +`vidxp[local-worker,mcp,frontend]` and run `vidxp ui`. -### Jobs and downloadable clips +If `vidxp` is not immediately available after installation, run +`uv tool update-shell` once and reopen the terminal. -Long-running commands wait by default. Add `--detach`, then inspect the durable -job: - -```bash -vidxp jobs list -vidxp jobs show -vidxp jobs cancel -vidxp jobs retry -``` +### 2. Desktop app -Create and export a result clip: +For a managed local installation, download VidXP from +[GitHub Releases](https://github.com/grayhatdevelopers/vidxp/releases). The +desktop app owns its Python environment, CPU dependencies, model preparation, +and local worker; the user does not need to install Python or uv. -```bash -vidxp artifacts snippet 30 45 --json -vidxp artifacts download ./clip.mp4 -``` +Installers for Windows x86-64, Apple Silicon macOS, and Linux x86-64 are +published with each release. On first launch, VidXP checks the machine and +configures only the capabilities and interfaces selected by the user. The +browser interface and its Python dependencies can be omitted for a +processing-only runtime. -Run `vidxp --help` or `vidxp --help` for the complete command -reference. +After configuration, the desktop supervisor runs in the system tray. +**Open VidXP** opens or reuses the local browser interface, and **Quit VidXP** +stops its owned processes. See [Desktop application](docs/desktop.md) for +behavior and source builds. -## MCP for local agents +### 3. Docker for servers -Install the local worker and MCP extra in the same environment, prepare the -models, and print client-ready configuration: +Stable releases publish three Linux/amd64 images to the +[VidXP GitHub Container Registry](https://github.com/grayhatdevelopers/vidxp/pkgs/container/vidxp): -```bash -uv tool install --python 3.14 --torch-backend cpu "vidxp[local-worker,mcp]" -vidxp init -vidxp prepare -vidxp mcp-config -``` +| Image tag | Purpose | +|---|---| +| `ghcr.io/grayhatdevelopers/vidxp:` | All-in-one CPU worker and browser app | +| `ghcr.io/grayhatdevelopers/vidxp:-control` | HTTP API, remote MCP, migrations, and upload hooks | +| `ghcr.io/grayhatdevelopers/vidxp:-worker` | CPU model worker for the server deployment | -`vidxp mcp-config` prints a complete `mcpServers` JSON object containing the -absolute `vidxp-mcp` executable path. Copy the object into any stdio MCP client. +The supported [Coolify deployment](docs/deployment/coolify.md) uses the +published `control` and `worker` images with `compose.coolify.yaml`; it does not +build VidXP from a repository checkout. The deployment includes PostgreSQL, +Chroma, resumable uploads, persistent media and model volumes, and static +bearer or OIDC authentication. -Verify the handshake, tool discovery, repository paths, and a read-only status -call: +Use immutable release tags or image digests for a server. The all-in-one image +is available when one machine only needs the local browser product: ```bash -vidxp-mcp --check --repository default +docker run --rm --init \ + -p 8501:8501 \ + -v vidxp-data:/var/lib/vidxp \ + ghcr.io/grayhatdevelopers/vidxp: ``` -Agents can discover media, index videos, search across the active snapshot, -ask grounded questions, create clips, poll jobs, and retrieve artifact -download links. MCP does not carry video bytes or expose server filesystem -paths. +## Use VidXP your way -## HTTP API - -For a local API backed by the same local worker: +| Surface | What it is for | +|---|---| +| Browser app | Import videos, prepare models, index, search, inspect progress, and download results | +| Desktop app | A managed local runtime, optional browser interface, and worker | +| CLI | Scriptable media, indexing, search, job, repository, and artifact workflows | +| Python API | Embed selected indexing and retrieval capabilities in another application | +| HTTP API | Build applications on a versioned service contract | +| MCP | Let local or remote agents discover media, index it, search it, and create clips | +| Containers | Run the local browser product or a separated API/worker deployment | + +All of these surfaces use the same application layer and repository contracts; +they are not separate implementations. + +## What is available now + +- Persistent libraries containing one video or many. +- Semantic dialogue search with timestamped evidence. +- Text-to-scene retrieval across indexed videos. +- Within-video face grouping and highlighted actor overlays. +- Cross-video top-k search with optional single-video filtering. +- Durable jobs with progress, cancellation, recovery, and retained results. +- Atomic index snapshots, so a failed rebuild does not replace a working one. +- Managed media, downloadable clips, overlays, and artifact metadata. +- Named repositories for keeping collections separate. +- Browser, CLI, Python, HTTP, MCP, desktop, and container interfaces. +- Local CPU execution and a separated self-hosted server topology. + +## A first search + +The browser app provides the guided workflow. The equivalent CLI flow is: ```bash -uv tool install --python 3.14 --torch-backend cpu "vidxp[local-worker,server]" -vidxp init -vidxp prepare -vidxp-api +vidxp media import samplevideo.mp4 --json +vidxp index create +vidxp search scene "a yellow taxi on a city street" +vidxp search dialogue "the bread just came out of the oven" ``` -The default server binds to `127.0.0.1:8000`. - -| Reference | URL | -|---|---| -| Interactive OpenAPI | `http://127.0.0.1:8000/docs` | -| OpenAPI document | `http://127.0.0.1:8000/openapi.json` | -| Health | `http://127.0.0.1:8000/health` | -| Readiness | `http://127.0.0.1:8000/ready` | -| Streamable HTTP MCP | `http://127.0.0.1:8000/mcp` | - -The API exposes media ingestion and delivery, capability discovery, durable -jobs, index state, search/query jobs, and artifacts under `/api/v1`. Remote -uploads larger than the multipart limit use the server deployment’s resumable -tus endpoint. - -Non-loopback deployments must use static bearer or OIDC authentication. Use -the supported [Coolify/Compose topology](docs/deployment/coolify.md) instead of -turning a local process into an ad-hoc public server. - -## Containers and server deployment - -Stable releases publish three Linux/amd64 images from the same release commit: - -| Image | Tag | Contains | Intended use | -|---|---|---|---| -| Local | `ghcr.io/grayhatdevelopers/vidxp:` and `latest` | CPU local worker, Streamlit, FFmpeg | One-machine browser app with `compose.yaml` | -| Control | `ghcr.io/grayhatdevelopers/vidxp:-control` | FastAPI, remote MCP, migrations, hooks | Public API/MCP control plane | -| Worker | `ghcr.io/grayhatdevelopers/vidxp:-worker` | CPU providers and HTTP Chroma client | Model-backed server jobs | +Searches return ranked moments with video identity, timestamps, scores, and +capability-specific evidence. Use `--media-id` to stay within one video or omit +it to search the active collection. -`compose.coolify.yaml` combines the immutable control and worker images with -pinned PostgreSQL, Chroma, tusd, and an optional Ollama service. It is the -supported server topology for Coolify and ordinary Docker Compose: +Run `vidxp --help` or `vidxp --help` for the complete CLI reference. -- one API/MCP service; -- one CPU worker; -- resumable remote uploads; -- persistent database, vector, media, and model volumes; and -- static bearer or OIDC authentication. +## For applications and agents -Follow the [Coolify deployment guide](docs/deployment/coolify.md) for required -secrets, hostnames, health checks, proxy headers, and backup boundaries. +Applications can embed individual capabilities through the Python package or +run VidXP as a local or authenticated remote service. Agents can connect over +stdio MCP on the same machine or Streamable HTTP MCP on a server. -## Desktop application +The MCP surface can register or discover media, create indexes, search dialogue, +scenes, and actors, ask grounded questions, create clips and overlays, poll +durable jobs, and retrieve artifact download links. -The Tauri desktop application provides a guided first-run installer and then -launches the same local browser interface: +- [Python installation and capability extras](INSTALLATION_GUIDE.md#optional-dependency-extras) +- [HTTP and MCP installation profiles](INSTALLATION_GUIDE.md) +- [OpenAPI and MCP transport behavior](docs/architecture/platform.md) +- [Coolify and server deployment](docs/deployment/coolify.md) -- checks FFmpeg before downloading Python or VidXP; -- asks before running a supported system package manager; -- installs an app-owned Python/runtime profile; -- optionally prepares selected models; and -- activates the runtime only after `vidxp doctor` passes. +## Models and local data -Desktop packaging is currently a preview built from source; signed installer -publication is still under release validation. See -[Desktop application](docs/desktop.md). - -## Python API - -VidXP can also be embedded as a Python indexing/retrieval layer. Install only -the capability extras your application uses inside that application's own -Python environment: - -```bash -uv pip install --torch-backend cpu "vidxp[scene]" -``` - -```python -from vidxp.core import IndexConfig, VideoSource -from vidxp.core.runner import run_index -from vidxp.capabilities.scene.operations import search_scene - -config = IndexConfig( - dataset="my-library", - split="local", - run_id="demo", - enabled_modalities=("scene",), - capability_options={"scene": {"sample_fps": 1.0}}, -) - -run_index( - [VideoSource(video_id="video-1", path="videos/first.mp4")], - config, -) - -for hit in search_scene("a person enters a taxi", config=config, top_k=5).hits: - print(hit.video_id, hit.start, hit.end, hit.score) -``` - -See the [Python indexing contract](docs/benchmarking/core_contract.md) for -configuration, checkpoints, result schemas, and run layout. - -## Models, downloads, and local data - -Pinned model downloads are disclosed before preparation: +Model weights are not hidden inside the Python package or container image. The +browser app and `vidxp prepare` disclose what is missing, the download size, +the cache location, and available disk space before downloading. | Capability | Models | Approximate download | |---|---|---:| @@ -320,17 +193,12 @@ Pinned model downloads are disclosed before preparation: | Scene | SigLIP2 base patch16-224 | 1.43 GiB | | Actor | OpenCV Zoo YuNet + SFace | 37 MiB | -Actual installed and cached disk use can be higher. `vidxp prepare` calculates -what is missing on the current machine and asks before downloading it. +Actual installed and cached disk use is higher because the Python runtime, +PyTorch, provider packages, indexes, source media, and generated artifacts are +separate from model weights. -These providers replace the earlier WhisperX `large-v2` + -`all-MiniLM-L6-v2`, OpenAI CLIP `ViT-B/32`, and -`face_recognition`/dlib stack. Their embeddings, face thresholds, and provider -manifests are not index-compatible. Re-index videos created with an earlier -VidXP release after preparing the new models. - -Local repositories and models are independent of the shell’s current -directory and Python environment: +Local data lives in the operating system's per-user VidXP directory, not in +the shell's current directory: | Platform | Default data root | |---|---| @@ -338,43 +206,48 @@ directory and Python environment: | macOS | `~/Library/Application Support/VidXP` | | Linux | `${XDG_DATA_HOME:-~/.local/share}/VidXP` | -The default repository is under `repositories/default/`; models are under -`models/`. Use `--data-dir` for one CLI invocation or `VIDXP_DATA_DIR` for all -local entry points. Docker uses its declared volumes instead. +CLI and desktop installations share this data root by default. The desktop +keeps its managed Python environment and launcher state in a separate private +application-data directory. + +Docker stores the same product data in the declared `vidxp-data` volume. + +## What is next + +The current product work is focused on: -## Current release boundaries +- signing and notarizing downloadable desktop packages; +- improving desktop runtime repair and configuration controls; +- evaluating supported GPU worker and desktop profiles; +- richer result playback, previews, timelines, and actor-labeling workflows; +- stronger collection organization and search filtering; +- benchmark-backed retrieval and ranking improvements; and +- scaling the server topology beyond its current single-node deployment. -- CPU is the supported default. GPU execution is explicitly deferred. -- Model preparation is explicit; first indexing/search requests never begin - hidden downloads. -- The desktop app is a preview until signed installers are published. -- The server Compose topology is a single-node, single-repository deployment; - multi-replica failover and hosted database substitutions are not claimed. -- Actor identity is within-video face grouping, not real-world identity - recognition. +VidXP is beta software. Current boundaries are documented rather than hidden: +CPU is the supported runtime today, actor matching groups appearances within a +video rather than identifying real people, and the published server topology +does not yet claim multi-replica failover. ## Documentation -| Use VidXP | Build or evaluate VidXP | +| Use or deploy VidXP | Build or evaluate VidXP | |---|---| -| [Installation and troubleshooting](INSTALLATION_GUIDE.md) | [Contribution guide](docs/CONTRIBUTING.md) | -| [Coolify deployment](docs/deployment/coolify.md) | [Adding a capability](docs/adding-a-capability.md) | -| [Desktop preview](docs/desktop.md) | [Benchmarking](docs/benchmarking/README.md) | -| [Changelog](CHANGELOG.md) | [Release process](docs/releasing.md) | +| [Installation guide](INSTALLATION_GUIDE.md) | [Contribution guide](docs/CONTRIBUTING.md) | +| [Desktop application](docs/desktop.md) | [Adding a capability](docs/adding-a-capability.md) | +| [Coolify deployment](docs/deployment/coolify.md) | [Architecture](docs/architecture/platform.md) | +| [Changelog](CHANGELOG.md) | [Benchmarking](docs/benchmarking/README.md) | ## Contributing -Contributions are welcome. Please read the +Contributions are welcome. Read the [contribution guide](docs/CONTRIBUTING.md) before opening a pull request. ## Credits Built by Grayhat Developers PVT Ltd. and maintained by the community. -- Email: info@grayhat.studio -- [Issue tracker](https://github.com/grayhatdevelopers/vidxp/issues) -- [Discord](https://grayhat.studio/discord) -- [MIT license](LICENSE) +Email: info@grayhat.studio VidXP contributors diff --git a/changes/+product-interfaces.feature.md b/changes/+product-interfaces.feature.md index 8036c30..eed9c25 100644 --- a/changes/+product-interfaces.feature.md +++ b/changes/+product-interfaces.feature.md @@ -5,5 +5,5 @@ Expose the shared VidXP application through each supported product surface: - provide a Python indexing and retrieval layer for applications - provide a versioned HTTP API with OpenAPI, media transfer, readiness, durable jobs, and artifact delivery - provide local stdio and remote Streamable HTTP MCP with import-ready client configuration, a real protocol self-check, media/index discovery, job polling/retry, and clip download links -- provide a Tauri desktop setup preview that preflights FFmpeg before Python/package downloads, owns its runtime, supervises the same local UI and worker, and leaves FFmpeg bundling behind an explicit provenance/licensing gate +- provide a Tauri desktop application that preflights FFmpeg with native consent, provisions selected capabilities and optional browser dependencies, supports a custom model location and deferred downloads, opens configured browser profiles directly, owns its runtime from a native system tray with full shutdown on Quit, builds as a Windows GUI executable, and hides supervised child consoles - provide an all-in-one local container using the same repository and worker behavior diff --git a/changes/+release-publication.feature.md b/changes/+release-publication.feature.md index d4a0369..34b5dac 100644 --- a/changes/+release-publication.feature.md +++ b/changes/+release-publication.feature.md @@ -2,5 +2,6 @@ Align public release artifacts: - stamp the Python package, Tauri package, desktop runtime manifest, and native app versions through semantic release - build PyPI distributions and local/control/worker container images from the exact validated release commit +- build unsigned NSIS, DMG, and AppImage desktop installers from locked target-specific profiles and attach them to the matching GitHub release - verify the minimal wheel separately from optional capability, UI, MCP, server, benchmark, and test extras - keep the repository README's relative assets while rewriting them only in the temporary PyPI build diff --git a/desktop/package.json b/desktop/package.json index 2e31594..e83472d 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -10,7 +10,7 @@ "predesktop:dev": "npm run sync:branding", "desktop:dev": "tauri dev", "predesktop:build": "npm run icons", - "desktop:build": "tauri build", + "desktop:build": "node scripts/build-desktop.mjs", "sidecar:windows": "powershell -ExecutionPolicy Bypass -File scripts/fetch-uv.ps1", "sidecar:unix": "bash scripts/fetch-uv.sh" }, diff --git a/desktop/runtime-constraints.txt b/desktop/runtime-constraints.txt new file mode 100644 index 0000000..d564a7c --- /dev/null +++ b/desktop/runtime-constraints.txt @@ -0,0 +1,499 @@ +# This file was autogenerated by uv via the following command: +# uv export --frozen --extra local-worker --extra frontend --no-dev --no-emit-project --no-hashes --format requirements-txt --output-file desktop/runtime-constraints.txt +aiohappyeyeballs==2.7.1 + # via aiohttp +aiohttp==3.14.3 + # via kubernetes +aiosignal==1.4.0 + # via aiohttp +altair==6.2.2 + # via streamlit +annotated-doc==0.0.5 + # via typer +annotated-types==0.8.0 + # via pydantic +anyio==4.14.2 + # via + # httpx + # httpx2 + # openai + # starlette + # streamlit + # watchfiles +attrs==26.1.0 + # via + # aiohttp + # jsonschema + # referencing +av==18.0.0 + # via faster-whisper +bcrypt==5.0.0 + # via chromadb +blinker==1.9.0 + # via streamlit +build==1.5.0 + # via chromadb +certifi==2026.7.22 + # via + # httpcore + # httpx + # kubernetes + # requests +charset-normalizer==3.4.9 + # via requests +chromadb==1.5.9 + # via vidxp +click==8.4.2 + # via + # huggingface-hub + # streamlit + # uvicorn +colorama==0.4.6 ; os_name == 'nt' or sys_platform == 'win32' + # via + # build + # click + # tqdm + # typer +ctranslate2==4.8.1 + # via faster-whisper +dbos==2.28.0 + # via vidxp +distro==1.9.0 + # via openai +durationpy==0.10 + # via kubernetes +faster-whisper==1.2.1 + # via vidxp +filelock==3.32.0 + # via + # huggingface-hub + # torch + # vidxp +flatbuffers==25.12.19 + # via onnxruntime +frozenlist==1.8.0 + # via + # aiohttp + # aiosignal +fsspec==2026.7.0 + # via + # huggingface-hub + # torch +genai-prices==0.0.72 + # via pydantic-ai-slim +gitdb==4.0.12 + # via gitpython +gitpython==3.1.57 + # via streamlit +googleapis-common-protos==1.75.0 + # via opentelemetry-exporter-otlp-proto-grpc +greenlet==3.5.4 + # via sqlalchemy +griffelib==2.1.0 + # via pydantic-ai-slim +grpcio==1.83.0 + # via + # chromadb + # opentelemetry-exporter-otlp-proto-grpc +h11==0.16.0 + # via + # httpcore + # httpcore2 + # uvicorn +hf-xet==1.5.2 ; platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64' + # via huggingface-hub +httpcore==1.0.9 + # via httpx +httpcore2==2.9.1 + # via httpx2 +httptools==0.8.0 + # via + # streamlit + # uvicorn +httpx==0.28.1 + # via + # chromadb + # huggingface-hub + # openai + # pydantic-ai-slim + # pydantic-graph +httpx2==2.9.1 + # via genai-prices +huggingface-hub==1.25.1 + # via + # faster-whisper + # sentence-transformers + # tokenizers + # transformers + # vidxp +idna==3.18 + # via + # anyio + # httpx + # httpx2 + # requests + # yarl +importlib-resources==7.1.0 + # via chromadb +itsdangerous==2.2.0 + # via streamlit +jinja2==3.1.6 + # via + # altair + # pydeck + # torch +jiter==0.16.0 + # via openai +joblib==1.5.3 + # via scikit-learn +jsonschema==4.26.0 + # via + # altair + # chromadb +jsonschema-specifications==2025.9.1 + # via jsonschema +kubernetes==36.0.3 + # via chromadb +logfire-api==4.39.0 + # via pydantic-graph +markdown-it-py==4.2.0 + # via rich +markupsafe==3.0.3 + # via jinja2 +mdurl==0.1.2 + # via markdown-it-py +mmh3==5.2.1 + # via chromadb +mpmath==1.3.0 + # via sympy +multidict==6.7.1 + # via + # aiohttp + # yarl +narwhals==2.24.0 + # via + # altair + # scikit-learn +networkx==3.6.1 + # via torch +numpy==2.4.6 ; python_full_version < '3.12' + # via + # chromadb + # ctranslate2 + # onnxruntime + # opencv-python-headless + # pandas + # pydeck + # scikit-learn + # scipy + # sentence-transformers + # streamlit + # transformers + # vidxp +numpy==2.5.1 ; python_full_version >= '3.12' + # via + # chromadb + # ctranslate2 + # onnxruntime + # opencv-python-headless + # pandas + # pydeck + # scikit-learn + # scipy + # sentence-transformers + # streamlit + # transformers + # vidxp +oauthlib==3.3.1 + # via requests-oauthlib +onnxruntime==1.28.0 + # via + # chromadb + # faster-whisper +openai==2.50.0 + # via pydantic-ai-slim +opencv-python-headless==5.0.0.93 + # via vidxp +opentelemetry-api==1.44.0 + # via + # chromadb + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-sdk + # opentelemetry-semantic-conventions + # pydantic-ai-slim +opentelemetry-exporter-otlp-proto-common==1.44.0 + # via opentelemetry-exporter-otlp-proto-grpc +opentelemetry-exporter-otlp-proto-grpc==1.44.0 + # via chromadb +opentelemetry-proto==1.44.0 + # via + # opentelemetry-exporter-otlp-proto-common + # opentelemetry-exporter-otlp-proto-grpc +opentelemetry-sdk==1.44.0 + # via + # chromadb + # opentelemetry-exporter-otlp-proto-grpc +opentelemetry-semantic-conventions==0.65b0 + # via opentelemetry-sdk +orjson==3.11.9 + # via chromadb +overrides==7.7.0 + # via chromadb +packaging==26.2 + # via + # altair + # build + # huggingface-hub + # onnxruntime + # pooch + # streamlit + # transformers + # vidxp +pandas==3.0.5 + # via streamlit +pillow==12.3.0 + # via + # streamlit + # vidxp +platformdirs==4.11.0 + # via + # pooch + # vidxp +pooch==1.9.0 + # via vidxp +propcache==0.5.2 + # via + # aiohttp + # yarl +protobuf==7.35.1 + # via + # googleapis-common-protos + # onnxruntime + # opentelemetry-proto + # streamlit +psutil==7.2.2 + # via vidxp +psycopg==3.3.4 + # via dbos +psycopg-binary==3.3.4 ; implementation_name != 'pypy' + # via psycopg +pyarrow==24.0.0 + # via streamlit +pybase64==1.4.3 + # via chromadb +pydantic==2.13.4 + # via + # chromadb + # genai-prices + # openai + # pydantic-ai-slim + # pydantic-graph + # pydantic-settings + # vidxp +pydantic-ai-slim==2.20.0 + # via vidxp +pydantic-core==2.46.4 + # via pydantic +pydantic-graph==2.20.0 + # via pydantic-ai-slim +pydantic-settings==2.14.2 + # via + # chromadb + # vidxp +pydeck==0.9.3 + # via streamlit +pygments==2.20.0 + # via rich +pypika==0.51.1 + # via chromadb +pyproject-hooks==1.2.0 + # via build +python-dateutil==2.9.0.post0 + # via + # dbos + # kubernetes + # pandas +python-dotenv==1.2.2 + # via + # pydantic-settings + # uvicorn +python-multipart==0.0.32 + # via streamlit +pyyaml==6.0.3 + # via + # chromadb + # ctranslate2 + # dbos + # huggingface-hub + # kubernetes + # transformers + # uvicorn +referencing==0.37.0 + # via + # jsonschema + # jsonschema-specifications +regex==2026.7.19 + # via + # tiktoken + # transformers +requests==2.34.2 + # via + # kubernetes + # pooch + # requests-oauthlib + # streamlit + # tiktoken +requests-oauthlib==2.0.0 + # via kubernetes +rich==15.0.0 + # via + # chromadb + # typer + # vidxp +rpds-py==2026.6.3 + # via + # jsonschema + # referencing +safetensors==0.8.0 + # via transformers +scikit-learn==1.9.0 + # via sentence-transformers +scipy==1.17.1 ; python_full_version < '3.12' + # via + # scikit-learn + # sentence-transformers +scipy==1.18.0 ; python_full_version >= '3.12' + # via + # scikit-learn + # sentence-transformers +sentence-transformers==5.6.1 + # via vidxp +setuptools==83.0.0 + # via + # ctranslate2 + # torch +shellingham==1.5.4 + # via typer +six==1.17.0 + # via + # kubernetes + # python-dateutil +smmap==5.0.3 + # via gitdb +sniffio==1.3.1 + # via openai +sqlalchemy==2.0.51 + # via + # dbos + # vidxp +starlette==1.3.1 + # via streamlit +streamlit==1.60.0 + # via vidxp +sympy==1.14.0 + # via torch +tenacity==9.1.4 + # via + # chromadb + # streamlit +threadpoolctl==3.6.0 + # via scikit-learn +tiktoken==0.13.0 + # via pydantic-ai-slim +tokenizers==0.22.2 + # via + # chromadb + # faster-whisper + # transformers +toml==0.10.2 + # via streamlit +torch==2.13.0 ; sys_platform != 'linux' and sys_platform != 'win32' + # via + # sentence-transformers + # vidxp +torch==2.13.0+cpu ; sys_platform == 'linux' or sys_platform == 'win32' + # via + # sentence-transformers + # vidxp +tqdm==4.70.0 + # via + # chromadb + # faster-whisper + # huggingface-hub + # openai + # sentence-transformers + # transformers +transformers==5.14.1 + # via + # sentence-transformers + # vidxp +truststore==0.10.4 + # via + # httpcore2 + # httpx2 +typer==0.27.0 + # via + # chromadb + # transformers + # typer-slim + # vidxp +typer-slim==0.24.0 + # via dbos +typing-extensions==4.16.0 + # via + # aiohttp + # aiosignal + # altair + # anyio + # chromadb + # grpcio + # httpx2 + # huggingface-hub + # openai + # opentelemetry-api + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-sdk + # opentelemetry-semantic-conventions + # psycopg + # pydantic + # pydantic-core + # referencing + # sentence-transformers + # sqlalchemy + # starlette + # streamlit + # torch + # typing-inspection +typing-inspection==0.4.2 + # via + # pydantic + # pydantic-ai-slim + # pydantic-graph + # pydantic-settings +tzdata==2026.3 ; sys_platform == 'emscripten' or sys_platform == 'win32' + # via + # pandas + # psycopg +urllib3==2.7.0 + # via + # kubernetes + # requests +uvicorn==0.51.0 + # via + # chromadb + # streamlit +uvloop==0.22.1 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32' + # via uvicorn +watchdog==6.0.0 ; sys_platform != 'darwin' + # via streamlit +watchfiles==1.2.0 + # via uvicorn +websocket-client==1.9.0 + # via kubernetes +websockets==16.1.1 + # via + # dbos + # streamlit + # uvicorn +yarl==1.24.5 + # via aiohttp diff --git a/desktop/runtime-manifest.json b/desktop/runtime-manifest.json index c10e2ba..306c32b 100644 --- a/desktop/runtime-manifest.json +++ b/desktop/runtime-manifest.json @@ -4,11 +4,17 @@ "package_name": "vidxp", "package_version": "0.2.1-b.1", "dependency_index": "https://pypi.org/simple", + "dependency_constraints_sha256": "d3ed16906841952017f112903356bfe433a8515a37aa3f56e4af4dfa1f985835", "python_version": "3.14.6", "uv_version": "0.12.0", - "always_install_extras": [ - "frontend" - ], + "surfaces": { + "browser": { + "extra": "frontend", + "label": "Browser interface", + "description": "Installs the local browser interface. Leave this off for a processing-only runtime.", + "default": true + } + }, "capabilities": { "dialogue": { "extra": "dialogue", @@ -32,6 +38,6 @@ "ffmpeg", "ffprobe" ], - "reason": "First-run setup verifies the system FFmpeg runtime and offers a consented package-manager command; bundling remains disabled until target-specific codec licenses and provenance are recorded." + "reason": "Startup checks verify the system FFmpeg runtime and offer a consented package-manager command; bundling remains disabled until target-specific codec licenses and provenance are recorded." } } diff --git a/desktop/scripts/build-desktop.mjs b/desktop/scripts/build-desktop.mjs new file mode 100644 index 0000000..4cc137a --- /dev/null +++ b/desktop/scripts/build-desktop.mjs @@ -0,0 +1,32 @@ +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const bundles = { + darwin: "dmg", + linux: "appimage", + win32: "nsis", +}; + +const bundle = bundles[process.platform]; +if (!bundle) { + throw new Error(`Unsupported desktop build platform: ${process.platform}`); +} + +const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const executable = resolve( + desktopRoot, + "node_modules", + ".bin", + process.platform === "win32" ? "tauri.cmd" : "tauri", +); +const result = spawnSync( + executable, + ["build", "--bundles", bundle, "--ci", "--no-sign", "--", "--locked"], + { shell: process.platform === "win32", stdio: "inherit" }, +); + +if (result.error) { + throw result.error; +} +process.exit(result.status ?? 1); diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 490d7eb..306e984 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2313,6 +2313,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2877,6 +2878,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3613,6 +3638,48 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.19", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.19", + "toml 1.1.4+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-log" version = "2.9.0" @@ -3634,6 +3701,28 @@ dependencies = [ "time", ] +[[package]] +name = "tauri-plugin-opener" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.19", + "url", + "windows", + "zbus", +] + [[package]] name = "tauri-plugin-shell" version = "2.3.5" @@ -4264,12 +4353,15 @@ version = "0.2.1-b.1" dependencies = [ "atomic-write-file", "hex", + "log", "serde", "serde_json", "sha2 0.11.0", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-log", + "tauri-plugin-opener", "tauri-plugin-shell", "tauri-plugin-single-instance", "wait-timeout", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 3124280..12c9696 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "vidxp-desktop" version = "0.2.1-b.1" -description = "A thin desktop installer and supervisor for VidXP" +description = "The VidXP desktop launcher and local runtime supervisor" edition = "2024" rust-version = "1.85" license = "MIT" @@ -17,11 +17,14 @@ tauri-build = { version = "2.6.3", features = [] } [dependencies] atomic-write-file = "0.3.0" hex = "0.4.3" +log = "0.4.29" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" sha2 = "0.11.0" -tauri = { version = "2.11.5", features = [] } +tauri = { version = "2.11.5", features = ["tray-icon"] } tauri-plugin-log = "2.9.0" +tauri-plugin-dialog = "2.6.0" +tauri-plugin-opener = "2.5.0" tauri-plugin-shell = "2.3.5" tauri-plugin-single-instance = "2.4.3" wait-timeout = "0.2.1" diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns index 7a1c9ef47d616b43396b61223e7ba70b2b0e9cbd..ddcc71122152ecf598ab3c6c97c010c4b6db488b 100644 GIT binary patch delta 144 zcmZ4dGI;UJV2;e>ykZ8CFN@nB-DLt|W*}zS{^%}i;jZn1*O;!bH!HGiS7c#)W!4@v zgAIt;ftX`^&HRBTgcD4D8+tubXy)kP)9K;30 g+(66&#JoVv2gLk9EC9rUKr95r!rKoAiEQcs0PA}_4gdfE delta 146 zcmZ4dGI;UJV2;e>ykZ8CFN?R!a&StrwEG@n-tK#d<(1j?BvsDE{M%P-<|qqcPByS; z_XEoKfn_RpF<)VCFpX~X&+COA-0Wmia^8hg~ g5c2^sKM)H5u^, + surfaces: BTreeMap, capabilities: BTreeMap, media_runtime: MediaRuntimeSpec, } @@ -56,13 +73,17 @@ struct RuntimeManifest { #[derive(Deserialize)] struct InstallRequest { capabilities: Vec, + surfaces: Vec, prepare_models: bool, + model_directory: Option, } #[derive(Serialize)] struct InstallResult { package_version: String, capabilities: Vec, + surfaces: Vec, + model_directory: String, prepared: bool, } @@ -71,6 +92,8 @@ struct RuntimeStatus { ready: bool, package_version: String, capabilities: Vec, + surfaces: Vec, + model_directory: String, detail: String, } @@ -104,9 +127,14 @@ struct ActiveRuntime { profile: String, package_version: String, capabilities: Vec, + #[serde(default)] + surfaces: Vec, + #[serde(default)] + model_directory: PathBuf, } struct DesktopPaths { + private_data: PathBuf, data: PathBuf, cache: PathBuf, repository: PathBuf, @@ -116,8 +144,13 @@ struct DesktopPaths { active_runtime: PathBuf, } +struct ManagedUi { + process: Child, + url: String, +} + struct DesktopState { - ui_process: Mutex>, + ui_process: Mutex>, operation_process: Mutex>, operation_worker_runtime: Mutex>, operation_active: AtomicBool, @@ -147,8 +180,16 @@ impl Drop for OperationGuard<'_> { } fn manifest() -> Result { - serde_json::from_slice(RUNTIME_MANIFEST_BYTES) - .map_err(|error| format!("The embedded runtime manifest is invalid: {error}")) + let manifest: RuntimeManifest = serde_json::from_slice(RUNTIME_MANIFEST_BYTES) + .map_err(|error| format!("The embedded runtime manifest is invalid: {error}"))?; + let actual = hex::encode(Sha256::digest(RUNTIME_CONSTRAINTS_BYTES)); + if actual != manifest.dependency_constraints_sha256 { + return Err(format!( + "The embedded runtime constraints have digest {actual}; expected {}.", + manifest.dependency_constraints_sha256 + )); + } + Ok(manifest) } fn manifest_digest() -> String { @@ -156,23 +197,97 @@ fn manifest_digest() -> String { } fn desktop_paths(app: &AppHandle) -> Result { - let data = app - .path() - .app_local_data_dir() - .map_err(|error| format!("Could not resolve the application data directory: {error}"))?; + let private_data = app.path().app_local_data_dir().map_err(|error| { + format!("Could not resolve the private application data directory: {error}") + })?; + let local_data = app.path().local_data_dir().map_err(|error| { + format!("Could not resolve the operating-system data directory: {error}") + })?; let cache = app .path() .app_cache_dir() .map_err(|error| format!("Could not resolve the application cache directory: {error}"))?; - Ok(DesktopPaths { + Ok(desktop_paths_from_roots(&private_data, &cache, &local_data)) +} + +fn desktop_paths_from_roots(private_data: &Path, cache: &Path, local_data: &Path) -> DesktopPaths { + let data = local_data.join(PRODUCT_DATA_DIRECTORY_NAME); + DesktopPaths { repository: data.join("repositories").join("default"), - runtimes: data.join("runtimes"), - python: data.join("python"), - active_runtime: data.join("active-runtime.json"), + runtimes: private_data.join("runtimes"), + python: private_data.join("python"), + active_runtime: private_data.join("active-runtime.json"), models: data.join("models"), + private_data: private_data.to_path_buf(), data, - cache, - }) + cache: cache.to_path_buf(), + } +} + +fn move_legacy_shared_directory(source: &Path, destination: &Path) -> Result { + if !source.exists() { + return Ok(false); + } + if destination.exists() { + log::warn!( + "Leaving legacy VidXP data at {} because {} already exists", + source.display(), + destination.display() + ); + return Ok(false); + } + let parent = destination + .parent() + .ok_or_else(|| format!("{} has no parent directory", destination.display()))?; + fs::create_dir_all(parent) + .map_err(|error| format!("Could not create {}: {error}", parent.display()))?; + fs::rename(source, destination).map_err(|error| { + format!( + "Could not move legacy VidXP data from {} to {}: {error}", + source.display(), + destination.display() + ) + })?; + Ok(true) +} + +fn migrate_legacy_shared_data(app: &AppHandle) -> Result<(), String> { + let paths = desktop_paths(app)?; + let legacy_models = paths.private_data.join("models"); + let legacy_repositories = paths.private_data.join("repositories"); + let shared_repositories = paths + .repository + .parent() + .expect("the default repository always has a parent"); + let moved_models = move_legacy_shared_directory(&legacy_models, &paths.models)?; + move_legacy_shared_directory(&legacy_repositories, shared_repositories)?; + + if moved_models && paths.active_runtime.exists() { + let contents = fs::read(&paths.active_runtime).map_err(|error| { + format!("Could not read the active runtime during migration: {error}") + })?; + let mut active: ActiveRuntime = serde_json::from_slice(&contents) + .map_err(|error| format!("The active runtime pointer is invalid: {error}"))?; + if active.model_directory == legacy_models { + active.model_directory = paths.models.clone(); + write_active_runtime(&paths, &active)?; + } + } + Ok(()) +} + +fn model_directory(paths: &DesktopPaths, requested: Option<&str>) -> Result { + let Some(requested) = requested.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(paths.models.clone()); + }; + let directory = PathBuf::from(requested); + if !directory.is_absolute() { + return Err("The model directory must be an absolute path.".into()); + } + if directory.is_file() { + return Err("The selected model location is a file, not a directory.".into()); + } + Ok(directory) } fn selected_capabilities( @@ -192,11 +307,30 @@ fn selected_capabilities( Ok(selected.into_iter().collect()) } -fn package_specification(manifest: &RuntimeManifest, capabilities: &[String]) -> String { +fn selected_surfaces( + manifest: &RuntimeManifest, + requested: &[String], +) -> Result, String> { + let selected: BTreeSet<_> = requested.iter().cloned().collect(); + if let Some(unknown) = selected + .iter() + .find(|name| !manifest.surfaces.contains_key(*name)) + { + return Err(format!("Unknown desktop surface: {unknown}")); + } + Ok(selected.into_iter().collect()) +} + +fn package_specification( + manifest: &RuntimeManifest, + capabilities: &[String], + surfaces: &[String], +) -> String { let extras: BTreeSet<_> = manifest - .always_install_extras + .surfaces .iter() - .cloned() + .filter(|(name, _)| surfaces.contains(name)) + .map(|(_, surface)| surface.extra.clone()) .chain( capabilities .iter() @@ -242,7 +376,9 @@ fn package_acquisition_arguments(manifest: &RuntimeManifest, python: &Path) -> V fn dependency_installation_arguments( manifest: &RuntimeManifest, capabilities: &[String], + surfaces: &[String], python: &Path, + constraints: &Path, cpu_torch: bool, ) -> Vec { let mut arguments = vec![ @@ -255,11 +391,13 @@ fn dependency_installation_arguments( manifest.dependency_index.clone(), "--index-strategy".into(), "first-index".into(), + "--constraints".into(), + constraints.to_string_lossy().into_owned(), ]; if cpu_torch { arguments.extend(["--torch-backend".into(), "cpu".into()]); } - arguments.push(package_specification(manifest, capabilities)); + arguments.push(package_specification(manifest, capabilities, surfaces)); arguments } @@ -542,12 +680,25 @@ fn clean_environment(paths: &DesktopPaths) -> Vec<(String, String)> { fn configured_command(executable_path: &Path, paths: &DesktopPaths) -> Command { let mut command = Command::new(executable_path); + hide_child_console(&mut command); command.env_clear(); command.envs(clean_environment(paths)); command } +#[cfg(windows)] +fn hide_child_console(command: &mut Command) { + use std::os::windows::process::CommandExt; + + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); +} + +#[cfg(not(windows))] +fn hide_child_console(_command: &mut Command) {} + fn checked_output(mut command: Command, operation: &str) -> Result { + hide_child_console(&mut command); let output = command .output() .map_err(|error| format!("{operation} could not start: {error}"))?; @@ -562,10 +713,10 @@ fn checked_output(mut command: Command, operation: &str) -> Result Result { let contents = fs::read(&paths.active_runtime) - .map_err(|_| "VidXP has not been installed by this desktop app.".to_string())?; + .map_err(|_| "Local video processing has not been configured yet.".to_string())?; let active: ActiveRuntime = serde_json::from_slice(&contents) .map_err(|error| format!("The active runtime pointer is invalid: {error}"))?; - if active.schema_version != 1 || active.manifest_sha256 != manifest_digest() { + if active.schema_version != 2 || active.manifest_sha256 != manifest_digest() { return Err("The desktop runtime needs to be installed for this app version.".into()); } if !active @@ -720,6 +871,22 @@ fn media_runtime_status() -> MediaRuntimeStatus { inspect_media_runtime() } +#[tauri::command] +fn choose_model_directory(app: AppHandle) -> Result, String> { + let selection = app + .dialog() + .file() + .set_title("Choose where VidXP stores model files") + .blocking_pick_folder(); + selection + .map(|path| { + path.into_path() + .map(|path| path.to_string_lossy().into_owned()) + .map_err(|error| format!("The selected model directory is invalid: {error}")) + }) + .transpose() +} + #[tauri::command] async fn install_media_runtime( app: AppHandle, @@ -741,10 +908,33 @@ async fn install_media_runtime( }; let plan = system_install_plan().ok_or("No supported system package manager was found.")?; if !plan.automatic { - return Err(format!( - "Run this command in a system terminal, then retry: {}", + let instruction = format!( + "VidXP needs FFmpeg and ffprobe.\n\nRun this command in a terminal, then return to VidXP:\n\n{}", display_command(&plan.command) - )); + ); + app.dialog() + .message(&instruction) + .title("FFmpeg is required") + .kind(MessageDialogKind::Warning) + .blocking_show(); + return Err(instruction); + } + let approved = app + .dialog() + .message(format!( + "VidXP needs FFmpeg and ffprobe for video processing.\n\nInstall them with {}?\n\n{}", + plan.manager, + display_command(&plan.command) + )) + .title("Install FFmpeg") + .kind(MessageDialogKind::Info) + .buttons(MessageDialogButtons::OkCancelCustom( + "Install".into(), + "Not now".into(), + )) + .blocking_show(); + if !approved { + return Err("FFmpeg setup was deferred.".into()); } let command = app .shell() @@ -769,12 +959,15 @@ async fn install_media_runtime( #[tauri::command] fn runtime_status(app: AppHandle) -> Result { let manifest = manifest()?; - let paths = desktop_paths(&app)?; + let mut paths = desktop_paths(&app)?; + let default_model_directory = paths.models.to_string_lossy().into_owned(); if let Err(detail) = verified_media_runtime() { return Ok(RuntimeStatus { ready: false, package_version: manifest.package_version, capabilities: Vec::new(), + surfaces: Vec::new(), + model_directory: default_model_directory, detail, }); } @@ -785,10 +978,13 @@ fn runtime_status(app: AppHandle) -> Result { ready: false, package_version: manifest.package_version, capabilities: Vec::new(), + surfaces: Vec::new(), + model_directory: default_model_directory, detail, }); } }; + paths.models = active.model_directory.clone(); let runtime = runtime_directory(&paths, &active); let version = run_vidxp( &runtime, @@ -811,9 +1007,11 @@ fn runtime_status(app: AppHandle) -> Result { ready: version.is_ok(), package_version: active.package_version, capabilities: active.capabilities, + surfaces: active.surfaces, + model_directory: active.model_directory.to_string_lossy().into_owned(), detail: version .err() - .unwrap_or_else(|| "The desktop runtime is ready.".into()), + .unwrap_or_else(|| "Local video processing is ready.".into()), }) } @@ -835,8 +1033,10 @@ async fn install_runtime( }; let manifest = manifest()?; let capabilities = selected_capabilities(&manifest, &request.capabilities)?; + let surfaces = selected_surfaces(&manifest, &request.surfaces)?; let media_runtime = verified_media_runtime()?; - let paths = desktop_paths(&app)?; + let mut paths = desktop_paths(&app)?; + paths.models = model_directory(&paths, request.model_directory.as_deref())?; for directory in [ &paths.data, &paths.cache, @@ -850,11 +1050,12 @@ async fn install_runtime( } let profile_seed = format!( - "{}:{}:{}:{}", + "{}:{}:{}:{}:{}", manifest_digest(), std::env::consts::OS, std::env::consts::ARCH, - capabilities.join(",") + capabilities.join(","), + surfaces.join(",") ); let profile_hash = hex::encode(Sha256::digest(profile_seed.as_bytes())); let timestamp = SystemTime::now() @@ -865,6 +1066,9 @@ async fn install_runtime( let staging = paths.runtimes.join(&staging_name); fs::create_dir(&staging) .map_err(|error| format!("Could not create the staged runtime: {error}"))?; + let constraints = staging.join("runtime-constraints.txt"); + fs::write(&constraints, RUNTIME_CONSTRAINTS_BYTES) + .map_err(|error| format!("Could not write runtime constraints: {error}"))?; let install_result = async { uv_output( @@ -899,7 +1103,9 @@ async fn install_runtime( dependency_installation_arguments( &manifest, &capabilities, + &surfaces, &executable(&staging, "python"), + &constraints, !cfg!(target_os = "macos"), ), "VidXP package installation", @@ -978,37 +1184,48 @@ async fn install_runtime( fs::rename(&staging, &runtime) .map_err(|error| format!("Could not finalize the validated runtime: {error}"))?; let active = ActiveRuntime { - schema_version: 1, + schema_version: 2, manifest_sha256: manifest_digest(), profile, package_version: manifest.package_version.clone(), capabilities: capabilities.clone(), + surfaces: surfaces.clone(), + model_directory: paths.models.clone(), }; write_active_runtime(&paths, &active)?; Ok(InstallResult { package_version: manifest.package_version, capabilities, + surfaces, + model_directory: paths.models.to_string_lossy().into_owned(), prepared: request.prepare_models, }) } -#[tauri::command] -fn launch_ui(app: AppHandle, state: tauri::State<'_, DesktopState>) -> Result { - let paths = desktop_paths(&app)?; +fn start_ui(app: &AppHandle, state: &DesktopState) -> Result { + let mut paths = desktop_paths(&app)?; let active = active_runtime(&paths)?; + if !active.surfaces.iter().any(|surface| surface == "browser") { + return Err( + "The browser interface is not installed. Reconfigure VidXP and select Browser interface." + .into(), + ); + } + paths.models = active.model_directory.clone(); let runtime = runtime_directory(&paths, &active); let mut active_process = state .ui_process .lock() .map_err(|_| "The desktop process supervisor is unavailable.".to_string())?; - if let Some(process) = active_process.as_mut() { - if process + if let Some(ui) = active_process.as_mut() { + if ui + .process .try_wait() .map_err(|error| format!("Could not inspect the interface process: {error}"))? .is_none() { - return Err("The VidXP interface is already running.".into()); + return Ok(ui.url.clone()); } *active_process = None; } @@ -1045,8 +1262,12 @@ fn launch_ui(app: AppHandle, state: tauri::State<'_, DesktopState>) -> Result) -> Result Result<(), String> { + let window = app + .get_webview_window("main") + .ok_or("The VidXP desktop window is unavailable.")?; + window + .hide() + .map_err(|error| format!("Could not hide VidXP to the system tray: {error}")) +} + +fn show_main_window(app: &AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } +} + +fn configured_runtime(app: &AppHandle) -> bool { + desktop_paths(app) + .and_then(|paths| active_runtime(&paths)) + .is_ok() +} + +fn browser_surface_configured(app: &AppHandle) -> bool { + desktop_paths(app) + .and_then(|paths| active_runtime(&paths)) + .is_ok_and(|active| active.surfaces.iter().any(|surface| surface == "browser")) +} + +fn open_ui_in_browser(app: &AppHandle, state: &DesktopState) -> Result<(), String> { + let url = start_ui(app, state)?; + app.opener() + .open_url(&url, None::<&str>) + .map_err(|error| format!("Could not open VidXP in the default browser: {error}"))?; + hide_main_window(app) +} + +fn open_or_show(app: &AppHandle) { + if !browser_surface_configured(app) { + show_main_window(app); + return; + } + let app = app.clone(); + thread::spawn(move || { + let state = app.state::(); + if let Err(error) = open_ui_in_browser(&app, &state) { + show_main_window(&app); + app.dialog() + .message(error) + .title("VidXP could not open") + .kind(MessageDialogKind::Error) + .blocking_show(); + } + }); +} + +fn begin_shutdown(app: &AppHandle) { + if app + .state::() + .shutdown_started + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + log::info!("VidXP supervised shutdown requested"); + shutdown(app); + log::info!("VidXP supervised shutdown completed"); + std::process::exit(0); +} + +#[tauri::command] +fn launch_ui(app: AppHandle, state: tauri::State<'_, DesktopState>) -> Result<(), String> { + open_ui_in_browser(&app, &state) +} + +#[tauri::command] +fn hide_to_tray(app: AppHandle) -> Result<(), String> { + if !configured_runtime(&app) { + return Err("Local video processing has not been configured yet.".into()); + } + hide_main_window(&app) +} + +fn create_tray(app: &tauri::App) -> tauri::Result<()> { + let open = MenuItem::with_id(app, "open", "Open VidXP", true, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", "Quit VidXP", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&open, &quit])?; + let mut tray = TrayIconBuilder::with_id("vidxp") + .tooltip("VidXP") + .menu(&menu) + .show_menu_on_left_click(true) + .on_menu_event(|app, event| match event.id().as_ref() { + "open" => open_or_show(app), + "quit" => begin_shutdown(app), + _ => {} + }); + if let Some(icon) = app.default_window_icon() { + tray = tray.icon(icon.clone()); + } + tray.build(app)?; + Ok(()) +} + +fn stop_worker(runtime: &Path, paths: &DesktopPaths) { + let mut command = configured_command(&executable(runtime, "vidxp"), paths); + command + .arg("--index-dir") + .arg(&paths.repository) + .args(["jobs", "stop-worker"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let Ok(mut process) = command.spawn() else { + return; + }; + match process.wait_timeout(Duration::from_secs(5)) { + Ok(Some(_)) => {} + _ => { + let _ = process.kill(); + let _ = process.wait_timeout(Duration::from_secs(1)); + } + } +} + fn shutdown(app: &AppHandle) { + log::info!("Stopping active VidXP processes"); let state = app.state::(); if let Ok(mut active_operation) = state.operation_process.lock() && let Some(process) = active_operation.take() @@ -1063,79 +1410,97 @@ fn shutdown(app: &AppHandle) { let _ = process.kill(); } if let Ok(mut active_process) = state.ui_process.lock() - && let Some(mut process) = active_process.take() + && let Some(mut ui) = active_process.take() { - let _ = process.kill(); - match process.wait_timeout(Duration::from_secs(5)) { + let _ = ui.process.kill(); + match ui.process.wait_timeout(Duration::from_secs(5)) { Ok(Some(_)) => {} _ => { - let _ = process.kill(); - let _ = process.wait_timeout(Duration::from_secs(1)); + let _ = ui.process.kill(); + let _ = ui.process.wait_timeout(Duration::from_secs(1)); } } } - let Ok(paths) = desktop_paths(app) else { + let Ok(mut paths) = desktop_paths(app) else { + log::warn!("Could not resolve desktop paths during shutdown"); return; }; if let Ok(mut operation_worker) = state.operation_worker_runtime.lock() && let Some(runtime) = operation_worker.take() { - let _ = run_vidxp( - &runtime, - &paths, - &["jobs".into(), "stop-worker".into()], - "VidXP preparation worker shutdown", - ); + stop_worker(&runtime, &paths); } let Ok(active) = active_runtime(&paths) else { + log::info!("No active VidXP runtime needs worker shutdown"); return; }; + paths.models = active.model_directory.clone(); let runtime = runtime_directory(&paths, &active); - let _ = run_vidxp( - &runtime, - &paths, - &["jobs".into(), "stop-worker".into()], - "VidXP worker shutdown", - ); + stop_worker(&runtime, &paths); + log::info!("Active VidXP worker shutdown finished"); } pub fn run() { let builder = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _, _| { - if let Some(window) = app.get_webview_window("main") { - let _ = window.show(); - let _ = window.set_focus(); - } + open_or_show(app); })) + .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_log::Builder::new().build()) + .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_shell::init()) .manage(DesktopState::default()) + .setup(|app| { + migrate_legacy_shared_data(app.handle()).map_err(io::Error::other)?; + create_tray(app)?; + if !configured_runtime(app.handle()) { + show_main_window(app.handle()); + } + Ok(()) + }) .invoke_handler(tauri::generate_handler![ runtime_manifest, media_runtime_status, + choose_model_directory, install_media_runtime, runtime_status, install_runtime, - launch_ui + launch_ui, + hide_to_tray ]); let app = builder .build(tauri::generate_context!()) .expect("could not initialize VidXP desktop"); - app.run(|app_handle, event| { - if let RunEvent::ExitRequested { api, .. } = event - && app_handle + app.run(|app_handle, event| match event { + RunEvent::WindowEvent { + label, + event: WindowEvent::CloseRequested { api, .. }, + .. + } if label == "main" => { + if app_handle .state::() .shutdown_started - .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) - .is_ok() + .load(Ordering::Acquire) + { + return; + } + api.prevent_close(); + if configured_runtime(app_handle) { + let _ = hide_main_window(app_handle); + } else { + begin_shutdown(app_handle); + } + } + RunEvent::ExitRequested { api, .. } + if !app_handle + .state::() + .shutdown_started + .load(Ordering::Acquire) => { api.prevent_exit(); - let cleanup_handle = app_handle.clone(); - std::thread::spawn(move || { - shutdown(&cleanup_handle); - cleanup_handle.exit(0); - }); + begin_shutdown(app_handle); } + _ => {} }); } @@ -1143,11 +1508,36 @@ pub fn run() { mod tests { use super::{ base_package_specification, capability_command_arguments, - dependency_installation_arguments, display_command, manifest, + dependency_installation_arguments, desktop_paths_from_roots, display_command, manifest, package_acquisition_arguments, package_index, package_specification, - required_encoder_missing, selected_capabilities, + required_encoder_missing, selected_capabilities, selected_surfaces, }; - use std::path::Path; + use std::path::{Path, PathBuf}; + + #[test] + fn desktop_runtime_is_private_while_product_data_is_shared() { + let paths = + desktop_paths_from_roots(Path::new("private"), Path::new("cache"), Path::new("local")); + + assert_eq!(paths.data, PathBuf::from("local").join("VidXP")); + assert_eq!( + paths.repository, + PathBuf::from("local") + .join("VidXP") + .join("repositories") + .join("default") + ); + assert_eq!( + paths.models, + PathBuf::from("local").join("VidXP").join("models") + ); + assert_eq!(paths.runtimes, PathBuf::from("private").join("runtimes")); + assert_eq!(paths.python, PathBuf::from("private").join("python")); + assert_eq!( + paths.active_runtime, + PathBuf::from("private").join("active-runtime.json") + ); + } #[test] fn capability_selection_is_sorted_and_rejects_unknown_values() { @@ -1168,18 +1558,39 @@ mod tests { assert_eq!(base_package_specification(&manifest), "vidxp==0.2.1-b.1"); assert_eq!( - package_specification(&manifest, &["scene".into(), "dialogue".into()]), + package_specification( + &manifest, + &["scene".into(), "dialogue".into()], + &["browser".into()], + ), "vidxp[dialogue,frontend,scene]==0.2.1-b.1" ); + assert_eq!( + package_specification(&manifest, &["scene".into()], &[]), + "vidxp[scene]==0.2.1-b.1" + ); + assert_eq!( + selected_surfaces(&manifest, &["browser".into(), "browser".into()]) + .expect("surface selection"), + ["browser"] + ); + assert!(selected_surfaces(&manifest, &["unknown".into()]).is_err()); } #[test] fn prerelease_package_and_dependencies_use_separate_indexes() { let manifest = manifest().expect("manifest"); let python = Path::new("managed-python"); + let constraints = Path::new("runtime-constraints.txt"); let acquisition = package_acquisition_arguments(&manifest, python); - let dependencies = - dependency_installation_arguments(&manifest, &["scene".into()], python, true); + let dependencies = dependency_installation_arguments( + &manifest, + &["scene".into()], + &[], + python, + constraints, + true, + ); assert_eq!( package_index(&manifest.package_version), @@ -1200,6 +1611,11 @@ mod tests { .any(|item| item == &manifest.dependency_index) ); assert!(dependencies.iter().any(|item| item == "--torch-backend")); + assert!( + dependencies + .windows(2) + .any(|items| items == ["--constraints", "runtime-constraints.txt"]) + ); } #[test] diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 655f4a8..03c28bd 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -1,3 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + fn main() { vidxp_desktop_lib::run(); } diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 455433c..208ff38 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -19,6 +19,7 @@ "height": 760, "minWidth": 680, "minHeight": 600, + "visible": false, "resizable": true } ] @@ -38,7 +39,8 @@ ], "windows": { "nsis": { - "installMode": "currentUser" + "installMode": "currentUser", + "installerHooks": "nsis-hooks.nsh" } }, "macOS": { diff --git a/desktop/web/app.js b/desktop/web/app.js index 1959c4e..7987bb0 100644 --- a/desktop/web/app.js +++ b/desktop/web/app.js @@ -1,15 +1,22 @@ const invoke = window.__TAURI__.core.invoke; +const surfacesNode = document.querySelector("#surfaces"); const capabilitiesNode = document.querySelector("#capabilities"); const installButton = document.querySelector("#install"); const launchButton = document.querySelector("#launch"); const prepareModels = document.querySelector("#prepare-models"); +const modelDirectoryNode = document.querySelector("#model-directory"); +const chooseModelDirectoryButton = document.querySelector( + "#choose-model-directory", +); const statusNode = document.querySelector("#status"); let manifest; +let selectedModelDirectory; function setBusy(busy) { installButton.disabled = busy; + chooseModelDirectoryButton.disabled = busy; launchButton.disabled = busy || launchButton.dataset.ready !== "true"; } @@ -19,77 +26,108 @@ function selectedCapabilities() { ); } -function renderCapabilities() { - capabilitiesNode.replaceChildren(); - Object.entries(manifest.capabilities).forEach(([id, capability]) => { +function selectedSurfaces() { + return [...document.querySelectorAll("[data-surface]:checked")].map( + (node) => node.dataset.surface, + ); +} + +function renderOptions(entries, container, dataAttribute, className) { + container.replaceChildren(); + Object.entries(entries).forEach(([id, option]) => { const label = document.createElement("label"); - label.className = "capability"; + label.className = className; const input = document.createElement("input"); input.type = "checkbox"; - input.checked = true; - input.dataset.capability = id; + input.checked = option.default ?? true; + input.dataset[dataAttribute] = id; const copy = document.createElement("span"); const name = document.createElement("strong"); - name.textContent = capability.label; + name.textContent = option.label; const detail = document.createElement("small"); - detail.textContent = `Installs the ${capability.extra} capability`; + detail.textContent = + option.description ?? `Installs the ${option.extra} capability`; copy.append(name, detail); label.append(input, copy); - capabilitiesNode.append(label); + container.append(label); + }); +} + +function renderManifest() { + renderOptions(manifest.surfaces, surfacesNode, "surface", "surface"); + renderOptions( + manifest.capabilities, + capabilitiesNode, + "capability", + "capability", + ); +} + +function applySelection(attribute, selected) { + document.querySelectorAll(`[data-${attribute}]`).forEach((node) => { + node.checked = selected.includes(node.dataset[attribute]); }); } async function refreshStatus() { const status = await invoke("runtime_status"); + selectedModelDirectory ??= status.model_directory; + modelDirectoryNode.textContent = selectedModelDirectory; + if (status.ready) { - document.querySelectorAll("[data-capability]").forEach((node) => { - node.checked = status.capabilities.includes(node.dataset.capability); - }); + applySelection("capability", status.capabilities); + applySelection("surface", status.surfaces); } - launchButton.dataset.ready = String(status.ready); - launchButton.disabled = !status.ready; + + const browserReady = status.ready && status.surfaces.includes("browser"); + launchButton.dataset.ready = String(browserReady); + launchButton.disabled = !browserReady; statusNode.textContent = status.ready ? `Ready · VidXP ${status.package_version} · ${status.capabilities.join(", ")}` : status.detail; + return status; } async function ensureMediaRuntime() { - let status = await invoke("media_runtime_status"); + const status = await invoke("media_runtime_status"); if (status.ready) { return status; } - const errors = status.errors.join(" "); if (!status.install_command) { throw new Error( - `${errors} Install FFmpeg and ffprobe, then click Install selected again.`, - ); - } - if (!status.automatic_install) { - throw new Error( - `${errors} Run this command in a system terminal, then retry: ${status.install_command}`, + `${status.errors.join(" ")} Install FFmpeg and ffprobe, then retry.`, ); } - const approved = window.confirm( - [ - "FFmpeg and ffprobe are required before VidXP can be installed.", - "", - `Install through ${status.package_manager}?`, - `Command: ${status.install_command}`, - ].join("\n"), - ); - if (!approved) { - throw new Error("FFmpeg installation was cancelled; VidXP was not installed."); - } - statusNode.textContent = `Installing FFmpeg through ${status.package_manager}…`; - status = await invoke("install_media_runtime"); - if (!status.ready) { - throw new Error(status.errors.join(" ")); + statusNode.textContent = "Waiting for FFmpeg setup confirmation…"; + return invoke("install_media_runtime"); +} + +async function launchBrowser() { + setBusy(true); + statusNode.textContent = "Starting VidXP…"; + try { + await invoke("launch_ui"); + statusNode.textContent = "VidXP is running in the system tray."; + } catch (error) { + statusNode.textContent = `Launch failed: ${error}`; + setBusy(false); } - return status; } +chooseModelDirectoryButton.addEventListener("click", async () => { + try { + const selection = await invoke("choose_model_directory"); + if (selection) { + selectedModelDirectory = selection; + modelDirectoryNode.textContent = selection; + } + } catch (error) { + statusNode.textContent = `Could not select the model folder: ${error}`; + } +}); + installButton.addEventListener("click", async () => { const capabilities = selectedCapabilities(); if (capabilities.length === 0) { @@ -102,42 +140,45 @@ installButton.addEventListener("click", async () => { statusNode.textContent = "Checking FFmpeg and required codecs…"; await ensureMediaRuntime(); statusNode.textContent = prepareModels.checked - ? "Installing VidXP, then preparing models. Large downloads can take a while…" - : "Installing an isolated VidXP runtime…"; + ? "Configuring local processing and downloading selected models…" + : "Configuring local processing…"; const result = await invoke("install_runtime", { request: { capabilities, + surfaces: selectedSurfaces(), prepare_models: prepareModels.checked, + model_directory: selectedModelDirectory, }, }); + selectedModelDirectory = result.model_directory; statusNode.textContent = result.prepared - ? "Installed, validated, and models prepared." - : "Installed and validated."; + ? "Local processing and models are ready." + : "Local processing is ready. Model downloads were deferred."; await refreshStatus(); + if (result.surfaces.includes("browser")) { + await launchBrowser(); + } else { + await invoke("hide_to_tray"); + } } catch (error) { - statusNode.textContent = `Setup failed: ${error}`; + statusNode.textContent = `Configuration failed: ${error}`; } finally { setBusy(false); } }); -launchButton.addEventListener("click", async () => { - setBusy(true); - statusNode.textContent = "Starting the local interface…"; - try { - const url = await invoke("launch_ui"); - window.location.replace(url); - } catch (error) { - statusNode.textContent = `Launch failed: ${error}`; - setBusy(false); - } -}); +launchButton.addEventListener("click", launchBrowser); async function start() { try { manifest = await invoke("runtime_manifest"); - renderCapabilities(); - await refreshStatus(); + renderManifest(); + const status = await refreshStatus(); + if (status.ready && status.surfaces.includes("browser")) { + await launchBrowser(); + } else if (status.ready) { + await invoke("hide_to_tray"); + } } catch (error) { statusNode.textContent = `Desktop initialization failed: ${error}`; setBusy(true); diff --git a/desktop/web/index.html b/desktop/web/index.html index 0c6be54..ecc5848 100644 --- a/desktop/web/index.html +++ b/desktop/web/index.html @@ -4,7 +4,7 @@ - VidXP Setup + VidXP @@ -12,21 +12,39 @@

    VIDXP DESKTOP

    -

    Set up local video search.

    +

    Configure local video processing.

    - Choose only the capabilities you need. VidXP installs into an isolated - application runtime and keeps your media and indexes in your user data. + VidXP is installed. Choose what local processing it should prepare on + this machine.

    +
    +

    Interface

    +
    +
    +

    Capabilities

    +
    + +
    +

    Models

    +
    +
    + Model storage + Using the default location +
    + +
    @@ -37,7 +55,7 @@

    Capabilities

    Checking the local runtime…

    - + @@ -45,10 +63,9 @@

    Capabilities

    - The first setup needs internet access. Before Python or VidXP is - downloaded, setup verifies FFmpeg, ffprobe, and the required codecs. If - they are missing, VidXP shows the exact system package-manager command - and asks before running it. + Configuration uses an app-owned Python runtime. VidXP checks FFmpeg, + ffprobe, and required codecs first and uses operating-system prompts + before running a supported package manager.

    diff --git a/desktop/web/styles.css b/desktop/web/styles.css index 9d2336e..61ad67e 100644 --- a/desktop/web/styles.css +++ b/desktop/web/styles.css @@ -75,13 +75,15 @@ small { background: rgba(21, 25, 34, 0.9); } -.capabilities { +.capabilities, +.options { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; } .capability, +.surface, .prepare-option { display: flex; gap: 0.7rem; @@ -97,7 +99,15 @@ small { background: #171c26; } -.capability:has(input:checked) { +.surface { + padding: 0.9rem; + border: 1px solid #303746; + border-radius: 0.75rem; + background: #171c26; +} + +.capability:has(input:checked), +.surface:has(input:checked) { border-color: #7d88ff; background: #1d2340; } @@ -108,11 +118,29 @@ input { } .capability span, +.surface span, .prepare-option span { display: grid; gap: 0.3rem; } +.path-option { + display: flex; + gap: 1rem; + align-items: center; + justify-content: space-between; +} + +.path-option div { + display: grid; + min-width: 0; + gap: 0.35rem; +} + +.path-option small { + overflow-wrap: anywhere; +} + .prepare-option { margin-top: 1.1rem; padding-top: 1.1rem; @@ -169,11 +197,13 @@ button:disabled { padding: 2rem 0; } - .capabilities { + .capabilities, + .options { grid-template-columns: 1fr; } .status-panel, + .path-option, .actions { align-items: stretch; flex-direction: column; diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md index 02c1182..41e5c09 100644 --- a/docs/architecture/platform.md +++ b/docs/architecture/platform.md @@ -998,16 +998,18 @@ displays model preparation progress, and detaches from durable work or requests cooperative cancellation during shutdown. Closing the UI never waits on an uncancellable model thread inside the UI process. -The Phase 11 adapter is a small Tauri v2 shell. Its bundled setup page selects -capability extras, while the actual application is the exact published VidXP -package installed into a versioned uv-managed environment. The existing Streamlit -adapter remains the local human interface on a random loopback port; remote -loopback content receives no Tauri IPC access. Runtime activation is atomic and a -failed installation retains the prior environment. Tauri owns the Streamlit -process and asks the repository-scoped worker to drain and shut down on exit. - -The first desktop release is an online bootstrap with no tray or updater. It -targets Windows x86-64 NSIS, Apple Silicon DMG, and Linux x86-64 AppImage. +The Phase 11 adapter is a small Tauri v2 shell. Its first-run configuration +selects capability extras, optional interfaces, model preparation, and model +storage, while the processing application is the exact published VidXP package +installed into a versioned uv-managed environment. When selected, the Streamlit +adapter is the local human interface on a random loopback port; remote loopback +content receives no Tauri IPC access. Runtime activation is atomic and a failed +configuration retains the prior environment. Tauri owns the Streamlit process +and asks the repository-scoped worker to drain and shut down on exit. + +The first desktop release is an online bootstrap with a native tray supervisor +and no updater. It targets Windows x86-64 NSIS, Apple Silicon DMG, and Linux +x86-64 AppImage. Target-specific uv binaries are release inputs verified against the pinned upstream checksum. FFmpeg/ffprobe remain validated system dependencies until target build provenance, codec selection, and redistribution licenses are diff --git a/docs/desktop.md b/docs/desktop.md index 6800351..11ca697 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -1,40 +1,52 @@ # Desktop application -The desktop application is a thin Tauri v2 installer and process supervisor. It +The desktop application is a Tauri v2 launcher, first-run configuration +surface, and process supervisor. The operating-system package installs the +application; the application then provisions its local processing runtime. It does not contain a second VidXP implementation: - the selected capability extras are installed from the exact configured package release; -- the existing Streamlit adapter remains the local human interface; -- the repository, model cache, managed Python, and package environments use - platform application-data directories; +- the Streamlit browser interface is an optional installation surface; +- repositories and models use the same platform VidXP data directory as the + CLI, while managed Python and package environments use private desktop + application-data directories; - the existing DBOS worker remains the durable execution boundary; and - closing the desktop process stops both Streamlit and the repository worker. -The desktop uses the operating system-provided app-local-data root rather than -the installation directory or current working directory. Its relevant layout -matches other local installs: +The desktop separates shared product data from its private implementation +state. The shared root is the same operating-system VidXP data directory used +by the CLI: ```text -app-local-data/ +VidXP/ repositories/ default/ - models/ + models/ # default; a user-selected directory is also supported +``` + +The identifier-scoped private desktop directory contains only the managed +runtime and desktop state: + +```text +dev.grayhat.vidxp/ runtimes/ python/ active-runtime.json ``` -The last three entries are desktop bootstrap state. The desktop's uv download -cache uses the operating system-provided app-cache directory. Docker and -Compose storage remains explicitly volume-backed and does not inherit this -desktop layout. - -The first release is an online bootstrap. It bundles `uv` and performs a -system-media preflight before creating Python or downloading VidXP. If FFmpeg -is missing on Windows or macOS, setup shows the exact approved package-manager -command and asks before running it. Linux shows the applicable terminal command -without trying to automate elevation. Setup verifies ffmpeg, ffprobe, +On Windows the per-user NSIS package installs program files under +`%LOCALAPPDATA%\Programs\VidXP`, keeping them separate from both directories. +The desktop's uv download cache uses the operating system-provided app-cache +directory. Docker and Compose storage remains explicitly volume-backed and does +not inherit this desktop layout. + +The application bundles `uv` and performs a system-media preflight before +creating Python or downloading VidXP. If FFmpeg is missing on Windows or +macOS, the application uses a native operating-system dialog to show the exact +approved package-manager command and obtain consent before running it. Linux +shows the applicable terminal command without trying to automate elevation. +The application verifies ffmpeg, ffprobe, `libx264`, and `aac`, then installs the exact Python and VidXP versions in `desktop/runtime-manifest.json`, persists the verified absolute executable paths through `vidxp init`, runs the full `vidxp doctor`, and only then activates @@ -46,29 +58,35 @@ runtime. From `desktop/`: ```powershell -npm install +npm ci npm run sidecar:windows npm run desktop:dev +npm run desktop:build ``` On macOS Apple Silicon or Linux x86-64: ```bash -npm install +npm ci npm run sidecar:unix npm run desktop:dev +npm run desktop:build ``` The sidecar scripts download the pinned official `uv` release, verify its checked-in SHA-256 archive digest from `desktop/sidecars.json`, and place the target-suffixed binary where Tauri expects it. The Rust build also executes the target sidecar and rejects a version mismatch. Generated binaries and build -outputs are ignored by Git. +outputs are ignored by Git. `desktop:build` selects NSIS on Windows, DMG on +macOS, and AppImage on Linux, disables signing, and requires the checked-in +Cargo lock to remain unchanged. -## Installation behavior +## First-run configuration -Users select dialogue, scene, and actor capabilities independently. The -installer always adds the `frontend` extra and uses a single sorted extra set. +Users select dialogue, scene, and actor capabilities independently. Interfaces +are selected separately: the browser interface adds the `frontend` extra only +when selected. Model preparation can be deferred, and a native folder picker +can select a model-cache directory before any model is downloaded. For the current beta, it first acquires only the exact VidXP package from TestPyPI with dependency resolution disabled. It then resolves that installed package's selected extras from production PyPI. This prevents TestPyPI from @@ -78,16 +96,27 @@ versions use production PyPI. Windows and Linux resolve CPU-only PyTorch wheels using uv's `--torch-backend cpu`; macOS uses native PyPI wheels. The custom PyTorch index is therefore a resolver input and is not embedded as a package URL, avoiding -the prior package-publication failure mode. +the prior package-publication failure mode. Every selected profile is also +constrained by `desktop/runtime-constraints.txt`, exported from the repository +lock for the complete local-worker and frontend dependency set. Capability +selection controls which packages are installed; the constraints prevent those +packages from drifting independently after the desktop binary is published. Optional model preparation invokes the shared `vidxp prepare` command for only the selected modalities. Setup subprocesses are owned by the Tauri supervisor; closing the app cancels the active process and stops a preparation worker before exit. No model is bundled in the installer. +After a runtime is configured, the Tauri supervisor starts hidden in the system +tray. A browser-enabled profile opens the local interface in the operating +system's default browser. **Open VidXP** reuses the already-running interface; +**Quit VidXP** runs the full supervised shutdown for the interface and +repository worker. Closing the configuration/status window hides it to the tray +instead of terminating a configured runtime. + The native NSIS, DMG, and AppImage packages themselves never install FFmpeg or -run a package manager. That consented action belongs to first-run setup, where -errors and retries are visible. `vidxp doctor` remains a read-only validation +run a package manager. That consented action belongs to first-run configuration, +where errors and retries are visible. `vidxp doctor` remains a read-only validation gate. Target FFmpeg binaries will not be bundled until their exact build provenance, enabled codecs, and redistribution licenses are recorded. This is an explicit packaging gate, not a silent download from an unaudited third @@ -98,9 +127,19 @@ party. The initial target matrix is: - Windows x86-64: per-user NSIS installer; -- macOS Apple Silicon: signed and notarized DMG; and -- Linux x86-64: AppImage built on the oldest supported glibc baseline. - -Updater integration, tray behavior, in-app playback, CUDA installers, and true -offline installers remain deferred until signing and binary provenance are in -place. +- macOS Apple Silicon: DMG; and +- Linux x86-64: AppImage. + +The release workflow builds all three from the stamped release commit and +attaches them to the matching GitHub release. Initial packages are unsigned; +Windows SmartScreen and macOS Gatekeeper may therefore require explicit user +confirmation. Signing and notarization improve that experience but do not +block publication. + +The Tauri shell itself uses the operating system webview. Selecting no browser +surface omits Streamlit and its Python dependencies, but does not turn the +Tauri executable into a non-webview application. Users who require no webview +at all should install a CLI/MCP package profile instead of the desktop package. + +Updater integration, richer tray controls, in-app playback, CUDA installers, +and true offline installers remain deferred. diff --git a/docs/releasing.md b/docs/releasing.md index a284268..37b876d 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -21,18 +21,19 @@ consuming them. The fragments remain in `changes/` for the stable release. 2. Confirm that every user-visible merged pull request has an accurate fragment. 3. Run the **Release (main → PyPI)** workflow from `main`. 4. Approve the `pypi` environment deployment when reviewer protection is enabled. -5. Confirm the new tag, GitHub release, PyPI package, and emptied pending - fragment set. +5. Confirm the new tag, GitHub release, PyPI package, GHCR images, desktop + installers, and emptied pending fragment set. The workflow only runs from a `main` dispatch. It creates the release commit, runs the full reusable CI workflow against that exact commit SHA, and allows no -registry publication unless that gate succeeds. PyPI and the local, control, -and worker containers are published from the same release commit; the GitHub -release is published only after both registry jobs succeed. Python Semantic -Release is the only version authority. Towncrier is the only release-note -renderer. During the stable build it receives the calculated version, renders -the GitHub release body, updates `CHANGELOG.md` with the same section, and -removes the released fragments before the release commit and tag are created. +registry publication unless that gate succeeds. PyPI, the local/control/worker +containers, and the Windows/macOS/Linux desktop installers are built from the +same release commit. The GitHub release is published only after both registry +jobs and all three desktop builds succeed. Python Semantic Release is the only +version authority. Towncrier is the only release-note renderer. During the +stable build it receives the calculated version, renders the GitHub release +body, updates `CHANGELOG.md` with the same section, and removes the released +fragments before the release commit and tag are created. Release and CI tools are declared once in `utils/build-requirements.txt`. Do not duplicate their versions in workflow files. Python Semantic Release stamps the @@ -40,3 +41,9 @@ Python, Cargo, npm, Tauri, and runtime-manifest source versions directly. The build helper only mirrors that already-stamped value into the generated npm and Cargo lockfiles, whose repeated version keys cannot be targeted safely by Semantic Release. + +Desktop builds use `npm ci`, Cargo `--locked`, the checksummed uv sidecar, and +`desktop/runtime-constraints.txt`. The constraints are exported from +`uv.lock` and verified in the desktop workflow before packaging. Prerelease and +stable installers are intentionally unsigned until signing credentials are +configured; they are still published with the corresponding GitHub release. diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 8185ead..75b12be 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -266,6 +266,11 @@ def test_desktop_manifest_matches_published_package_contract(self): / "tauri.conf.json" ).read_text(encoding="utf-8") ) + cargo = tomllib.loads( + ( + ROOT / "desktop" / "src-tauri" / "Cargo.toml" + ).read_text(encoding="utf-8") + ) self.assertEqual(manifest["package_name"], project["project"]["name"]) self.assertEqual( @@ -274,6 +279,22 @@ def test_desktop_manifest_matches_published_package_contract(self): ) self.assertEqual(package["version"], manifest["desktop_version"]) self.assertEqual(tauri["version"], manifest["desktop_version"]) + self.assertFalse(tauri["app"]["windows"][0]["visible"]) + self.assertEqual( + tauri["bundle"]["windows"]["nsis"]["installerHooks"], + "nsis-hooks.nsh", + ) + nsis_hooks = ( + ROOT / "desktop" / "src-tauri" / "nsis-hooks.nsh" + ).read_text(encoding="utf-8") + self.assertIn( + r"$LOCALAPPDATA\Programs\${PRODUCTNAME}", + nsis_hooks, + ) + self.assertIn( + "tray-icon", + cargo["dependencies"]["tauri"]["features"], + ) self.assertEqual( Version(manifest["desktop_version"]), Version(manifest["package_version"]), @@ -294,10 +315,13 @@ def test_desktop_manifest_matches_published_package_contract(self): ) for target in sidecars["targets"].values(): self.assertRegex(target["sha256"], r"^[a-f0-9]{64}$") - self.assertEqual(manifest["always_install_extras"], ["frontend"]) dynamic_extras = project["tool"]["setuptools"]["dynamic"][ "optional-dependencies" ] + self.assertEqual(set(manifest["surfaces"]), {"browser"}) + self.assertTrue(manifest["surfaces"]["browser"]["default"]) + for surface in manifest["surfaces"].values(): + self.assertIn(surface["extra"], dynamic_extras) for capability in manifest["capabilities"].values(): self.assertIn(capability["extra"], dynamic_extras) self.assertEqual( @@ -305,6 +329,16 @@ def test_desktop_manifest_matches_published_package_contract(self): "system", ) + def test_windows_release_binary_uses_the_gui_subsystem(self): + main = ( + ROOT / "desktop" / "src-tauri" / "src" / "main.rs" + ).read_text(encoding="utf-8") + + self.assertIn( + '#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]', + main, + ) + if __name__ == "__main__": unittest.main() From 8fbbeaa4ddf1dd52635464fb5a145461eb04dfeb Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 30 Jul 2026 18:13:27 +0500 Subject: [PATCH 40/46] fix(ci): reduce pull request fan-out --- .github/workflows/ci.yml | 8 ++---- .github/workflows/desktop.yml | 10 +++++-- .github/workflows/security.yml | 3 ++ desktop/src-tauri/src/lib.rs | 52 +++++++++++++++++----------------- tests/test_cli.py | 5 ++-- tests/test_media_catalog.py | 3 +- tests/test_models.py | 20 +++++++++---- 7 files changed, 58 insertions(+), 43 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00fbd49..f98867b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.11", "3.12", "3.13", "3.14"] + python-version: ${{ fromJSON(inputs.full && '["3.11","3.12","3.13","3.14"]' || '["3.14"]') }} steps: - uses: actions/checkout@v7 with: @@ -117,6 +117,7 @@ jobs: --extra frontend \ --extra benchmarks \ --extra server \ + --extra slm \ --extra test \ --no-dev @@ -213,10 +214,7 @@ jobs: strategy: fail-fast: false matrix: - os: - - ubuntu-24.04 - - windows-2025 - - macos-15 + os: ${{ fromJSON(inputs.full && '["ubuntu-24.04","windows-2025","macos-15"]' || '["windows-2025"]') }} steps: - uses: actions/checkout@v7 with: diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 57c199d..4200546 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -43,18 +43,22 @@ jobs: strategy: fail-fast: false matrix: + target: ${{ fromJSON(github.event_name == 'pull_request' && '["windows"]' || '["windows","macos","linux"]') }} include: - - label: Windows x86-64 NSIS + - target: windows + label: Windows x86-64 NSIS runner: windows-2025 sidecar_script: sidecar:windows bundle_glob: desktop/src-tauri/target/release/bundle/nsis/*-setup.exe artifact: vidxp-desktop-windows-x86_64 - - label: macOS Apple Silicon DMG + - target: macos + label: macOS Apple Silicon DMG runner: macos-15 sidecar_script: sidecar:unix bundle_glob: desktop/src-tauri/target/release/bundle/dmg/*.dmg artifact: vidxp-desktop-macos-aarch64 - - label: Linux x86-64 AppImage + - target: linux + label: Linux x86-64 AppImage runner: ubuntu-24.04 sidecar_script: sidecar:unix bundle_glob: desktop/src-tauri/target/release/bundle/appimage/*.AppImage diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 268772c..2b2fa6e 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -24,3 +24,6 @@ jobs: - uses: actions/dependency-review-action@v5 with: fail-on-severity: high + # No patched Chroma release exists yet. Chroma is kept on the + # deployment's private network and is never published directly. + allow-ghsas: GHSA-f4j7-r4q5-qw2c diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e997016..553e442 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -435,15 +435,15 @@ fn executable_candidates(name: &str) -> Vec { let mut directories = env::var_os("PATH") .map(|value| env::split_paths(&value).collect::>()) .unwrap_or_default(); - if cfg!(windows) - && let Some(local) = env::var_os("LOCALAPPDATA") - { - directories.push( - PathBuf::from(local) - .join("Microsoft") - .join("WinGet") - .join("Links"), - ); + if cfg!(windows) { + if let Some(local) = env::var_os("LOCALAPPDATA") { + directories.push( + PathBuf::from(local) + .join("Microsoft") + .join("WinGet") + .join("Links"), + ); + } } if cfg!(target_os = "macos") { directories.extend([ @@ -1404,20 +1404,20 @@ fn stop_worker(runtime: &Path, paths: &DesktopPaths) { fn shutdown(app: &AppHandle) { log::info!("Stopping active VidXP processes"); let state = app.state::(); - if let Ok(mut active_operation) = state.operation_process.lock() - && let Some(process) = active_operation.take() - { - let _ = process.kill(); + if let Ok(mut active_operation) = state.operation_process.lock() { + if let Some(process) = active_operation.take() { + let _ = process.kill(); + } } - if let Ok(mut active_process) = state.ui_process.lock() - && let Some(mut ui) = active_process.take() - { - let _ = ui.process.kill(); - match ui.process.wait_timeout(Duration::from_secs(5)) { - Ok(Some(_)) => {} - _ => { - let _ = ui.process.kill(); - let _ = ui.process.wait_timeout(Duration::from_secs(1)); + if let Ok(mut active_process) = state.ui_process.lock() { + if let Some(mut ui) = active_process.take() { + let _ = ui.process.kill(); + match ui.process.wait_timeout(Duration::from_secs(5)) { + Ok(Some(_)) => {} + _ => { + let _ = ui.process.kill(); + let _ = ui.process.wait_timeout(Duration::from_secs(1)); + } } } } @@ -1425,10 +1425,10 @@ fn shutdown(app: &AppHandle) { log::warn!("Could not resolve desktop paths during shutdown"); return; }; - if let Ok(mut operation_worker) = state.operation_worker_runtime.lock() - && let Some(runtime) = operation_worker.take() - { - stop_worker(&runtime, &paths); + if let Ok(mut operation_worker) = state.operation_worker_runtime.lock() { + if let Some(runtime) = operation_worker.take() { + stop_worker(&runtime, &paths); + } } let Ok(active) = active_runtime(&paths) else { log::info!("No active VidXP runtime needs worker shutdown"); diff --git a/tests/test_cli.py b/tests/test_cli.py index 6e07e63..0f43ceb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -456,8 +456,9 @@ def test_search_help_explains_cross_media_default(self): result = self.invoke(["search", "--help"]) self.assertEqual(result.exit_code, 0, result.output) - self.assertIn("every media item in the active", result.output) - self.assertIn("index snapshot.", result.output) + normalized = " ".join(result.output.split()) + self.assertIn("every media item in the active", normalized) + self.assertIn("index snapshot.", normalized) def test_query_constructs_shared_command_and_emits_typed_answer(self): answer = QueryAnswer( diff --git a/tests/test_media_catalog.py b/tests/test_media_catalog.py index 0ed23ac..9718a1d 100644 --- a/tests/test_media_catalog.py +++ b/tests/test_media_catalog.py @@ -203,12 +203,13 @@ def test_destination_rejects_junction_parent(self): root = Path(directory) junction = root / "objects" / "junction" junction.mkdir(parents=True) - original = Path.is_junction + original = getattr(Path, "is_junction", lambda _item: False) with patch.object( Path, "is_junction", lambda item: item == junction or original(item), + create=True, ): with self.assertRaises(PermissionError): prepare_managed_destination( diff --git a/tests/test_models.py b/tests/test_models.py index 34d5abd..0d0a56d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -51,13 +51,17 @@ def runtime( root: str, *, allowed_specs=None, + allow_model_downloads: bool | None = None, ) -> ModelRuntime: + settings = { + "repository_root": root, + "model_cache": Path(root) / "models", + "runtime_backend": "cpu", + } + if allow_model_downloads is not None: + settings["allow_model_downloads"] = allow_model_downloads return ModelRuntime( - VidXPSettings( - repository_root=root, - model_cache=Path(root) / "models", - runtime_backend="cpu", - ), + VidXPSettings(**settings), allowed_specs=( create_capability_registry().model_specs() if allowed_specs is None @@ -145,7 +149,11 @@ def test_actor_download_uses_pinned_media_object_not_lfs_pointer(self): def test_normal_model_resolution_never_downloads_implicitly(self): with TemporaryDirectory() as directory: spec = replace(YUNET_MODEL, filename="missing.onnx") - runtime = self.runtime(directory, allowed_specs=(spec,)) + runtime = self.runtime( + directory, + allowed_specs=(spec,), + allow_model_downloads=True, + ) retrieve = Mock(side_effect=AssertionError("downloaded")) with ( patch.dict( From d901118a8f4707f461e37422dc6f0242352ed3cc Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 30 Jul 2026 18:14:26 +0500 Subject: [PATCH 41/46] fix(ci): restrict desktop pull request target --- .github/workflows/desktop.yml | 29 +++++------------------------ 1 file changed, 5 insertions(+), 24 deletions(-) diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 4200546..50abdef 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -38,31 +38,12 @@ permissions: jobs: build: - name: ${{ matrix.label }} - runs-on: ${{ matrix.runner }} + name: ${{ matrix.target == 'windows' && 'Windows x86-64 NSIS' || matrix.target == 'macos' && 'macOS Apple Silicon DMG' || 'Linux x86-64 AppImage' }} + runs-on: ${{ matrix.target == 'windows' && 'windows-2025' || matrix.target == 'macos' && 'macos-15' || 'ubuntu-24.04' }} strategy: fail-fast: false matrix: target: ${{ fromJSON(github.event_name == 'pull_request' && '["windows"]' || '["windows","macos","linux"]') }} - include: - - target: windows - label: Windows x86-64 NSIS - runner: windows-2025 - sidecar_script: sidecar:windows - bundle_glob: desktop/src-tauri/target/release/bundle/nsis/*-setup.exe - artifact: vidxp-desktop-windows-x86_64 - - target: macos - label: macOS Apple Silicon DMG - runner: macos-15 - sidecar_script: sidecar:unix - bundle_glob: desktop/src-tauri/target/release/bundle/dmg/*.dmg - artifact: vidxp-desktop-macos-aarch64 - - target: linux - label: Linux x86-64 AppImage - runner: ubuntu-24.04 - sidecar_script: sidecar:unix - bundle_glob: desktop/src-tauri/target/release/bundle/appimage/*.AppImage - artifact: vidxp-desktop-linux-x86_64 steps: - uses: actions/checkout@v7 @@ -116,7 +97,7 @@ jobs: desktop/runtime-constraints.txt "$generated" - name: Fetch the pinned uv sidecar - run: npm run ${{ matrix.sidecar_script }} + run: npm run ${{ matrix.target == 'windows' && 'sidecar:windows' || 'sidecar:unix' }} working-directory: desktop - name: Test the locked desktop crate @@ -148,8 +129,8 @@ jobs: - name: Preserve the desktop installer uses: actions/upload-artifact@v7 with: - name: ${{ matrix.artifact }} - path: ${{ matrix.bundle_glob }} + name: ${{ matrix.target == 'windows' && 'vidxp-desktop-windows-x86_64' || matrix.target == 'macos' && 'vidxp-desktop-macos-aarch64' || 'vidxp-desktop-linux-x86_64' }} + path: ${{ matrix.target == 'windows' && 'desktop/src-tauri/target/release/bundle/nsis/*-setup.exe' || matrix.target == 'macos' && 'desktop/src-tauri/target/release/bundle/dmg/*.dmg' || 'desktop/src-tauri/target/release/bundle/appimage/*.AppImage' }} if-no-files-found: error compression-level: 0 retention-days: 14 From c901c471e61aea0d8377117b716c29f3e340cbe5 Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 30 Jul 2026 18:18:12 +0500 Subject: [PATCH 42/46] fix(test): normalize styled CLI help --- tests/test_cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 0f43ceb..bf1365c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,6 +6,7 @@ from tempfile import TemporaryDirectory from unittest.mock import Mock, patch +from click import unstyle from typer.testing import CliRunner from vidxp import cli @@ -456,7 +457,7 @@ def test_search_help_explains_cross_media_default(self): result = self.invoke(["search", "--help"]) self.assertEqual(result.exit_code, 0, result.output) - normalized = " ".join(result.output.split()) + normalized = " ".join(unstyle(result.output).split()) self.assertIn("every media item in the active", normalized) self.assertIn("index snapshot.", normalized) From 5cf1ac7f08c5cc2e8f6f05420dbbe623213d3846 Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 30 Jul 2026 18:41:15 +0500 Subject: [PATCH 43/46] fix(desktop): repair managed runtime setup --- .gitattributes | 1 + desktop/src-tauri/src/lib.rs | 72 ++++++++++++++++++++++++++++++------ tests/test_cli.py | 8 ++-- 3 files changed, 66 insertions(+), 15 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..64067ec --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +desktop/runtime-constraints.txt text eol=lf diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 553e442..c831e8d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ use std::{ + borrow::Cow, collections::{BTreeMap, BTreeSet}, env, fs, io::{self, Write}, @@ -182,7 +183,7 @@ impl Drop for OperationGuard<'_> { fn manifest() -> Result { let manifest: RuntimeManifest = serde_json::from_slice(RUNTIME_MANIFEST_BYTES) .map_err(|error| format!("The embedded runtime manifest is invalid: {error}"))?; - let actual = hex::encode(Sha256::digest(RUNTIME_CONSTRAINTS_BYTES)); + let actual = hex::encode(Sha256::digest(normalized_runtime_constraints().as_ref())); if actual != manifest.dependency_constraints_sha256 { return Err(format!( "The embedded runtime constraints have digest {actual}; expected {}.", @@ -192,6 +193,29 @@ fn manifest() -> Result { Ok(manifest) } +fn normalize_line_endings(bytes: &[u8]) -> Cow<'_, [u8]> { + if !bytes.windows(2).any(|pair| pair == b"\r\n") { + return Cow::Borrowed(bytes); + } + + let mut normalized = Vec::with_capacity(bytes.len()); + let mut offset = 0; + while offset < bytes.len() { + if bytes[offset..].starts_with(b"\r\n") { + normalized.push(b'\n'); + offset += 2; + } else { + normalized.push(bytes[offset]); + offset += 1; + } + } + Cow::Owned(normalized) +} + +fn normalized_runtime_constraints() -> Cow<'static, [u8]> { + normalize_line_endings(RUNTIME_CONSTRAINTS_BYTES) +} + fn manifest_digest() -> String { hex::encode(Sha256::digest(RUNTIME_MANIFEST_BYTES)) } @@ -1061,14 +1085,10 @@ async fn install_runtime( let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|error| format!("The system clock is invalid: {error}"))? - .as_secs(); + .as_nanos(); let staging_name = format!(".staging-{profile_hash}-{timestamp}-{}", std::process::id()); let staging = paths.runtimes.join(&staging_name); - fs::create_dir(&staging) - .map_err(|error| format!("Could not create the staged runtime: {error}"))?; let constraints = staging.join("runtime-constraints.txt"); - fs::write(&constraints, RUNTIME_CONSTRAINTS_BYTES) - .map_err(|error| format!("Could not write runtime constraints: {error}"))?; let install_result = async { uv_output( @@ -1087,6 +1107,9 @@ async fn install_runtime( ) .await?; + fs::write(&constraints, normalized_runtime_constraints().as_ref()) + .map_err(|error| format!("Could not write runtime constraints: {error}"))?; + uv_output( &app, &state, @@ -1173,10 +1196,20 @@ async fn install_runtime( } .await; if let Err(error) = install_result { - return Err(format!( - "{error}. The previous active runtime was not changed; staged files remain at {} for diagnosis.", - staging.display() - )); + let cleanup_error = if staging.exists() { + fs::remove_dir_all(&staging).err() + } else { + None + }; + return Err(match cleanup_error { + Some(cleanup_error) => format!( + "{error}. The previous active runtime was not changed. VidXP could not remove the failed staged runtime at {}: {cleanup_error}", + staging.display() + ), + None => format!( + "{error}. The previous active runtime was not changed, and the failed staged runtime was removed." + ), + }); } let profile = format!("{profile_hash}-{timestamp}"); @@ -1509,8 +1542,9 @@ mod tests { use super::{ base_package_specification, capability_command_arguments, dependency_installation_arguments, desktop_paths_from_roots, display_command, manifest, - package_acquisition_arguments, package_index, package_specification, - required_encoder_missing, selected_capabilities, selected_surfaces, + normalize_line_endings, normalized_runtime_constraints, package_acquisition_arguments, + package_index, package_specification, required_encoder_missing, selected_capabilities, + selected_surfaces, }; use std::path::{Path, PathBuf}; @@ -1552,6 +1586,20 @@ mod tests { assert!(selected_capabilities(&manifest, &["other".into()]).is_err()); } + #[test] + fn runtime_constraints_digest_is_independent_of_checkout_line_endings() { + let canonical = normalized_runtime_constraints(); + let windows = canonical + .split(|byte| *byte == b'\n') + .collect::>() + .join(&b"\r\n"[..]); + + assert_eq!( + normalize_line_endings(&windows).as_ref(), + canonical.as_ref() + ); + } + #[test] fn package_specification_has_one_sorted_extra_set() { let manifest = manifest().expect("manifest"); diff --git a/tests/test_cli.py b/tests/test_cli.py index bf1365c..b3b4466 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -457,9 +457,11 @@ def test_search_help_explains_cross_media_default(self): result = self.invoke(["search", "--help"]) self.assertEqual(result.exit_code, 0, result.output) - normalized = " ".join(unstyle(result.output).split()) - self.assertIn("every media item in the active", normalized) - self.assertIn("index snapshot.", normalized) + normalized = " ".join(unstyle(result.output).replace("│", " ").split()) + self.assertIn( + "Omit to rank matches across every media item in the active index snapshot.", + normalized, + ) def test_query_constructs_shared_command_and_emits_typed_answer(self): answer = QueryAnswer( From 4eccbcdaab42b37621b4f8d79873c2c7fc7cfe48 Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 30 Jul 2026 19:00:13 +0500 Subject: [PATCH 44/46] ci: cache desktop builds and remove duplicate work --- .github/workflows/ci.yml | 19 +++++++++++++-- .github/workflows/desktop.yml | 35 ++++++++++++++++++--------- .github/workflows/release-to-pypi.yml | 1 + 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f98867b..626aad5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,11 @@ on: required: false default: false type: boolean + skip_containers: + description: Skip container validation when a downstream release job builds and smokes the same images. + required: false + default: false + type: boolean concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -45,13 +50,18 @@ jobs: env: BASE_SHA: ${{ github.event.pull_request.base.sha }} FORCE_FULL: ${{ inputs.full || false }} + SKIP_CONTAINERS: ${{ inputs.skip_containers || false }} GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | if [[ "$FORCE_FULL" == "true" ]]; then { echo "run_suite=true" - echo "run_container=true" + if [[ "$SKIP_CONTAINERS" == "true" ]]; then + echo "run_container=false" + else + echo "run_container=true" + fi echo "require_changelog=false" echo "needs_python=true" } >> "$GITHUB_OUTPUT" @@ -157,7 +167,12 @@ jobs: [".wheel-smoke/bin/vidxp", "--help"], text=True, ) - assert "benchmark" not in help_text + assert "benchmark" in help_text + benchmark_help = check_output( + [".wheel-smoke/bin/vidxp", "benchmark", "--help"], + text=True, + ) + assert "official benchmark adapters" in benchmark_help for module in ( "chromadb", "cv2", diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 50abdef..94c0b6f 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -22,20 +22,14 @@ on: - "pyproject.toml" - "uv.lock" - ".github/workflows/desktop.yml" - push: - branches: - - main - paths: - - "desktop/**" - - "src/vidxp/requirements/**" - - "src/vidxp/capabilities/*/requirements.txt" - - "pyproject.toml" - - "uv.lock" - - ".github/workflows/desktop.yml" permissions: contents: read +concurrency: + group: desktop-${{ github.event.pull_request.number || inputs.checkout_ref || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: build: name: ${{ matrix.target == 'windows' && 'Windows x86-64 NSIS' || matrix.target == 'macos' && 'macOS Apple Silicon DMG' || 'Linux x86-64 AppImage' }} @@ -76,6 +70,17 @@ jobs: - uses: dtolnay/rust-toolchain@1.85.0 + - name: Restore Rust build cache + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + desktop/src-tauri/target + key: desktop-rust-${{ runner.os }}-${{ runner.arch }}-1.85.0-${{ hashFiles('desktop/src-tauri/Cargo.lock') }} + restore-keys: | + desktop-rust-${{ runner.os }}-${{ runner.arch }}-1.85.0- + - name: Install locked desktop tooling run: npm ci working-directory: desktop @@ -96,12 +101,20 @@ jobs: git diff --no-index --ignore-matching-lines='^#' -- \ desktop/runtime-constraints.txt "$generated" + - name: Restore the pinned uv sidecar + id: uv-sidecar-cache + uses: actions/cache@v5 + with: + path: desktop/src-tauri/binaries + key: desktop-uv-sidecar-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('desktop/sidecars.json') }} + - name: Fetch the pinned uv sidecar + if: steps.uv-sidecar-cache.outputs.cache-hit != 'true' run: npm run ${{ matrix.target == 'windows' && 'sidecar:windows' || 'sidecar:unix' }} working-directory: desktop - name: Test the locked desktop crate - run: cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml + run: cargo test --release --locked --manifest-path desktop/src-tauri/Cargo.toml - name: Build the unsigned desktop installer run: npm run desktop:build diff --git a/.github/workflows/release-to-pypi.yml b/.github/workflows/release-to-pypi.yml index 7c9575d..d5ba482 100644 --- a/.github/workflows/release-to-pypi.yml +++ b/.github/workflows/release-to-pypi.yml @@ -78,6 +78,7 @@ jobs: with: checkout_ref: ${{ needs.release.outputs.commit_sha }} full: true + skip_containers: true publish-pypi: if: needs.release.outputs.released == 'true' From 0409c5a1f2fed85bede6c177fdf6dcc6d34fb022 Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 30 Jul 2026 19:08:04 +0500 Subject: [PATCH 45/46] fix(ci): align model-free container smoke --- .github/workflows/ci.yml | 2 +- .github/workflows/release-to-pypi.yml | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 626aad5..bf7f460 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -202,7 +202,7 @@ jobs: trap 'docker rm --force vidxp-ci >/dev/null 2>&1 || true' EXIT docker exec vidxp-ci vidxp --version - docker exec vidxp-ci vidxp doctor --json + docker exec vidxp-ci vidxp init --json docker exec vidxp-ci python -c \ 'import chromadb, cv2, faster_whisper, huggingface_hub, pooch, psutil, sentence_transformers, torch, transformers; assert psutil.virtual_memory().available > 0' docker exec vidxp-ci python -c \ diff --git a/.github/workflows/release-to-pypi.yml b/.github/workflows/release-to-pypi.yml index d5ba482..ff53181 100644 --- a/.github/workflows/release-to-pypi.yml +++ b/.github/workflows/release-to-pypi.yml @@ -178,7 +178,7 @@ jobs: trap 'docker rm --force vidxp-release >/dev/null 2>&1 || true' EXIT docker exec vidxp-release vidxp --version - docker exec vidxp-release vidxp doctor --json + docker exec vidxp-release vidxp init --json docker exec vidxp-release python -c \ 'import chromadb, cv2, faster_whisper, huggingface_hub, pooch, psutil, sentence_transformers, torch, transformers; assert psutil.virtual_memory().available > 0' docker exec vidxp-release python -c \ @@ -210,7 +210,9 @@ jobs: worker="ghcr.io/${{ github.repository }}:${{ needs.release.outputs.version }}-worker" docker run --rm "$control" vidxp-api --help docker run --rm "$control" vidxp-mcp --help - docker run --rm "$worker" vidxp doctor --json + docker run --rm "$worker" vidxp init --json + docker run --rm "$worker" python -c \ + 'import chromadb, cv2, faster_whisper, huggingface_hub, pooch, psutil, sentence_transformers, torch, transformers; assert psutil.virtual_memory().available > 0' - name: Log in to GitHub Container Registry uses: docker/login-action@v4 From 3db529ac48f0f63e85369091bda9ebc4d679e988 Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 30 Jul 2026 19:23:01 +0500 Subject: [PATCH 46/46] docs: refocus README on the product --- README.md | 256 +++++++++++++++++++++++++----------------------------- 1 file changed, 116 insertions(+), 140 deletions(-) diff --git a/README.md b/README.md index e48e8b2..02116d8 100644 --- a/README.md +++ b/README.md @@ -28,215 +28,191 @@ ## Find the moment, not the timestamp -VidXP turns one video or a whole collection into a persistent, searchable -library. Search by meaning instead of filenames, tags, or exact words: +VidXP makes one video—or an entire collection—searchable by meaning: -- **Dialogue:** find what was said from timestamped transcripts. -- **Scenes:** describe what appeared on screen and retrieve matching moments. -- **Actors:** group recurring faces within a video and export a highlighted - overlay for a selected group. +- **Dialogue search:** type what you remember someone saying and jump to the + matching moments. +- **Scene search:** describe what appeared on screen and find the closest + visual matches. +- **Actor matching:** find recurring faces within a video and export a + highlighted video for a selected group. -Use it to find relatives across years of wedding videos, add semantic video -search to an editing application, or give an AI agent a grounded way to -understand a video library. - -Processing and search run locally after the selected models have been -downloaded. +Use it to search years of family videos, add video search to an editing +workflow, or let an AI agent answer questions using evidence from your own +video library. Your videos can stay on your machine. [![VidXP browser interface](./docs/images/video-screenshot.jpeg)](https://www.linkedin.com/feed/update/urn:li:activity:7343569473720725505/) -## Install and run VidXP +## Start here + +Choose the setup that fits how you want to use VidXP. -### 1. Native CLI and MCP +### 1. CLI and MCP -For scripts, local agent tools, and direct control over indexing and search, -install [uv](https://docs.astral.sh/uv/getting-started/installation/), then: +For direct use, scripts, and local AI agents, install +[uv](https://docs.astral.sh/uv/getting-started/installation/), then run: ```bash -# Install isolated CPU runtime +# Install the CPU edition uv tool install --python 3.14 --torch-backend cpu "vidxp[local-worker,mcp]" -# Check required system tools +# Set up FFmpeg vidxp init -# Review and download models +# Download search models vidxp prepare -# Verify the installation +# Check everything vidxp doctor -# Print MCP client config +# Connect an MCP client vidxp mcp-config ``` -The CLI works without MCP. To include the local browser app as well, install -`vidxp[local-worker,mcp,frontend]` and run `vidxp ui`. +`uv` installs and manages Python for VidXP. `vidxp init` checks FFmpeg and +shows an operating-system install command if it is missing. -If `vidxp` is not immediately available after installation, run -`uv tool update-shell` once and reopen the terminal. - -### 2. Desktop app +The CLI works without MCP. Add the browser app with: -For a managed local installation, download VidXP from -[GitHub Releases](https://github.com/grayhatdevelopers/vidxp/releases). The -desktop app owns its Python environment, CPU dependencies, model preparation, -and local worker; the user does not need to install Python or uv. +```bash +uv tool install --python 3.14 --torch-backend cpu \ + "vidxp[local-worker,mcp,frontend]" +vidxp ui +``` -Installers for Windows x86-64, Apple Silicon macOS, and Linux x86-64 are -published with each release. On first launch, VidXP checks the machine and -configures only the capabilities and interfaces selected by the user. The -browser interface and its Python dependencies can be omitted for a -processing-only runtime. +If the `vidxp` command is not found, run `uv tool update-shell` once and reopen +the terminal. -After configuration, the desktop supervisor runs in the system tray. -**Open VidXP** opens or reuses the local browser interface, and **Quit VidXP** -stops its owned processes. See [Desktop application](docs/desktop.md) for -behavior and source builds. +### 2. Desktop app -### 3. Docker for servers +Download the installer for Windows, Apple Silicon macOS, or Linux from +[GitHub Releases](https://github.com/grayhatdevelopers/vidxp/releases). -Stable releases publish three Linux/amd64 images to the -[VidXP GitHub Container Registry](https://github.com/grayhatdevelopers/vidxp/pkgs/container/vidxp): +The desktop installer manages Python, VidXP, and the local worker for you. On +first launch, choose the search capabilities you want, where model files should +live, and whether to download them immediately. Python and uv do not need to be +installed separately. -| Image tag | Purpose | -|---|---| -| `ghcr.io/grayhatdevelopers/vidxp:` | All-in-one CPU worker and browser app | -| `ghcr.io/grayhatdevelopers/vidxp:-control` | HTTP API, remote MCP, migrations, and upload hooks | -| `ghcr.io/grayhatdevelopers/vidxp:-worker` | CPU model worker for the server deployment | +After setup, VidXP can stay available from the system tray. The browser +interface is optional and can be left out of the installed VidXP runtime. -The supported [Coolify deployment](docs/deployment/coolify.md) uses the -published `control` and `worker` images with `compose.coolify.yaml`; it does not -build VidXP from a repository checkout. The deployment includes PostgreSQL, -Chroma, resumable uploads, persistent media and model volumes, and static -bearer or OIDC authentication. +### 3. Docker for a server -Use immutable release tags or image digests for a server. The all-in-one image -is available when one machine only needs the local browser product: +Run the published all-in-one image on a home server or another single machine: ```bash docker run --rm --init \ -p 8501:8501 \ -v vidxp-data:/var/lib/vidxp \ - ghcr.io/grayhatdevelopers/vidxp: + ghcr.io/grayhatdevelopers/vidxp:latest ``` -## Use VidXP your way - -| Surface | What it is for | -|---|---| -| Browser app | Import videos, prepare models, index, search, inspect progress, and download results | -| Desktop app | A managed local runtime, optional browser interface, and worker | -| CLI | Scriptable media, indexing, search, job, repository, and artifact workflows | -| Python API | Embed selected indexing and retrieval capabilities in another application | -| HTTP API | Build applications on a versioned service contract | -| MCP | Let local or remote agents discover media, index it, search it, and create clips | -| Containers | Run the local browser product or a separated API/worker deployment | - -All of these surfaces use the same application layer and repository contracts; -they are not separate implementations. - -## What is available now - -- Persistent libraries containing one video or many. -- Semantic dialogue search with timestamped evidence. -- Text-to-scene retrieval across indexed videos. -- Within-video face grouping and highlighted actor overlays. -- Cross-video top-k search with optional single-video filtering. -- Durable jobs with progress, cancellation, recovery, and retained results. -- Atomic index snapshots, so a failed rebuild does not replace a working one. -- Managed media, downloadable clips, overlays, and artifact metadata. -- Named repositories for keeping collections separate. -- Browser, CLI, Python, HTTP, MCP, desktop, and container interfaces. -- Local CPU execution and a separated self-hosted server topology. +For a long-lived server, pin a published version instead of `latest`. For a +Coolify deployment, use the published `-control` and `-worker` images with +[`compose.coolify.yaml`](compose.coolify.yaml)—no repository build is required. +See the [Coolify guide](docs/deployment/coolify.md) for the complete setup. + +## What you can do today + +- Build a reusable search library from one video or a whole collection. +- Search dialogue by meaning, even when you do not remember the exact words. +- Find visual moments by describing the scene you are looking for. +- Group recurring faces in a video and render a highlighted actor overlay. +- Search one selected video or every video in the active library. +- Open matching timestamps and export downloadable clips and overlays. +- Keep personal, client, or project libraries separate. +- Follow long indexing jobs, cancel them, and keep the last working index if a + later run fails. +- Use the browser app, automate the CLI, connect an MCP agent, or integrate + VidXP into another application. ## A first search -The browser app provides the guided workflow. The equivalent CLI flow is: +The browser app guides you through importing and indexing. The same flow from +the command line is: ```bash +# Add a video vidxp media import samplevideo.mp4 --json + +# Index the returned media ID vidxp index create + +# Find a visual moment vidxp search scene "a yellow taxi on a city street" + +# Find something that was said vidxp search dialogue "the bread just came out of the oven" ``` -Searches return ranked moments with video identity, timestamps, scores, and -capability-specific evidence. Use `--media-id` to stay within one video or omit -it to search the active collection. +Results include the source video, timestamps, match score, and the evidence +used to find the moment. Add `--media-id ` to search only one video. -Run `vidxp --help` or `vidxp --help` for the complete CLI reference. +Run `vidxp --help` or `vidxp --help` for the full command reference. -## For applications and agents +## For applications and AI agents -Applications can embed individual capabilities through the Python package or -run VidXP as a local or authenticated remote service. Agents can connect over -stdio MCP on the same machine or Streamable HTTP MCP on a server. +Use the Python package to add selected VidXP capabilities directly to an +application, or use the HTTP API when VidXP runs as a service. -The MCP surface can register or discover media, create indexes, search dialogue, -scenes, and actors, ask grounded questions, create clips and overlays, poll -durable jobs, and retrieve artifact download links. +MCP clients can add and discover videos, start indexing, search dialogue and +scenes, ask questions about a library, and create clips or actor overlays. +Local agents can connect over stdio; remote agents can connect to a +self-hosted VidXP server. -- [Python installation and capability extras](INSTALLATION_GUIDE.md#optional-dependency-extras) -- [HTTP and MCP installation profiles](INSTALLATION_GUIDE.md) -- [OpenAPI and MCP transport behavior](docs/architecture/platform.md) -- [Coolify and server deployment](docs/deployment/coolify.md) +- [Python, HTTP, and MCP installation](INSTALLATION_GUIDE.md) +- [Optional capability packages](INSTALLATION_GUIDE.md#optional-dependency-extras) +- [Coolify server setup](docs/deployment/coolify.md) -## Models and local data +## Downloads and storage -Model weights are not hidden inside the Python package or container image. The -browser app and `vidxp prepare` disclose what is missing, the download size, -the cache location, and available disk space before downloading. +First setup downloads only the models needed for the capabilities you select. +VidXP shows the download size and destination before it starts. -| Capability | Models | Approximate download | -|---|---|---:| -| Dialogue | Qwen3 Embedding 0.6B + faster-whisper large-v3-turbo | 2.64 GiB | -| Scene | SigLIP2 base patch16-224 | 1.43 GiB | -| Actor | OpenCV Zoo YuNet + SFace | 37 MiB | +| Capability | Approximate model download | +|---|---:| +| Dialogue search | 2.64 GiB | +| Scene search | 1.43 GiB | +| Actor matching | 37 MiB | -Actual installed and cached disk use is higher because the Python runtime, -PyTorch, provider packages, indexes, source media, and generated artifacts are -separate from model weights. +Leave additional space for the VidXP runtime, indexes, source videos, and +exported results. -Local data lives in the operating system's per-user VidXP directory, not in -the shell's current directory: +By default, the CLI and desktop app share the same VidXP data directory: -| Platform | Default data root | +| Platform | Default location | |---|---| | Windows | `%LOCALAPPDATA%\VidXP` | | macOS | `~/Library/Application Support/VidXP` | | Linux | `${XDG_DATA_HOME:-~/.local/share}/VidXP` | -CLI and desktop installations share this data root by default. The desktop -keeps its managed Python environment and launcher state in a separate private -application-data directory. - -Docker stores the same product data in the declared `vidxp-data` volume. +Docker keeps the same data in the `vidxp-data` volume shown above. -## What is next +## Product roadmap -The current product work is focused on: +The next product improvements are focused on: -- signing and notarizing downloadable desktop packages; -- improving desktop runtime repair and configuration controls; -- evaluating supported GPU worker and desktop profiles; -- richer result playback, previews, timelines, and actor-labeling workflows; -- stronger collection organization and search filtering; -- benchmark-backed retrieval and ranking improvements; and -- scaling the server topology beyond its current single-node deployment. +- labeling actor groups and matching the same person across different videos; +- more reliable face tracking across angle, lighting, motion, and occlusion; +- connecting visible people with the dialogue they are speaking; +- better search ranking, time ranges, and natural-language questions across a + whole library; +- richer previews, timelines, filters, saved searches, and result playback; +- easier organization for large personal and project video collections; +- faster indexing and supported GPU acceleration; and +- smoother desktop updates, repair, and model management. -VidXP is beta software. Current boundaries are documented rather than hidden: -CPU is the supported runtime today, actor matching groups appearances within a -video rather than identifying real people, and the published server topology -does not yet claim multi-replica failover. +VidXP is in beta. Feedback about search quality, actor workflows, and real +video-library use cases is especially useful. -## Documentation +## Help and project links -| Use or deploy VidXP | Build or evaluate VidXP | -|---|---| -| [Installation guide](INSTALLATION_GUIDE.md) | [Contribution guide](docs/CONTRIBUTING.md) | -| [Desktop application](docs/desktop.md) | [Adding a capability](docs/adding-a-capability.md) | -| [Coolify deployment](docs/deployment/coolify.md) | [Architecture](docs/architecture/platform.md) | -| [Changelog](CHANGELOG.md) | [Benchmarking](docs/benchmarking/README.md) | +- [Installation and troubleshooting](INSTALLATION_GUIDE.md) +- [Desktop application](docs/desktop.md) +- [Coolify deployment](docs/deployment/coolify.md) +- [Changelog](CHANGELOG.md) +- [Issue tracker](https://github.com/grayhatdevelopers/vidxp/issues) +- [MIT license](LICENSE) ## Contributing

    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 z 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%0DuLJu6c zMpp)xUUHdg(ARwA??CJAK5ObC$DQh{^tQ)@Ac^}%@y8eBkU%ZK zl|Z8dB|+d4Sos^|D)!m2oUYR3r}XrcP)J+qb>m=Klu)X*v#6VizaIUse zbt|N(PEG3`_NOUV+o}5~^#NV+UxsJ}QWVhfaXIUCDp+5vFLu1KAo&kzZ__(v`?Esv zkKTXI_9onx_S824ww{Z4Y#m`)-p=(GsQV2??FBc8XeSH-xq9l9U6`u!!-+jVF*P{2 z861F#m}Jw;hwS&X3`ZIZl_}B@*CxhQ_73DfwcQR7O~AEM+~5`mO*RO5*U6NLV!Ip2 zcZJV08UaMVe&;v7tA!BLd&gA2R4?$b#0>=}vA5;HYIe;N=70p~Nr%R ztv)w=MlwwcbEW8)T(Gj0LTcVGYd*WIV^1dw9;ffO4M{WviCN2{jpltKZ)r1v#R zv_tGJCTm>q@vr~scYjyc0mmhDs6!jptB!mzu?(az9Mi_0yJa15olK~oyA!&T|3Z`P zu&TggYXOwof(Z}eo(rsWPomrOTK!EaqBvj_b9l^SvWHX-fiq|#zvnWDfcv&ek5%M1V@_OMpqbY*BAJ#2+JTU)nx#d>f%>!Y$ zNW2-?@9}AbY}s)0uE-8I1!3_y;S!*s#ODI5i+kiV2DJ!01WJAn3Bno3E>v3q zq$nYm3Z|uzH?zjZBttu1m@6JREzP}M{PVRk^L%1cDcQUv&U#kRqLj5nwx~jpvbE znjdNIeXvn-sp3J+W7nsd9QyO}{-1R9-}w`~9;rNkXd*8`ihq zfkf+hX#(C+s1{bgh|>jp?z}GEMW~fR=FrK@Z6JdKBJi`veyYlVOD>g*yiDP+qQ!Kb zxVsfDLcuHu_YKM5l`@foaEF42Mgj!2!|zPSy7Yu5fU9RPmt6SEB*wskSgtVD*&1MJG8PVBamf65IGmQN~CZ6yHs+4vL#N1fW1C#ij11V zjZR1<{z5@NUM2c1O-O5sjAva7xLA-3B2w5Rws`i>E&djXi0h;vF3~PCJGQY``h?pc zbCZI@KA|Y#0Puvovgqz*^s6K1J2{ayWcLo(9VRj$gCVHrd13ZnE)Y1(Y(gPiWcQ6C zjZPd>Hi%**z~{i@Bf3Y6k>mX+Qbnf0hkiQq`0wXL#=HQ9u&L* z@Qo$_yL}WI-9Hb}h+6P2gMp7POqk};#4J2-VBzI*DEb|*!SBeUz#J;+_K7O4oy&B0 z8Ba2crT!4FDW+r~E||a(9E1l$E(3^|hh!xKhI|M|>ih&;A2nRN4p@j7d-(tO@e^X> z{EU91sRDWe1P4t28*jW>&6$7(sOO+B{ybs(t>&t03!VHSodl}zlEe|?JwcO&ECd7~ zfyaU8;q}|u3a%D4&P~L)TF4GZs2vfFy+~5ZgtL-enA7!DJJMP&`X&DamvNYut2+0! zCZaZU30YWAKKXM@0!-zCF_0KI15_mbjh#e0nSCJMExQZK=_NAEH4d2FN%i)l*owoX z{6eXwKb@l+q8zTcN;D-Y*&>kD(u zX!QeBu}rTo%hF`PS}qEwS6?o;^oH2^3F~RwKLX&HYp}V7=QrIYY*tziIUUI zsb;8OKnS?%X(!|G{ z;7Tx;tE2#y5)n(%&dPCeg;Ck$GLh{gD*x_0l*@%`q$= znb&s(Yi%w0jDZ~w*8gB8liCJ{i={&85%O?8P9BTccOR4c&QA9&YYGq$k-PA`1Sw&i zfyaj803kRJh$#*@6L>9ABnK}A?8GC30`1hj5YfB$ z>HCf1jNij!0x3b$VsT8Fv*bGJ8e_b-u!lyh@AJ<+qs1SSKRhPx^m0Ym5(8<${X3rE z4N-wI!xhA?GWlC}y(K0fJIe+)ea`j?#cg>4H8fyUv}lM`UR87762iuFB&(e7^yB|d z0B%5$zY+q+iGG$0F!Aw7QTp(+AsX0Eo7zZ#1$cIM=^FrX>H1jox+n3^-AOpk0B1H# zX^jGutq(ZLkjSKM&%_#x@1n%6fEaK^H{W6>$dPQN)ns?19X5J`k>fxm`j=f> z(dV(%UbaRk!~O$4L(!v;?7$*k9#1534RRqax*xXe`MAOCDV^@^+mXR?u91$YNNnUx z3#@y0nLO|Es{m-iF>xOhzxF-e>wgiia1!8z`iUG<_uY5hqmcVkPdzEoxUY%(+b8bZ zqV@mg0}m*wfGHD&DJ1{3%7>VVJr5!FDTvei(u@!qV~o9z z{z7>dfQ&!=2rU2C)PMi|_uti33i1lT#s}&UvKMsKLM6=#jT{$&rmU}Z9P3j;&;*y@ zl~-QU;(vwkEU@#3o8uJ%`vc+7m}$yC!Zq@0LDx=GssLxlV!L_10bD5Zvv=^LRbnAs z)p5Ci^St7p)(mR)OKInnRJSTw zt+EE6=H=NJ5o!?ib1@#;##wlkbHAzh&wPKGiY7zcWF2Um=wQMJX%6NuxRC!$ta)D& z8QyI|-a^>S-aUK0wtja=(#sO$&iVU*BOqdM_sH+y_HWCs`|-zrrBF2P4`@I~O6zw0 zvETn>q;K{P$NYkbHJmj6IURy3{+6b6EVE!9?1R^P8(}=pbLTc?5B`Nt#E{sb=%e{T zBY>3yk`H0O45mHy{Lu5GQ385`37mXxKzIKskD%-)&_(@X>IWQ;l?EOk?t5=a*wbsT zzN*fKXy#xdbdT)hAt%H%t~?7L8f01gjvfmZ$MSn{Z^YW?(_^0~j{#Cb$o|kk0$D&4 zd6i85cyB=LTtsrkRqAMkA2<>EaX#$6RXt;8hX$Lx0d~B)Jg?#UJePc2A_3+OxPhqG zTjLL*e0Ea3HJGQ9pk>mswOsI*i#7I6fItZsi_OrCjG%e;b1+pt4kZKRRH6~@JBTY? zz*s%PlP+N6iMAf^Iwr3Sh7@t<7q9cU!Huki=$Bh&loGD1u7iDj%D55zzh!1P@$;(6 z;48BPm(h+EKk+>1kB!~JJ0brBabM@0L<~g3J++& ztP3hzB&9+CUZd)R3goB(Dd;AVIGX_9D0D|<+D9>jh9B|3KsoT59u)E-Snz=P;bWjO z!;SUMUp{?&m8)=7e=K)EbvvcBv;( zdeig;85|`tAnj71F&T^}FkCX#KD3xC_e&;bD7SF(-=K zL&G*_6kU4f0HR-zvL%H=)E?rAdgGfEJ=2Hwr~Wp+L~5 zd|&Gy>9kylKHZhw}*S$2mZLkx%1+fai95Y&(J&87;TmL%fjxLL?(2A z#-EmO5edXKXwKm&0I3~z{~!jUsrdQNex_11G#jwmg>g_YxC!AuQ#QY`lLf6ZqVbq9 zPuZkHUhM$G9UR}f5(=FvACMh@$^LU$m%yU{1V8*Yeg-tAv>$Ip;Xh`=I{3||pH0iZZAWjZA2%`Jxx zsnqW(nb4PNlC-T#AbEBpR{Q{WQWBMn+hMf;NdE&dD|+dr7sNjJWtC(iNfOA>okIA7 zeA9VzqnLFcyKyGrTeGyyTyK;|pM4r%mkhLU3Kg}KLGccZBdcwfjYVd#@G6Ep$>Y;X z$+O9-v;e(Z$(tEB$8XuVeE%E@dMWT~-~=qAP_8uak&}irtp7SZfzQY%_tOm}{`&?% z5P;M23#SCu+TXv|%fz2B9Ju~)LVsBq?#q>XY@9ajuUwY%aXo9kV(S<8A~jNzB_X6z zH|uhX-AcM%V=c`IT|9Lk%w`;gZSk8Kp|UGlj+PBP>i*ti0Eb5qBpX+p@$s^4-N_{m z=Zw#ln|2h#*P6Q8u^P8vLItD$vrj*Bj~)9L-S1v5Iq(mO|38YD+5_jz`H@2&cOC-r z1!(xUfBXCHb|JjMau%>O^hZ`G^2FYTA{L7`5E~Kg zg3^KDWgrthiaX?PVDMw&2cZu+0(h=?-SGMxl1U%3#Y2K};CU@IDtT9Ba47mNA`On3 zZQ`Q1!=spjz^wJJ&6eCJnhtpRVN!;Me^?j5hvBIwpHM;})D+-|;BoE{MM2I>a|Gow>3+ zFV+;QwHjRch_Ow~XFC!!KSxIX{0$!eT$^*5t2UwB=v6S!2e`&1a7JbWRoVDe-|kHI zXBG2S4X}*{@Q7U3!SPNs;upzO_jPFqPD(=DGkVd#^kE@7Nf?zdfF42rWOFsMn>Tg2Dhw0}wXh zC2+UoG+cMx4c zxI?Xz(d6LyQSR24HTPurkMK&IA#N!?=IV$l?xpedh1?vQV8b!7X4cev?nWkRTt;Y_ zxhyrzwEU|Y2$dU!{@TQ`%r9-?$uQ2zHg+`JS7O3L3GMi_;1I`z^K!Ot0CqQbx1yZ)!4{%0bRYyM}q0FBj#=C6#2-v9#yMagz}&XKmkGs5DH6x>H~AVP)hG^}l}7w8|3aj(nH{KF4F)ZO)$U!K+^zRRz;T;Xd>)cd5Mfpj+52}jWM zhyXYgR*E8m6Lf$E)_ho3Y>Wz-Axd6y${ERoS5L*Qj72Bx34E0|Ye#-hYt()Hgz!HDnar zDkul!pOAavbz7(eaWO&!b5+6l7yq_m^=}dlejzOQ<`n;|37CieUC5`y?Ze9yKfdC% zK@|fjNUy)+?=9_P*^bYaGXh<%V ziNsCaHe%CmKY=NQ3Zq z{q;9`UO*uHtvSoej199gPOQkt)a@77b&Ukrk|ST98&cx$8Q4L?dce8DK6oN}mzzcf zcJ)~MRz!ORvLFw;{j{x%r~WaSkJ27I&u1_Vd%E99-b20TMYyAF-r{qQ{mElOG+EMm zr$MGIF$6v#zj;n>xsQmN`=P-ogj^ZcueCL~Y|r+yrsfO+ZUX@db(!r_JpQoK`QZyq z9{)vlVP~7i)OadzEwHCD`CC?AW(XrqoaAlApUW&o!yegQLbdERsqv#RmPHOkqMBM$ zjcf#dNAZJW+_q2ELy`X1pZ!$#*>{RxJqUjIw-zZM3Pp&P1yl`Q0etzv5xC}>YjvVU z4DNBr=tMIQNc-?RZz~dTsle)J1mNEf?|#7G(6vK*uLeVFU2x6Ps|rx$b#(yg;!X$W zV_%Mm7Lq#@{WBUCbWZ*}-LD#O+ip+Kx&}c)9`^VM5CVfA1s1lpa6ZK2JO4W2d&`p} zU63En@{Lc?LQM2nAK>N0{o?h&3IeYOvIBrjK=ub&A>8~REd(Ngrc94To-6s(LG#?R zas#CHL!F=~hP)QwHekK*;)~BK(Gl-8Bz|~2!2fuC5F5SY-&2ahlWN=+HXC2c)*p@X z*~$I4owP>{am{XS@gLZ2^Hhj#HAtQdk{3P?*_k9`olbk9-b z!Z~;V^;uWTu(A^Z!kMk@?W6d&|N3A5j}2`A)^>K!@gDc7eBw{bjjl)o2$JNDX{GMc!CjZ5hF`4OnDbrkdjrnC;3!@^9Hau$yA43HuU2=QXJv+ z|Ay$x*EZJF@ek~FC<9!hQ(ib9ao>=S;R}W2&O7fCSoy2&Gnv3Yl9j+wDR$rppa|i1 z>x<5)2n8AH0u*3O-v?O~pnb;sPNtygq0|x>dra1_mK4=(Sg0 zQE4B(VC8#A;C>MBprpWa$Me)(zZ**`OWLC0I=tvrDrlB~gkbISk}v*yMZEx~KOPUO z4&48i_xA5dg`9C;O6E2NKTfs-z?kdg&q?(Trv$>P({8J0zeN}>MEJ3F9hnmgJXN3@ zy!`W>{ge&2n+TXPYD3fz)Y4bE(x7x?k>J|FiTf_~#1c)^bo9CS{v7p^<27vncGh-I z%N+25+yt?}Hw67S&MzfT$pIO$y?#r=S!$YH8IA;ljBNCSTaW=4VARC3-)MGCgWd`Ddh zeaMeqAVNlg{2AQ&7K_gIS9t}}I9z`t;OqUm&d^9B^c zPzAW#Eq6PpKH(V{{s?4UtX(3p!&*gcc;!-tcTMx0E)@zkdS;%a0*_D2$_F}++$H9f*i3DWe}0Z~ zyDoobVJ6B4&~zv(ItMl;5)zdI1qR35;Q6zR`#H+94tfv3+~X$-TJYg z{HqdRcse1eC>i9I)j=r~X-*K(RFlP4QeLayW<&9xc$O{2-!&Bf(eIfTmg@3<_SeF* z)UgZ41bl^PydRQ+e~IjvfAZshmOb=aQj9lspMH-p&##h#gaR8NcvsL92V}0FcICDj z6cp*rF?9oqR{5Gd1}6AZr@vGN{s}3DxIH3uLD1WhT|V~`I_}AW_+1bWp~;5}A65r| z)%WYPe}%I3@mLyY{Xo16>j!la^qB#JjCVrH%L5{V$48_xyaRwT zJoEHZiU6P?ft>MPnf&1fiAHH_Yr9V;3|R&93S!&2+!p#X{&-sjw8>OxPTDV@KdW%c z#D~uly5~l8z|Bq)ACuXA{bwuSr%ZYsrdi@t;S?$N!@C4%Z5^^x`OAlLLYxS9CD(za zHUPi=^_ySbd+)vP$s}+*T)2v`isj=UOd)#5B93nzFIy-Qw2+yA)Cl`D_W@*iX!EF& zP43$$HkHVEY24NjFdJ!sTx9sVN=#`J{KQlGV0-OHz3zAdQJ@xfE0>8#o~RmsWF}8I z9v6R_L?CKS1q}`yZWW1O{nDl9lN2FLnIPgJIS_ku+|~QSK!@g4$J+50I@IFq)>+-@qu7H3@tzp%paA3ffoO<* zI}DY8T0qkeGa*L&aZqloE$UpE>^u+X%le{IR-pO#=;%jU{9)Dv?gh{Q1Qp#Q=+2Ed z-k?%Iygp;&AU1=x(uiJ8&4mi9Cn~u5H9_@NO10rFV%`T;&i5(8p*28KB;~y@u70iL zy}99XmHX*^`~%J(kn(;^m(CM^M+ER<5q>t_TMuSFsIkl8_FfLinbL_prqtQ{YYgzV z{Oq{g?o@&R{P@Q|UjP30zrXVrfAJR|@7up|7ALpYz4EY}7|pPh`}WyWZhi+m#l$k~ z=t~j5GnjbnW=hTY{ADo}HF!$XP6p;^c z`#d8+-T35tU8c}Axo@?<)v0$e$o@t!yC=8#K$-x#!_5wjLy$X`QaavO5umw9(WHODzUA{ z9}oy4nzrul1dDjh4s4?SvP^>!Kaj7FftKU)*rh#b0Jog&dm*4s(OWCpVqsr71tWcSNnug|t%fuf>2mpDzzS08-rnNrMyy z+h9b^{^A$^>K^~~uhl5$TaW&RDhF_z+=OK6@#5PAX`zdG1878|@nRCaVNO);(?cdwF$BUV1@)EACUY`fs`n>!fv?A zF=G`IKJ$a@vc02Yuk&{v^*9EWza#~|{dOlQ#LsULLnbtFAa{b3fhG6>Qk!SLZRAzp1hjYs#7zL( z=h0B_63R50Su%O4Va_96vRL5ibjg8WE2951^2e*v4ZIuvQPM_vT}(;zj>Mw#%NuUG z!gw<9;-;=XA>a|BaW}3IL)B zXqP9L`#dqV#*YzpJk)N|F^bLQRChb}j!y5NiwS;QBo4-}$!OhQVh(dTQlK zKWJt_Dw-`(d8*(7`7k*uww&Ed(k44qkNSvG1G8f88Sfy;fh>oZU%=4MJoB`|*QZba zllaBIE1LN`bc%%BPa|?=#~mj7aYI0(+;A>ww>_M0hvu-G#^jtalX6)tQ@H;)eK4#c zu!l!+#+1Fjw$ZcY;WGd?L0IXc@O~_CKmHp<9k1g-nY2;xuM@Z*j)G7;zjF5Z9Lu*KY@3T9MN^oC6`{}ZomC5)%ag49u06~ zM6%y8pe~HmwY9Lyg?cSiyQUWDIGbLhwgV@KuW)k;1=Ah;0h|J%c1dNnd%<14weF5&y zEk6o~^WWk<=}3D7Ku|JXf5ZRfPyXajmVfzIfAxX<@;d@q-Gyi^&ep@1`dag&B#YO}lm? z!*!agPK$HNR6M>K+p$H)MTE${2o*_K(S@ZW?w!`3T_aHScYo{ODGBh&Cmz>5_Vdp@ zD|_tE)LiE#VXZ?&0J2>m+y!_){*EtzT#W>-OeV~e!9Qb<`1!?uaQCCMk&=Q7vJwjW zwkOP;lASuT13nN&KfDeA`vcyD7XrNWZxD$8kc9Qzbn{K>FsOwao(WsqO1cZsr8R|( zb2y?DP&vN!&L+j}%Hy38M&gZY%RS7n{zRjms_TGXPD_2 zam8(Rl;cjxg71^2gR1Jf87D+#!_U@5fDISDx>lcy?ji*2ctvBBkE8jP@ z5y7#5Pi+7U6^ z8OsCYFf~b9e*bY~)8v!J533~!e4BtoRFO8pbG&JXv zf1Zsk02@A267mPLkHgH1FZ1JEf9a){`d<_K(6_F=;f5F1XmM_@XooceyJH=#MIRy> zxM`g39a2$1TvWZyNMtY|-j|>N=Bu%AhAQ7U{M4Ml6x(0?6esWNsX9 zOS|`;_|O1b*nEU>6i4j>&*_!0aneY=6-KFOY(Rk(LQ=1Osdy4mwz^cNiFgfwcwpK`h5@dZXtq#vm%DxP+2AVPAT^DfBoyuSed}N)=&Ip^ zkKYrR|2_A<5HdfIH3W_o?+R-F*IU zLs-z*{yeia?3H%;7M_>A)oITKM(-_+_b&1BeYwaUUh=uEQ3IglzrXy;wcq=_-&@Lw zy($0wik#J-$vxa=y}7-ESLg5-Xo@stP>};u9>pNXP47B2>DzhAp3$1tx%j(mP~4d( zY=L=Zd~XfWsP1rQYJH2vzqU7=irD0$f{z<*oCiHLnZzY7?B45KC2niRR)`4hr&`zf z{X>TK$}2A^RR57c*C?`xdqv>_n?0Y(OYdnf6JzeCV4R#%oNkhRH)L&JN+Cs%&8x4z ztS*8ImCNM(wlp6om8g}m^UK12ez~^w~18jcckZe%{{z4uUwgkK{dkO*H*LTF?vxk{=KmGGeN%bIxKcWZ*Hq`hwBM|y%liw z^|>78YjWa#Q;ub$Qv-0ECmauKlwEKmZ+}DE8kz8*Bzp>57@3r>T6QeOwxkZ!A^X-vJ_fIr0rqo60 zeEX%y-YAg#p+mRHuK08Jrue{bN-@FYd*A){D@hH-BG}F5Av5KPVc21Jdi#Z12qVDe zO0nU+^NzceAb0H8C(;Ofq5(aSr6KATQ!*x2GzGBiyGQl9&{&9l1SM^Oz+% z{0bxmz9*7C$ol~O;|s6f?c(Nlt2A>+T|};dA{;#{doh~MMyshQ-VK%209^9wFM05C z4T+>;lr|S~Q>YMYURB|^`N@D>GW#K#v~uT_)Bs#`15J5{)Lh`pGS}h0n7O15;*4v< zy?oG10o^k4i%mxVqU2lV;ta#vg#lM{z7>YVigviEwN2)a!u9<7U zk|QAV3eJ8l{GR4Fm}gGelWOMk8`<6J^rV_e2#O(!h@4dJp@$#RY5h5o@4f&2ds-M# zB=5Pm+ZBhzzW^7z30YdWU@X0ba41|Tl9yk8i>d(-CUi`ifTIFaV=VyE9Euk*?$I2e z7^6YJF{0pIto)^S5mU0>c1NrOq>puidhVa?8i4ND zcC`V(zv1i5=`JD|kBN{S6n8u}iYE#`iYn}Rkt2Wx0E~V-C)_U{OH=%mQ*k!pf(-G% zJw!VtTkXwLx^ppD2SeJY>4t))oRSs97gGFzq5$rPxe%T!UZ?BDjqw&y85|O0pMwXl z$A+WhCqUjuu7~?uay8o26c>gpSLf={L?}<~`gtLWf7{-ill3pH=)ig*hJzW!pSd7$ z4$agMiIXf|*p?K=n*vH?_Dr_v&@z?cc}tWI^>b&JZtc|tU;fIU{^@_)&M&yy0K`0i z>o+bv_xak1x8wvrB6obVObd%HCbOIF$;j7D!hP*M@5so=apJ7s50G5=Jsy#bxE_8n zHrTeJibivUH9ovB#}-adL|elHp>EE>w1u zan47%m~pj_uEqB9*y6Gw@y~+0Qw|VCmxL*)aDGKB`+C$POqs1X@3zscwA*jH=G2kC z>6P+8ayP}y%zf(`2dGkCFlm&j1NeSe2l&Chg!F`L0Z)eU+f*3$iHo-m4@Y^q^V$#p z>c4&I+5a2D{@)5uO&fqe{^LIeB%nX|$xA;uBUYq8UtiyIuiW4F(4or&u%wO!LVB=zu7xpHM-{QfHC;k-;h03=hXYmgCJSGjU zEG$D%GaEBHk77XU-U{Qkj*F0OOAt@RRi$X&v-;Qgf<;D5D474(9JtoqCq)rc_QlU% z;$C_AWyynoLCJxb${`s8lO55qLH=eH`P$^9q`eSXt3f>*GQsaxLK^nrSAFFwnNqJ) zI39&nnfB5YoIG_>3*DwyD`0Y0*&GaakTj#Y+?ven*ssP0wP<@9UY^W9= zoQHrgTqNm%`!ptapZ+d>M^_5o$#B;v_~~E~B#WL`F~G0W(H)#kjlpda=mw5ac^W?P z?~5-!*Zcr*C(xkbPkdc0%?p$UXyPC*#PpAKiL$35?c3Sz`3jRJS6ou0uwn6<%Dvc% zL#{oZ56YnkE>DT#Ho~$r|7YvPMF@fr-KKV+|vd*=I>7Mpc%u!QYHlX{r3bZd``+| z9|3&!?6;-c|A({z2wx!=z{8D4g+%y{oIqdqyuU^NRDlhjFA7M*S`-OP zBXb{Iw}SbR@&~w63UqknvLr+=TGozadoJ7U`}=oMG%&v55rF3e)1>!h$_Kd*Mm|s^ zU_IhA0qrV9WAOT;=wB?*KM;%!Y0Ai(rcg1=s&G!{o&qgd2-cN_Cq9pCIQSQd#dY>T ztq@M9`PHC#n$-4>tqVAdK+cYj=sY+F+&ny%GDEoXI{LL9TRQ$hF`pqZzR*)r!a4;X z4^H^xp?~&4RO~EMARbto9tqf$72Dbx&dc5H*LRtI#HUYx5N`h4zwMTfJ?88NK*C63 zYoX8Oj(@$mwRN?SUJh!akou1DqH0*(Y;$WiS8X14@p(tn#f-R7DMvh|z^NP@&&v;} z`26#vCeY1`|1{8>7N@e6NJrJco_TOY`zpX)fcscIRWS8X)%MXc#Wqe~}c^Jzh??n}iE-W?B_D0W>TUJCrO0*Z^MVQc#iW zHT*Cg+6WU?QBT#W0#F!tcR9-hsdz+$%{S$jIoC+09Y>>ejm=_cY&^077s0JfQ7deB zJP6n*A35@l1_$A|@qS?9NA3awmJZ!2>zHe<(XgOxuW*17Qy>kF37VC2deRbnRq`rV zLII|(kgHqU&EK4{5z|doDI>diDVubmi~k%hQkij@qb1q|5cM{FB`dHb?Xtab) znG+#GmQw~fyejvt-7}{i|GU5YyHnr$-uKoYd+f2Duug9Z_ujldCp3En{XaW7~qtr;d0YjAdQ)we%_QCMU*Y|Qjd^A*IV%!pOE(vjnfV1 z<`h`u94lpTt^?RAnc~*JFCya{?4ALc-zm|)kh?*q_tMKRX>t1HFMpxU0NDIUkb|=y z2yf?Vnn3Iq$7+Fj~DC&af5+1HO=VPZ_TSNI*|BrD2a8g^_SL`6#A$*rd^N zlH!CJjzQk*pPp*}S=mS^>R4%Dy2r#1&jFY+ef-hUo>&Mz0(ciUK&mN5K4<{&v)+AC z_z+7k5kzxAOcNd>m7i6Ut+{Cp*p$eZdt_7wAm(y~qqscLJon%6{VuDX{VCC}(bwZV z&qbcO12r>c&CfF@9{&_Y-wwujl<_Wx+83@6TyUky$kgmcu7J@;frbkY+y*;CDq8EB z_1({wOm9l@|I{b{Evx}MX#ijb+; zSDY^l7;|FMxX{rCt9SDpnS1c0?H|{?&sUiIOJ700hba}>cQDKm5qp*B$={MSz#HNh zfMWIOr=MxK48m=YArF81Yh*G7t3IdXe_G897JQVaBqxk8SH?o6Z%qHCCcHIg@=2C;M1J+3I>qg+c!WqBY^JM{6W z)8_e5ViAd`7*`bh_N~D`S&mg5#3nF3H{)Vbn?fd{OA}tkgzX<^(h$y4`C7BWU8D0? zm@^Z6Cy&B&`@0>>lYvwo#J3uRT1%fo?oYV6sK7*_Tecv zPB!IYMIZknk(ukKS;3#*#O|oY8oln%Z~F3&k|4&(e3pK=J(ZJ-lE2pCZyWWrHA@*! zA>p!d=H7)SU>T5r=p~^Z`5xY#0V&|@dhg|QLnY0k(OsmVrw+O64r)oz?29yaXSw;2 z{L4SmUV0(?jv!@A>&aS9fMCw{H{9BFuL^eXD>1@&NbY{ml7opjFI@HveB~yNLFv@v zHFz7bky`Brg_)_b=mE*t4KdG1rbrVr{&?0B?Oc6-SAyFo0#ur8avS%EWL%5VB53gQ zIZv{yUx>JzhFrc_6#{%}nq;#PC_1YZoWW>ft32W5olA#Wi+}0j(GwJDpsMzs^RhyN zlys-R*LO~37PD(N$3jE1aCv>lm;*CE~ z2}2(;N4y3g?g245AQDXEB4}>FWtZvH599?qekBX;kTwy6%iJQPtx@zDEW`C&YKXZq zG1_V7V~DWG&@Yn!+a~`4v#Lzm9u?~3^TgTfMCCNrWYrhLwmdz-uY%BVlz%X4ZY#|K zRZ;7Fai$r3JT$>6MDOcVBJd1=LGb+_UoUxZ42WN`IEXH4BEmi_fWL_Sk(Ohyzhd{k zg!laOU;W?*M?~Vkz}{a5`Q;=65KsVF0qpia*rjKei{;qj4w8A# zaD%;x&9%(P4HLrbxcv91Oth2WReHNy1KSs|k z8ZOv8_O|bRN@F-KGAzRXE&3Fp3`B_34gR&*Rj0uqOWf|QgJCO-!vEPZys2|W1&w2$JCCIHe)Gz7tK01`k&Um_x6M;x@@J>vAj!giOvg2`sM!^&QfSFz@7 z7&h3d?QOdY%1%e3?3%%)26JjEpK?@fYXZtHkgm#os9DlBNdyy*nsh3Unt)>2pv*nc zU(qP3B zq@^>EDJz1Z?!nLPTI80nA8YcjA`nrh$knNY3osy8`F>No<0W%(8!$x<8l-7uk3t^* zg8lN&jQYF~Y+>7;v~8k}J;mdbA;{=(e}JU1>xL{1903Fffz0;WtFNeH07Vd70r>mF zWe^1q$N*gGu9wiCE3Ujs19NoB_13+zz0*&A64t|Gi4`SH3K!&QBMjVo@^ixdWiLNm zgu?Fy%M@GqV&eB}5s)0=u6JA~{1dvnhd~ea_$N*V!=iIqz{BI<^|?_BJ$wy-iogL8 zX^HFJivY+9io41w{S(7$8(c{&Yi`Pjq*Xr~^K+GPmdT7vpCAE_sbHgXbV6>+HP(DG z8B^wfI$=6gk?(SG7-I8cJ34=S5uI}G*k7Y49{c2&`ut-G23ZivgZLA> zZnfII>{7k`KJT~(``W$IETAZ^M`k_BN5I7fO@faA&Xp9!*DbjicAtZVS`jbwQ?cZZ z@Cb4plKXZM5~P#8I1J+F^52_M@R7I|as?pU1Edd!#8anEs#%YQ26?Cy2nW&u;nD~4 zDWiruF1O0bz1!&dyIZ64E# zW)%|lN+N5!Y*W(}h8ojKtmjjT$X@lUWt}iJM6i{59A5~AG~E2X^bfO)hWmW{p=c!# zf0QWUpSw`x`@D72=s5f@&gP{I0t<nx_{ZzA zt9(Q*CjYyfdH2~fqDrz$TH)CDcq}0tM|;`nNiNY98Xx3@a!Q5)(ZnRl z^@PAGoL%`{3z@HXy*%jxW+MRYGNuvvj=5ZR*;Smvrb$Fa0+2~uqPA&$A{nq%W;B1w zpI-5#-d7b9rHruJO|!c?5yP+ks2JKD6YJW~KL1>&W0x|KQ zmi;{jlh~U8Um!eQD%=Qg9UzCi;)-tlaitFn0)HY_L3G_d1=s&kBghrFirFB!QkltBjU8==GO5(%MYjun@$_!ZBLhS&`j!yq0m1p!#>ZtpxR zxYz&r-~anx{@iuG|9iae|2zaBvj<-L{`bF+)RSNS`JeybhV^y&o^Y|KaN)U$Yb3^9hFnoh&pPwn^}cJs`*c_yZ`UpUYlcb?<@@pwQy`q#y*i z4|T(nVv3^KaRv7DoB{l7zZed|ryqpEwcaJLbNi#ku%pKcVd?nE&eLE=Z=)xlJO=jku-t_)kER1kv!bCva$Gn*Gy_<9VEV@~ zFC>cqh=md#{e5vTJR&#p^O%I)t(aT39Wk@$pbW&T^FH5Wno8Hzy>6D{*X zj9P5FmXBC*{kq0KDvt->p)4|O3m0gQ6nzQo+kQrV^k?VJoqjpo=#x)ASx&t)>r*$l z?Y7$%TI?M{Pu89lVBlv0w_Pn4o2zl4a#7e-{o&{Xm?Y(t16;T`AtsxJQEFes26LV% zgyERy=4ng0LW(KKqeo4Eo08X2!DPJDq)gc&I5(MJo^uSF+!R+#^5qF~VF?nY0 zKgg@OCbakraKm1*SI7;pI=(=53;S>UY>x!}U^Q@1_WxkaW5U*88!^6t1|ENg^gjvu zkm4(FyVwh(Kz4=Ke|%($zEq3tC3>I3hq8n77k*FgJcnS{^9cGzjyr=Q92=Su!1!Y$ zq^^zCr%O^B@-+rw)X| z78zdk{c2IBjJ)qlvN|^R#WjAA$qTHJ|JYSM>dDolg7qnevI&@Z$_8~>?^`R-bI-;4 z`#9&gTBL?UVfM5>Dl}KbhGKRg+75GWv=`t$-7iyhSl0=P`B(!1A?*KkB_WePe&I(O zYwidC&;R_NC&J_p=fCrA0N#H4?Y@hU6Y_nTPX0zNCbvo_{~ufzNV4re`it&D(R%_X zNr$m{LM;2u`~p%H8jr{MIE$uFVdQKCh=)%eL$cMfyKJ=bO~OZOOqu$6fHS#JP0!Ai z&V|Re|Bf3>H%8w2T+rlGMvlsp8gm#whuxSPh?1`%wW^Yeqs*C2zEPOVMyk%6Nh~;U~;hMcXqoS_~|cCD-@4>0YLC5)Yz|M|Bl;) zSw(+#Zqu9LEc?f-nXe!KRY0o0`WHL{6nA(UV6_nT^_bXEy=hxLFZn1c*Gsi9B|Q0?`#@ zJt{)`9FyL&{XhF2e=)2S+_Eo@VU6IqD-^GG*swlTqM+QxLN5+(Lr}OKsaem;xbWl; z|LXrfVI}^ZR|6o9fI3Dz`Q(!u4?q0yxj*}}Kl{Z+7hUqOK!6YL+1PW19OXLqZsgr& z!#9wu0)>t(Nh6;P^StdZkK~#(ffT#JjX7y-`SzA&f~`L@i~n3=U@c|KTB>ksE6_sU zDc$i*Uw&6}-QvA;bFlt8|BwzrZXATChBkl2R?U~gC{_(ouT9Yn+KXDIxL z4hF;z?txqzy6E8bb^YCCP%5yW@Fi9RP08lwwr{#=uuSdMgp@_Ha`OkMINfu}jdL#k z6c_)T2-qN%EeN0a0-P&z?}Pup_P#wv;w7R zYC#nol155I5($t>t6)=6OGp(;|ELu4Pf;ro;X+WboFW2_A2T?%vC*Us5=Ml?0v83a zQxl3Y4}-_X#*ca2d%v?+*FNX$z1LcM?Q_n(9uGFoCoaD8-S2T8`>`Is$ILYXPmOuN zjG!5xF_lpZcYwTFpB#8_0O~@*oJ_5sERcbbNe@CEVmRMW`Jzo7BfS8f)yavv!>;Y+ zG?o-R?|hfubTjE@{cU_L1>mdQtVOdPAN$zH)~>kXik&L3d7Z&^Hi}pi_h)oOtkcS>>U~xHYcw-~S{9Pw2UviD z46{^qy=L?VYc5_;UN<+z>c>)QcO1}HqM2}{+At5egfl=xBg7zGoo#3gJ%cJ!EbZXuz|sdobSX839#&Je!+{(s_5&@j2oA3=7Sprt-f{gZ<>pn7c7?Ruin3Z1er z9SoHi7J>{8l)C}Wccg$U%s7B4nYjYWMJUP&44W=!o-S()Ky?Yh(!QW%pCNmRQDtD8 zf4VFqh`U)%y~XB_QZztJel!FrY*cumTkU3AXHEeH;)Y{4E9+SnTAYu)BmU=>2)RoY zsORPw73tSYNC!?(BcZ;*bf7%O?`Mmk{%N=f5C2;l@nE)+bUi6%DX|yXe|N7$*i|P6 zWPijSbRfN`?v1{b8rBH!3MR%L2GP7@B@iSMHlA?F;WbPyk;T zGu+CoW^^!L>cIf65+(4ANY#Nb^#ow&RQ!yC@1qLS|Y`8~XB4m7UKYyp$83~jVR0M!hAtP+tjT$Fa<#e$Dx zeq=LL8@b4XS-{f4X#MkX?wpyR;_6BP?rBWVuPETIK4a!Q!aDkYmKT62Eo{^(J=z9O zr_eofxAS)QxE@zkEy4*ppnNZ~;sA1K|6cdrO8|eQ`|$w4A&y%Lt6R_u0CnPE5Ca9z zs}3lU|HH4IUf&RQ2FMue;l}$9FIITnC7-$E+wXVp_ij2zDjv;l`zj<%(tY@qS6=yJ z`kwzqr}3wR-u1!voQ^UyELXgTpUREG|Cf23Vk$XGDlO{<$lH~&=*%BX^%@Zn0K-{# zkNDmpP>90-T=Fl-vcia5E=^JmQE_O^$tqOXP_{P=R6Q#Db2T`VwPZfdQjwDJM}-!( z+Xbv2qg$MKojmrU)qqCHkNu!BRYEJXMM<3#{kcln@abkOQ++`+N}1(7tKbE6s{`wr z@pGlDpik;qk`B-MtxSTWM z+FR{1`*{?GthQ+mzb7Kt5`Qv|&((D@#OH!#uA`3#^$e-_)veYxW09D zP5m=E=8Wg20)iUfe{>$2ngnvrjLlJJ2wus111{72;tv>JDwsfh005}ylijAS7kRed z@Qk+mfJUkW_PAF7c1Usdbs<40`-ffu0sjc)hhM~{`-{V$IsC1#`G@4(m}aLo00^ap z016DkK;hr-c*i@w4%mL04(iK6@Pm3NYjmXR5m|)JP55jU8$9k_S+8O%WD0)I-X(Ar zgb!_M;+_C1Q(EISD%oDaIJ>jUc4P3vvBSVI+k(oZbrpxbu|;REz@d2pK=zr>YB}r$ zfrXXqa#mpAG>1vZ@}_{zm0&ylQ*7)}+SdW+DjN%`W99Qxn=NXYT?-$CZe~{fbObW3 z@2ymaJYp4KT(9!rc;#+{Hay#A7o6MUq}f{!e@0+1#qCE`xx$gTfpeskrA%G53jt8? zPhxiD8@Fj0XIUGcfxY!mA3U_nPV#z50zLQ@dhYrqVsiWY{O3R4?B!PO=}oZ;Ff0uB z(m?tIA&u_}#~V<=-I$Lf5{8W7qY^y2m)o3o*p~=nO$tTw$jfLd!BkrRK0szYzk>+8YM)&E z`d9e$rL3|f#!~6-mWGbFV0m2XMG^Hbh~t4C`l!#wk4M}CHU5Wtjl~y>m;W2bVJOo)^H;p`$84id|8y#YW*P40%R;(@DCsgZ8Rc6>WoBIEG zae~1%jZqT^6+`3zF4aX66LZ|v0*exsQkTLQoUq6!+C#?L9;7qVs)rojfO!uqa}AvA z@s8jHl-;W{#h4MxQmy<0n_O5##1m4zCf!DAL}>G@$z2vnZ|84$~>1%)J_?4S*=*$M0fqTH^N=Fa!&i9e=Bz%56P9N7wgzU!{L)}9Wt zdT(WeBolnN`|@|a?OhLfz&j{Nxq}2renKDx5&~Pmkhr*!whUXiQ1r0PNL141DvN(X zBZ4xo){N^CR974YateqFLalzB(LOJO5LLob0*a*|2?Bdg4V41YJ6+ebGBbt&7%x#y zA#8EH5~j2<5z!f`y2BD0&wu!$0mkOfK=+0;ETYkU7~A|+_60>?1O}E~idM1X!9xEK zjj{k^KlLjdXxxb|W5!FzLuusc40||*K9Dl^;M9vo#8#M@ozXx;S5kh_nb#T%_Dl+X znE$W?F=^5`wRR@318}K)hgj`bvGdXe8V)dSl5&j93iH#5tQw)*;;gC%RC?KLjlz=B zsPJR*a~ZXmRY(wBS!pQOT@kdy9Bja7C5M6D*l;Z0>oG<9vG>laR7ti+FG+@EF_wjW zs7Hw-7?hL<0PoeO>O%E1;zgGx2c;NWij13OQ1J|&8}!uY7MqKY+s94N zU6FLWC>Y7fm{5rZ@*h-%fJGb%yVHg4k$w{Snt%~hsQJRF2stXf&w@;>^}Y0Se1f7B zJ`yk&zSuD!qbi%`vlSIw!?qL+8)wFvgS63=$FKe-s?nOLKGi?O5=U5kt|-;ZmK%zK zf34@33zG;Mn88g-GLyb&F;0>%xteHb{h(smcvrUR z9M*fWs{a!CxHt#DgSB8eFR7vi0SHNL7^_|76JK4nwa_f>$K5oOKN1ca)c6tz4n7;I zT>YY*>&$vZZeirliY0!Z{~#Un(s9Sh)_}0nE!YA+TJb^&yvv_hR4a#T7+W^R7y4xf@C_- z^1U4pobrR3rXKB9=~3SHvI2xw3&cP}Vr(dazV+y^&ObBgX?>9E_oU`#RLiFUk25s* zr{K3`JAsEfFm42-RsLm(Hc^y~1A*ylq*Uc1O1x|T`;_1rfv_uj&;bMhYS*gKtA=Ljc?jI^W z24?z&^)9(9=$cNy4;egwXB*K@0u3d`7|cXDQ7$e5S$G9(s<`H8b|G=nuuM!89B!$Hw8D)!fd<*nr*ySu4sC{DP_NP+GkXK>#KV=OLplL6Z^q z)j+xvV5mOA&=60@J|Asgoxe6&iqSjbv2k5|>*ZKs?#PTDigsAhh7qsx*;1#caf^x> zH1ZzG$|$1H)>pv~xMIwqszjtcLzabDi4!u1UsM=afZo;aL_0;!Idp}iW#^04ESh!f z+%(&-gY(y7ySnA3TMm6M>hbJG?B|(m0dxl(4kkziQ=|gF|NFo9%l97e(ojq|DWvu9=he0TfP@xckjLTKJC)~o|FK`9%1tj;K2CbtFF57+=B;C z{s+P@{AUUP&Lxu)-v=CM+hEk4z0g=_OPzf^NLe@qQaw<4ih)ko01eeaaB_Q>+Wy?v z86}mKe<$dQeZ_gt#CRiDkhO~;h3Xut3WO;@BarGm!^IKZ{|VJUEGjG&KW(co&Vhk7 zxln#DC$Irf8XscuGI4xS|5NyR3c+eHD7$Ea+E8^Rg`B7iGcByeh-UyI=O-IVnsv>5 zHNK~~`a-uHhYEwxK@-DWr|=_8n7RrlIzbbbn(SCr1=QBJHo_gE-nvk9D`oVD-ap!# zQ&0aZ-QRE^q@^lD)l&q=yaPBR4md7WRs}4;;qJ8hjZL(evsQJdq|G{uA2Vp$gZW$>k-;g@ab;=W>3X%?ik96QA!cH{(m_@LHZM75{6DCcF20gpck7cHLf;E zl^9_JCPA?^02=D$#X=@QI%&TKl-6miy$?`}BS4VMs#4hqvZ`*}9MtY6#l!~vJ9mQC z?VQTQo(fK(+{{Jc7K~N{jJU6;ax#>a5=84WkQpF@uInN2l3-+%0n~lyMix0%IKW65 zD$DE05Xo4!B$KH)k69oZ`9Rk1=X4WLMnA>1Vt`z%Y?cYG9X&XhLZ>_q$H=W*Z#|Yx z(begM#&aBFE)ZY9`%F1pMl7n0z&jH8a9L0XqGOiVETbC31m<&CgIX<~OEXAtAIA%% zixM+xlpg9;ef)Wn+UEQLo!MeBk)R9)Subt`2T(O%8p!aLe*wek0lOifsF^Ou%p*cU zCHn{pvkIuxPqFH8U`q_;3e74^uE6%|bQW6tHhsYjR4@PJt$%*qcjC+c@qq))0}njl zpOpbXe2_{YcId=Q-SU0E{k})v^u{;-{RWi)$g1?`sWNaTxd*Nd4Loly@=#U-CVinn z^v!|W1P}hnM!UFtE;o=f!($wH$STpIkSOib5T;CgWVVF%W~YtiL2v$IBH)-#g4q0_ z_B!(;^vM9{-&Uh_sBP?XH~+F4;6_RzQ;bElJ*G->D%9H|puN=XQ3@r8KDT;j*v1z4 z7^o_f$&40V{#^w;s=X4C?sC_WC&0gNWIm>zJ~+FQ8zs3D$OG)-;EsYh2DQxCZD&30 z%?`#j6PthJgowCSkrJQ;KNex)h?GW*;#nywu1`uyf}t8wbglX3@A!rf6N7&j>U^m5 z`N87vkM$FDqAPmkGYz$hTq7=2s*-5pP7uthR2JodN|y}{bQGwd6kxuCIEs;6Q#A`N zy7Xv5!HCNK4^SX*BbCapyZPoDzZqV4*=3jQBfTB`#3w$nC$XQ?(gLWp1j6svTyxC@ z^!sl+SiJAx!2>US>e#VxZFcC(*J54~tH#_=R?tLn%76t6u60q9jyKbD_N-weS(bL7 z;+f7WyIM=2aG+3VZEzO&uI6H;k#%SYAE7q;V><@gWxx^yhxN5gv|>=0iQs6!p@2V` zD&xlj7N@B~l_n(2qzS?p7!gZ%9YFhos{u?l^+*a=pzqy~rJ&+V7VQzw0V=%jFOpA` zlrtggePL{8s=d(~O{KCj9 zp}dqoA_!228(%YIM1m+bD+GJ&pmvHZO8Z0GD*)ai6|mvNxBMC~wC{^+Ghv@w;0P3Z zkZIme_3p>*c&A!}Q*%03MKWj+v5dq77*s=MYx2enF>E&y%h9NXA~?3B;h;?(pazJ) zgFy8Qy!lWae~8lJ!*sYGzV+5a_f6oco}Co{_GsSop7%6|4jsZx%mxT@>@YAJVciN;1iHe^6{_((s!-ER5y44! zkQ#{xs@gqRLdszhoCot>%R^?FNEHh)-z+(KckKLYB}JH6J0!BInsVlf-UL7i<~@&f zRmrjQ;>LIAieA68{$y+gd4YM$MfS$In=2@MI><4^x>$7^4vuukBC`hpjt9kwhXVDg zn^-1-(7wl7)@cMwnq$`J$fh7J>UyQ@KVfrRq4#ZkDKM)t2BH)49Yw&B_!XM3lhO1d z$jf&~_`It=IjSQPZ4oFpS&fP-vUoS)x~Gc}wZ6U8U#QK|q5=Y7G6-hb0V~;o%nzuH z9aN>Kiv2hms-~dF9{^LgwH)NxXP`s11%NCAcW1nUPT+5`=!2^E@90&Bux+lt_0}8z z*7f$j%QwII&5OJ5zPo*LqrL# z!bP(RPBwUi`DF`1VNeYt%qc+zUt!fEqiWkbWy70uo{I2LAt7t(4Djr^)SrVIj?UuP z8SMZA%2=SYKliDG#4rs+a zp1f*t8{Ha-O9S6ueFb2rDpy094WNqZV2Ph+f-qVNw_$Ln0$`UI1)v=&%L)iuCS9yj zz&2eT5U|YN0MAv+^CI;&sJp}101=3A_Hd*(Hcd%y)HK12g|Nr=qy@C4P@0x#?T^G*^rIV;9Fee6H6m39Kq%tmysA@C%jw@1a){)eo+_<(3=16E}at(yiPx{Aixh z4=3x0 zIuu|xu9XTSEZ*GUE;V}5VMW}Q9cLkXiivnGEQbqQUBtrr*FYNNj;XB7j<|RzTT>x! zA&f{eeoe0OmC`#pS9&tl7WvZR##Pr6l#bEeLZ4-Gxmm<4e=V)Jr`?7vY$yhHcBjtKK^s}uEoi{c=tuN1BmnvhRRaCgP zq0jtiNIO%GOp}?Axd;z_FtkQL>eqh10IEW22%`S1)V}Rr^J0#1U zO;pB#A@QuaU+UWMc2>5Qm7Ak%Qbccv3S_E#uL$buXcRC^vao*LDsFvPe!n(i(#NQj zKdp)zV9i2B5HZv+_t7{>s`{awA(*;opw@i>VTsCpjsr>ug+erVpa}qjENJoEu4yX> z{gAWYGQ6<$?KkKJpK5pf^>OnLk5|3wRp3s;fw@y~azy6UQZ zXPj~HU5jP&AL-;@y;v^0Yt{zI!??m}We-Dm#p*8>yW&`;2$lXZ7RuzD+6jgwsj41; z5tX7{Jy6GjBL+N?W@t#nfrVss254qNl*jDNDQX__nzstTU__-4D*Z<++5`Zmr+{cF zyLrIQ!aaJ}V>og}YBne%@CGZO$KovJielLxKTY zTU+7M?a;*wA=W~3&#M*p0tAO(Q#TQ6pd+!6RC4 zWZ$W_xGn zyXbdbOBd+pH#axd$Vb2tZLl3~dV`|F53>Mm>KWjUUQqW0K&N@!W`>)mcG26l^i#ME zEvR8R92-t>N-)v1(ew_;BN3;S_ATC}vsxsO&06Pj){ID<+Z&XkRWl5lT4$9V#pWN2?s?1K&S*P zMZbcRbixJk^8eg7m@3#UKWv5Rb4pn zlqX1uxdvsminoOkZOKf%LTeX2uJ>52>|&+{imZHEwR2oEgHf_%YXGfkp;X`lyAT-L z2Pmnjpbc`fZERBCfvh$t?$gkHK*!}|rYLE`H91vV49)&Zd%#l66F@yPo-`!0wfFjn z65kw;)0H!Jg93Bm<>NcS|XlY+G44~mw{-!)XFq%EW50UY zZ#=kY+>_|reRL0BOaQ?RVhRA^0zw?Q8ZOX6-9uo+3lL9Bol_qEN{t~Qk5`k6=2b%k zt$qi#SqKz-zX3L6D##-ii(<|`P{uu|M5@QE!6df;IGKopB~b*znL*_wGEko<6oxmY zKL8lI`7j~)qm4Irqh6+=Ur<~luw?(!1u^N1%z~aPN2;Q`l|B`lAg|L1bv0#=?d(i2 zJ0+(WLIc$}#meWbG7l{f2nR{_7qna3QVzx#lmcLdLP4)DVI?PDm#tI*4D~e7Hg?>` z0%XN+2%;ZK zOeX*cP&13-u@LMgxaK$=7CeA}LJ2Y}I7^Qr&JUiM3t>EDP)=U1;zy1~m6nMk1D>09;|@)oI+&|F?wmBdp(A*u##~Jp3X(_wj{W z-Eh<4o9-b!e~gLndFP$i#DAae$4{*S5KsQ~uYdj8Ti)`Pt?=(_ul?9L$B#E}LTG-I z2F4rc>Yh(GYF`NMI&7dvVakHJr_X_bSQwMonRr#u1;;Hep@KFN*|pFwnG&qgKs(h$ z6sASOv%uA4GpoX0t_~!cS%Db2ZX*=F5!XBuuv7)_SRzS?GdzY%V1^}NB&s<4u4p!? z$Q)2~QP42+MbREav8u?7#v>{!l6@Tzv7(irX=7|$YQ|0RSoD2ZM_Y+01Av{3pdk`+*1?^&S@uF@1Fafv_6h;{USp8X)I;usMqKl(YmNcOP{K1+R&KQM`CHZLpTZ(}EETAlXshR;g z!@P(CBS8V_N~c5>92FhkU2yo_3=rTbkg%h^p#&gdO_9(EKrR^3kPHaXu);AG2Gz#^ zquiVfa+nQ8D)9^524t2Kz~fxyd2dBrL&#V{UoIM-|~K@@nG<&wmVLsAz#GKpY0xuew(1rkk*1E$v}S zFnG@BSrEWLkX+9sBVF>ik@Cc;{f03hMb6DFLF00N3Ga-y_lx!QeNDKJtJU_G>2=q7 z@LzbmY5xAsLx-L^9qNA1r2rsR0w@}4Nc^CUECC7x&RN2~H?CmyYjiDtiSEJ62|(H1 zHVOnnB-|SMI1B(v|PxU9Cq2u#SFJAdYe8OnA2L;Bn3GS!rl{ zctf%_tNQ108&Ty3(?A)r6#c5v0D!q*v0x?#+TB0A)lCJ1NLJ37j&abpzlJI!TKl>R z09arOivHzBX>D>Cab&tj%Wi3`0+KUbD1^(zMy`Z-=1J{hp$6!*gtBt$)!%_t42x9Q zb4nuGW--Y%5}8Pk7LDTAm7YEeNkK9(N^~eS%p1W}afJa?6nI3Rt7`=ETXgefdwXY_ z{`@0KPrpVh&duxY*zJc7efCFjpS$a>yVj@*u>I5C_@7k)fUDC6n~#3l)w4^Z8sJK0!f|lW9OIr@3;IV*0P3=vYjREtcNJhG{9_K}FRuc>=2p&3o3p1Sll16_=lvu;a0?`A`~F zq0MMOqogl3I=xTLQ(AOmd2|X+41He~2=yv^tUAI~_mXIj(8IK~C`NoW3d&!ZJxlvw zzOD9pPFW?elF1uNgvq%YWKj&=n3eBTU?C}jQ1ubOx>){h42TMg;5hbknH>v4jcDBz z3>}=X7Ov?wav>p3(JZtEu%q|&{99`FFM&jT!sp&iFT8Qhtq$LG(@pmWW4xbsng2Nx z00foURz4#mrbHyvVWU-j}~Ko0K?1i>Q*(JI(Ls0fgxf9wBnV5CXy0=t;h^vL%4B z*<*T#k3ZgD;_Dd{0{O?{=n#8ex( z+zLpQPA4US1mHK$WiTRsc|&TcW}w&Lvf1go8pV@k<~l2jF61C9ToN!`_m#ByW&=YM(1cD#~4C`d#)4g_QXLXFe>|XkoFVhzEr4=l{_}QEO?1A_VLDrXmen_Z) z|7^+ppY;`hP%gRTl1t#NZ~YxlV-N#y;`P7$%dgnKx%qEsF8v1myn$}gnRGK(^t0B> zD*8fZQjA%VDdT`5OuFA#1PZ(YWMn>AHuC^WB_5DHtAbWw6ur<|SjU8YaEyj%F9?z^ zYXSHag)hDtYGQ_N6cCS7ihy8&7#D_aLyeZisKMA!u{wonPN{)*E=f6yMUHFs zCq$}HbHu_Zfa0?8f8JxJ}-!Ul%tn{xH z+ac@VYL!f}k&FNt-Mni#;$DajwFS3C)%sWh0teF!6;?7boG?e6@hTZR2lq9j6*&t8 z$mb)!=LHzIQW2TX>}vx$Yc?R}EW2S9F`w;|QCDOFFws)A>;vKkB(?^Js-A9XU`@wd zjB`DwhteRGqTNHZtAB-()7#dZ{~RT-_s9L~wjcg*arf0%ubyq2{&OM#i1NX@eDaZx zeB}J)a^pfOw)_)XdjB=WV3+RSzyIf`1k+Umb_jXldb)O~@-^6d3pU&f6kP?BBHQ=e zPzWvkfvnawrkH11?qW?`sDzE{0AyNy&#<)ydl7PxK?EZ*0F(5gvGV<#1+mUD03*Y3 zNiM~eD)WVMa6UJzYl?8tq{y6wZ7(QQl#0#|*7rOt`Sf-_SLVveyf2wo^*u~fR=!Sg zpJMJ5cX~f;l>Gt=#b$uW>fqo?>)$HBA5E1mv;*0kg*kMZ*@X^O3#%fbZ75ne)n>4? z60D#g_Oi7ku;V3Q6&Pu-sBC>>AyiVx(6XLLmSF;EmyDW)fFtUeiq*UebE|3q!oV1y z41{XtJH8?%u2uJX4ka)X5KM}V;<~580T$X1&QQIwf(;Y}hze*@$N~oiK;M`59r)oq zf^lCE^y&V-v%U2s9V`j@xWB-)wZB<+JNHwD|2Ut9l(aU_#;pH2g8>+83xs3;pC7&Y zm&i=lhN~8uUfLBQ+ZX&OLM8%hyf53 zFaBLn!O&=W<4^;a9Rw_=jq(d90*mezcqXn2qWvXx@?`&6gT)Z5We456G9BEq5K>uI z=h;6G016blRM9J-D>*O)QN9*Ks>^7lIFJn7vO}Ilsnx9vtBYX<06Ne`94qFe$Xp9Z zWo(1AHs*0QuWZB(KAp0tQc1MGD+iJ(9Eq+}>q12j8-hCi(k@O867}J+S_Nu9K1Mm^ zQTqE2>3hEpE#9$y48L&bFZ+!@RH^Adf#T=M$*+AjZ~V`h0ATDvOo6qNCr@tDgm}qv zx$h0lV)2_aXx?%u$*g=} znLIj^o@O>teJUkL5m01+KW_MK=naULK`ch4#ZvSAJ;QaVa;3fix&g{-R+dGtKuYJD zf}lbP&j7T{i?E`K#vAS4<)XL*5J+r+GR`tJ`?CvVRBb_4Mlai692jSS5p|zTs~sZS z+$~`WBkJ_xgVUNKC~1;bR|efaqRsFcwe0liS#fK1^qDANV|`Al*KY=`h-OCewwd;O{MZFa3${KmPdRC!Q-Cy?eHJ0i3F8z=IDy*bWte@bkn6KJbB~FW7(fQMdG8J@(X- zub}77b3sT5;T5DKa3E9#0!njdd&jNX6-9JiRbVyHhhljk*W>!g6h^e_ZTQlNUBSR> zM>nBAKeWWa3Sp5G`c3oOLVn#Yf(mLoRx|mLetwEQKq}j@_^2#ZmPG^vOL?2QH^G>B zL9J;Hy1s&?k#nKfW?<6LuwvDQZ{QF{ZG#!|Q&Ba0!{CyD;w%(Fyx0|!LS`q8DERuQ z5ZqweRT|}X%n7E>)`5vK@G+kPE|7t%Jh@aqlZ*_duML7So2yAl1q`HdOd;+BrbQ!G zRNO@j7-0mct6GXeCUp6WViq74b)s?gy(pk>==qmbHdS>=7ZJthxk6LVs#s|ly%5u} z0(&ipDmHEfAQldvJ-U$Loe&Cqk^)RBF+OmkPQQ}15;cM_+QPVmXT${sx{GEAibA#@ z_WY19Qu4S%m56Vx{Pu$cg!~S@>;F(7@b#T$?cO7wzTsiEDZ%CMe)rma_uYrj#bDq$ zT>*$VfdCeaVnPJISU+oX^Zez3jaN3!`ag59{6&iYUQ2&EKlE|vzX8d))&z!h!(GIj zc7a1-ADcl(3~(o*d7?BpD%gWnseZE`Zde;Jl5i#3gMR-4H2$mPN_Wp^4j=wjeCJT= zcZUwW22Yo5?>|%kDvz-F-*eAB>8?{i5JVQfOM$>6XP^Dj`<6@mJVgdCTe$XRGyr~% zUifmV3cQNSIj=n9j04Lc;1D#0+SW(BK=)tlYDR9=IYO%0F<`Jb6%W{^nb>?O3(o4m zatT0Zs{UyL0gedjOmOx!#SAPOmZH#ut(c3Zet%T!bB1doL+=6>$7PEPA($GDZpy9? zjV2(tL_1?*5|z44bO1n0zfMIJQk@W8p@oUnDv~th3f8vV z6TSrYu{n@IbX>#+nX5Ep<;F}29kbUKvcO{|9VMtvq3%%*M<#D>umPCcRSJBQwK8Og zW2sZX86i%{Q?g(q-(tg}cn z!o?SRzx;&-tX)E7fLGCtzmUeoi)i3%(s*MMImOSB?T znV(MrWO7v|L747x5YTF4ygZXQ#PQ#Q^{IDpu|~T@3rW!-E5#0+_0Z2xW_*2xdpIT( z_pgux)|&ZBXFO>>>P?JGSsErgT|b>4vq!VaqBC=`Nnkc&$sj83)*^y~97sgEVn)h~ z01wplC^izvYM080XOQc)q`NgO4^l8$NpSPVhK1x7Bpk*N05k)_(aUO(s5EIW11+Lw zQ`=BEZ*i(32zCJH|7((wK>r)w8)KCU0sN~t-dVuaOu5gSy(rtq@e4&Crvi3F>E9#8 znlwfdP=0i`HuRUEDg39HT(WD-7y_asc9+c28@6cXo}ka$qMs9V^;fj+@6cKPh^Ert zdFLOb5aB^072dNtan?6(yY1@Zs@4pbU;gecS$@e&UeZ3Bg?*m40l*wv#UV)8pn!1W z`0?Wl@7MRQt+f{tFX|srKyV?AxC>~&y_AR_7tkVkA&tqGZEl_c)E4RnoezTv6Q@+r zVnW5B4|J0$v- zXFD*#9-#?wAG*Z@Cj z-CzD80C<*R1HxRP4Zl0b02-`CZr5CM&A}(Ow$8=2J!fCDb`Gr*=K%O~T5Qjwwf`Uu zzyoxpUPuFPKMgv9g}QYbl?Ujp`@70PSN z8nQ2;IOP*gNLKZcy*^vk7e?|T3LoS8vg|@K2eiGe4*skf6@mB-l_l+atnXG&GpjjL1q#1ip5o&byV*{pn=#6Hvx6yA~A< zO$lUKBY><36S z_R8?ReLHqF;B6?AC(t(C{$K{zx-NTn+w^mco;yZoev;Pd6@9}A`kcq;`KRd9PSWQb zqmcf`q|y7`);ABi_2nZASU%bw+jq3g^7FL5?6S+&!!IXJoM;EfKKxnR_0>u3Mn9tq6d<%1x*gmrlbEO}3w$z)Y@2&f^`$X4;FdrmaP@*3leG{O={{m5 zvMqWJDw{NT{bRb?Sd$IK361|z&|tiXjZ*7DQ}ig?yQ&xjOg-C@Y8T5qq76ETOc`Q+ z2qmR43)WczOTYks2uj^nrCf#*pJK#u93e=PEYrUVD-zHk38tng6d3Zw#ZG02%nUzX z0Uk#x1u;|fx&_1(E30@?HvRl#NT=ATM7Ho%)0ScUUk*J_3iEDxo4iaY<6}w;e_9Wjg2+*Czea< zC(yv$=wyjo2!nV@zb&A3YaW0wi0v9Zw@xE_AK63hqd*~+o5S-P^y@y_&3bK1`lW~6 zVGu3Yq(M)T$6;d7rwo&x>S5RpjcOe0NcmKD4wH@mG4%c3Z*;cGoeE3`~crYkuMeii^5@C?v>(8gpS+oxu6F=%V@^Aq< zE%nYqpsV!^{~Ll&m&(>dQF^$*!)w!LH~oeN-KC1E!`;c#cR()AW2*mR>{BK5msdMU z6aEZ1G!>}r)lvVWVS{IC*l|QOeTxBxE(}d51TU1_`fHOUTw&;*Brw`=1Dl3*QG9>8 zJ;6@ikF+bG2>Ez}OSjLuGi!z}Pq^34mm8n&gRBmn*oewucd48BZQdZoYE8)=#ITDlx)LdyEDiUh@2Bk|3CutY zAllWc4`PU-K_?j`wR^-ww^=p;Y>^j0IuLAEz=MtMGZsq>U$AJ0O-QzM8D*mddB4N? zECvc+JE-XaH5{?q3_LR-YKGm5&L2Dc2c1mVB^SeSv~&kr+J8fP2ZznH=^+Ebn1cAG z{Yi%nAuPt-yB578T{wzT-=9;6rF>t}3di00 z?%V_4s&~HgF5Wk4hxX64!{*Xm=XMbP88$BPbWGj(F1nOE9RH%hZXEFHOL*U^+r)i; z?$8&1>B_|d=n^IajouPxt17yY&9?^pl*=7YV7hE8{H+NM!uBA>1u zeWmEH_aNUe*;d{CYP#L32~cs@jm!K}49t;kvae8o@r?zdX38 zlQ~rS=mC=eZ2vyBxTHTFqZd3xYYc(p+BH7*(j?{+I?RXR&#-T8ScBTe?}XK2hq844 zB!vM_Eoq|;_4MsETm)o4Zf~DhERH{UAs%?@*xC25_I>x<=0^t)u7wSrWOLh7u(oz; zd}j>n>q8Gcga;2E#BYE5EIe@FJ`AQjY_rGb`~LU;0hgl+ur(S{y#N3J07*qoM6N<$ Ef^w(@a{vGU literal 0 HcmV?d00001 diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..e78d13a60efae3633c08a21cb8761310c835d39d GIT binary patch literal 99450 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 z zo)C1kB^e~yDBB)>@F9_tKTf?M$$;W>aNP*tkeo0c{lZ@RDn4Q}zyQ1w)!VK*pTI70$HQwvJTA3{W_@8z<|~N_l(OconANRDYrJ6M?|A zXA7?)(_72b_SLm!dSOH1rj}Z6`@aJ(n=PAG{0vk{h_v*tA}O`!=RdD4AvR|W0{kAE z1BCk!p%Rwuzu=xg4C7No`6BxO?E`!WOJa|M^IwAZUa&B zrG_UXp(Amkv_(@gZdzVg-oRKPJ;M#=#(?Z3e;rtfqGc`tdM z2Oqp&Px^&3)u4E?^Y6!X`01VfZb{V?P@dIxv;j@e=I==UWzUf&_PV5^#Nm%XpmnWw zr;T%X&SY(Ygy-2Z$h4h;@VN%?f-bxK@{mse@HP1dZ$}L?5Tb57m}-XG_9y^#Hhtel zcja(|wcWrGBP)9F+}km^nT&(}NKj!?={@({Qwc7$c}JY)Bg(@bqvpk83h6I&>JhR~ z0Z=9M__QxZS?Xp^Fg7q#bLhw;lEJ1!ehgJ1QtLsOcpC-&5lfDjP$zT>_2lERv5p`s0;`4;8#QUe+$MvIyc$%pD(Doz?pq?gs(UIK%iWQ(A zetOeph#dL7+D6-0+OS-c{<^=FAv;G5Vz@7x3h)PyqYw4xv)_B~ebR|IUCme*3RZ7+ zU{h%bcLH(~q9mOD1gyNpsuJMxS&u#DX!9;P3^o>VQh!hkwEW_F-YqRWM z)Ojch#B{CI`sev06@HYF%4kBI8||x6UFY11yJNy*QmeZ}4E*4jizJ``m^O0#2Qm&k z(+7ZTbhq7n&gA1hUf5G0@)`nXr=kGrJ9`Q*}I5q-R!jU>Gcfl6kIKs0-K-bnG=FVo-9VBoy>qXA_`AXiDQt?Pya|- zBm04H1TA?zQS|}kS8hO5c?zWj$Ug6dNyJ!2xL}q+5JBJrpmSSsXC?qf0~o+v^a5yg zg4^sV+yZqG&UhVDxBdFscwHwT*P{bia9MH-v7Y{a4*Z2Kk01L5+#6WiPaZ0}ZSgS# zfPn-60l{b5OE0^ulq%0p_+rzR^&;5pyhu`=eb(8Eb~A{y{`wzQgqil*tWfJmnJOL^hmxo{ zLE6C=ZV~}WYGoE46joY-sO%uAg`5{S#N2buqhrDv2qq_k@JN%|e1+^&OYSNefI-`j zAJU@osz1+b&5nfn6Hh!Te1hk-b3u6ns*cGj045~`90p{tFOkVVxbn*_w}J$|^))jf z8(=>EY$=mqI?@iM-fuY+rjy>PB$sqJ?!NmD9gF^`wAMKJ>$&_0vXWi#UT8V;S`1pv z7yKRjY}P0_DBGTpQR$s_*|n8?50}aR&O91OP8SOE=XTgh=l-K8h>5c7^52uB#G!@z z8PPU~v;T3c(G5EGmq%(Q_)+0HL^*cXk>={kcJ)o&^{w@8DKN#?rTTW~0F8iZ;I-s$ z$JPgB%d%}VdQSuzNC23aIo^&DJ8N8~G*Y>WxR&~4T^S^7(WflBg|0*W&B+U$L5RY5 z()NQa^}mC<3k!7v;e~rApwr9`MQVJVb=T3p@>_+b$^zF@JrFyzg(Y8Yu)#(`Gk(9a z+CZ*xieVI)wjd*~ENI4Z{RnZ~qyEH&pTNw*+Coafwi*W71NS|kR9sFIqr8ND*yLPcwn4Z%h_INy0{t6N5~=5p>9S*z9~}xrv&Z6L?mVzCH*&A|Pj!7-S;9B6Bh?@{I5pgCUXST-4L4xW%!r0qJf>9iX$`-U{ za^MDkmSD2tiYuwkg4cmeij^8TpB7*b_9d)Fco(yWO)!l0mSgw*^&SCKpOeqMafhKe z%E8xV?Lcc@9U!!Q-hd-mf40Y2%T__5G9lR{NuYDbS}oE*UhPiuK<7H^dqT7 z*2CukUoe4r2nKMy1T?ho=t?XrZM}75z=;4yN%PcGPnydwy-ZtQ0IHjBxut?&Iwcuq z9P*7H-&dqwqnt~ZgOmSFfkBx|f3@9#p<)%V98VKVxYdPc005b@{n}2CmjYu5i1BKI zGps*lMA~+3`QfX1wd^=wq{#R9{1;U6thEd?LE~>s#7xdog3}IC9!yiEsV4a*l4bC7 zT!-O6OjC22$*hDey8u%S>5OFk)wO<)UAyZw1Tj6OKr89q^(!m`MH7LE{DSz*t&u=D zkZH`B_D`2y>N?0}-`52JD4Q>?-)N6nLLWVN>Kf{`8!+Z9z}Q}*Bq9)SN?vfmc`DjL zKYj~Y;A_j^khh|q4E%kUT886cZXT8&Eu+84kj!=0UZ(*Abo{;22822b5vR$dw-oeK z1I{LBwvVYGC7WzK*mgrNg~I?CMXiom27v+<8*iQEX~dCr7)O*r^l2+bK@VzpPIulX zuS2d74M)aw`tL<$$}#J}X%G1Yu#?js)*tmcZn+@*mxEuWpnz=^*vocNCFD-WAz&0 znv>4IAUuboj{F}T)y9(`H}P}qK!< ztGIZta!=hIFkRQ=T$kVU*ngQ3t-iY4Hhm+<)(1&zxR1$GUvL9#Eu3N?ZHqsldQq3FgiWeEIlD z?f}t-s>`4dGT2OrZ7EVzw3FDNY+Ntm*@B+{c+-PNJbrt(-*&q&{I1p%c|J+umt1lQ zCCM<_il}CE($Uf0FrKSj00r!P+2Lz=uhk!G`g&2_0p&;T5h5q|6>zud}MGjtvV_K3x8Aga> zEg7|q^5UfxoGY!1T#s#e90nRrHZ$!d1b`?M;Bvsg+8Xigp2n^l zy+&;6slR^hT!3zR7#qfcm=l{t%z}(ey{I%CXZPbYSLbtv*HuH0AO<=XP=1{i z2<<}vOOH9+udF}#1#C1}j;l(R0`CD*kvam)XNM(tKd8UhcReX$AOr=3JiX4o?^5)Q z-Nv$tK+5JP-KW>ShMf8;v>F0kK97+La`HH`4fqoT3`zx6KeAoxt62Bboj64Xaz3^7 zw)!~;ZV`lybvI+s4?p~%p6sX*;b9PeFO?CJ`qSY#PFjB$ima}u|C|(T2q2VJCkt_i z=lhi43@%Jt23?%W#ycazYS{7MYwfzabNy2T?S1>K$=#&0K{}U-fVf`vVN3Z( z6jYd~huKRm`I8%s?1w49R^%`>3M;|T!Wyw}z-JT@sPELz7h|v2y6aO=?GFTbt7TfK zw{N-SCLz6Cs^%J`$m>XrNh^%eQ8qm%mdaHH@sV~e!K43dDtzrgr{V-C^Bc4^nieV55VhFE3^`%_x#NiDnl+)8x5o9)r5e}N`%%hBOmjRfM8S(7LfSo_L7S) zac=aivn!;)J11{wLs)A^w*4}hU}sEG8^fR>kv0tY z%6b-fRNB%IKu_}!E+BF83W!EwhNr5FM811~3;t`>}oB?V$2_XtDt^D^7vORpvk z-4N7xeFni!l3;>s5qgo6!AvzWN_pmAOAw({_^h}Wsb!dX4Itkr-Tu0bH=>`Pce-3L zHtWTHO-`oExdPcNz3*n(wzf}m0WLd?ZQ(v;%Ir?tfD@C=cgCA8)5s;A5$EbwU{c`l zzwS4GdeKfwRJQBU|-jhBNmwL?@%I9aX0dz6gvy8ONsO2}6beK(p z0uHLc)XdDmuPkZ(Dyy!dgCV}A_dp%%jrxA}d@xIugX#PW-QLC_*z3g=C*Q>xM zAfPPl;lwa$19D>9sPH3;nbd#!G{51|;-gKdtwg2+U*}zg*nm-wkWIpSBHd+}1=b(k z3^Iog3T>HTCer069)Ch#--B<1-a(cqCq9D?flSO@B7;J799Ca_4Q(%SBIh#M{`Af~ zY7sV(_YEEa#TTR>IyBVi2t=3ay55_C3(6e+8zjgurmMV-PItg>e#V*UYHYL3cKTY; zP^8I6g5{cDCa?dFcPuF~?lN5%f~!pq)kdw-zz;T*>Bu-GN^t9Fef@u6@pZid`lbMF zM<>H(S!>_WT)Pcy4$vkyiguIOGRrPEziZ2v;dA39;Ad@1^EH*me?>Hi9DrnU?okMn zA;A50{|K?f9>A}bVluta)pg{h`kYwutVK2I+(0*@la0!$%7`g!z9*$~0E&(u-3fAn zoEE?20H{w+XTv_a=YW|8t3Fa9}5#+F|0sww)!(ZxBD zgQECnxM7^`1Qq}+(p9_!=#$egXV8hCBaDi0Ctx4}0}yTc^yOqz-lHW?oXgL-_KGtE zsV$!8)?nJPy!b0FEp0(Q0{~*{a%@ZWrp6_iGTNwYQ1}E8j9$_J*>|5kILh@n<9Da) zd)RWTPnld|F3y-mQPC$PvpP!{duvN8Gpay)wq|?a@E&MSJ|&+615^Gz5xo5p2VP$5 znyasr*6CZsLvn@iJcHhG-B%^2^@(~UdT?%+YcpxQ{P}HXE!T4T1*V>-(7l>-_T8y0#vIiT2wQ(MRJ?b7p+g?*j4p@4~RDs^cx8* zc4S#QegsMPH24in1P(qVk;TPl4{!{=I4z>9MWu!fammFOt3WB`1E(Q?8`9qk34NIF zaXQfrn0tI~PbF1lpjM|}zYd!@&uwDcFItqx7GTsJ@VbMOx=5ds@m9(VsJ{G{3d%DLVR`4!EG$SO$ppBAj){cF5m z{R*as@+L}Zxz2QElS(Rr=_QguJ@n86lEQDP0<9{jVF!htzh6>AJ#G0JNIo2e?LX@U zK6oFV;pUH`Naf}l($dAd5R%s91;s-!#BWh~=>QM*&h^x!>s z;E0L>q9Tf|Km$&#$RP4S9tiJFXRoZ^9@pA?f9G5r(y81kc=z3R@44rk@7sH?^;^GT zX?I?HNf&@H7PLq&VG5QH?-<-8~%{e3mdRFNu#ejb7ofPQx}#~xhvtJrG*swCV* zK)C^S?Y}?!)U(14lK>EdybTxwfSWgO&M8ANq0#?7KZfL!7ri z08Ak~r+s~b&x@HCe<(+#J?SY=_5>$P0zgZ~WA5fs9jwEzG0hnZcQv{qgA4`Y3|NZL zJz&Ge#)3@(s5zzsuVOGU1*&>hA5|xQT*b*d5j1B^B49Cw&)>?UJ75RJ`b%1syC~jS z^pCx5+r=L61V0~D#)^pk&cYgRf;ur*az+7w(0|SpZ){$v{m~1;nJ=)g2`tMKxJp>% z&5cBkeQ=jzPeIm<0Ng~Huz!uV=hRy`cG5GVF=n9N?Tpevpv; zy5P}g%~+11Xqo*%!~4nt#sOLyS$xC`+s`GsusQ`oh2pYpSj%*($P>Nb02LwLp59{! zX)l$9)U}}Q8<)Tr#qUnvV@uwfAJt*wh1!>IOf2MQb8_)f+$JnUDJ$G`Ank?o4yFRX z_{Du5OL&yYIPA9;d&^aS)9u4jRv^z=O-Ck%cZ3@rOu-fC?4vm*L_GKt6M)lLtOULR zD7;X*ZD4EwkBidQrwF^i8boUoOfHP)*rQben1+Rk?*$kL_2W*&g>y2_>hg-8SO852 z3XesbZPr9bS)X(O+LG7`u0Lo&0GtAI2uZ}z(MJM()j3f4nqU%BGmMD#t6#bhk6D-IkL8Te<0+1Gj z3gG3he6i$zpAP0H=?}Kl#Xn|$4zv7?U&vdx^lJTEYgF8El^gc?YmKp@19%*9KJ*8GwwL9fZ zE<$HiXhfn)>(n|D%9t>NxT7vDF@=|wB4>T@8ulk~sl4EmXQm#>#!a2#xdRaRsQ1l- zb|88B1HlCm2R{7hkpVis;uWv*-g$U-VEe%!1WdupwqNQ+IVUrF4r4M@2ZX-b=A`%V z9OG0003a$7EDH4CH{bj{bK(gnG5>uH>=!>_0CVqfZe#pO&geJ#{oPB&LfuyMZ&@~C z1H%dX1Lg$4CjllN;1s}Q6R$Y|jP=i=GC@o6C}RnT6W?#&{W(?Xt$s5LK;EDopuE5T zChzb6WezEMJ+Z1 zJCESgJBS(;psRZQITCv1K|tCQGfm{Eqb#heZM-L-O7kbUYWum$rPa{L%wAg}SYbry zdkR~RCP$G`G25isvf%!BkCaiSp|KW$dezOSVT0ZTtd2n8g*%~1{{GaXCvpfj3WQR? zi5Ro;pdOrL>8>lr4SsVt^|V*`Su~`{U;p~`-l+&414-lqu?)yFNE*TkhdDmV`Jr~; zjamfc31A!vUO()%kZuEeZ>atd+5yi5?}dnT;iWYZ>tZ!8!RYa9#whr|2l(-!pv08n zShi9}#$R9vKBAq0jg+Ap-vXgkfa}g-haJJ_`>6q-gUq<3X=YvkD9d0re(rOhVfyyT zzCBsW{sMyN5(7Q2WN(3e8Sn%<5xB;Xrw(Dnx(O~%Tt$FKfi}gfL06Wu1*RQ|>Lvk? zXBU)Cs$>O}1ZIId!WrRymV2?8z6;1W9oGT#n@xT~K%h*qmBmd^X_3)QjKb znPMSDO5{H|hEN6=ZL`pA#c-oG+@NNUIqJk7Nf%bBNv%T}yI$?oikW0$tF01C6b!1h z-O9^d7?y_9w`^Qq`wungGU+^o%J~J8=q=orqc=xR-nH!315Wx+IjjVt9FScwug4-j zihc5dHvqgte_hN@gu@Q^)kqG0;DMb{&mR2t@YpERk?%L1?vCDxwlUw3eRTReNKLF8 z_mvG;kci9%8xW)!I0~VUUiOYfOI_oSRPy6jVX6Y{!O=%Q*Q+OkamiX-sN(nY*Ece- zc?UBBA@P0IkfF`!W|TeR%IpL&n?gO%=tKqe?LT2 zE2}Hb+`hN?Rb^OP*w@{DW#UPucVCq;8;NmSx8!dytT-8=k}nX~z3}nMc3{dVG)bCM4|irU5BEPv5Op43N)}8 z)b|~9k8OfUr zoM~5 zVzhbjg%|k9b>JU?q?2y?vQdHV{0*+0uIC_+H}U8bX+}xwFXFKM-@nKc3RE13nak6s z%5T3m<)qH(UN1}O(cdU$TW}$74D$qdMVKSoa+@Jd61~4%?D3Q@lY#g(`S&dR;XQhC z)>&u0hp+Y%$d~VppPB81gIbasf)O5;v&JMndGI<$dDMX$tgeqZ z-(!M+-a7I8W$c<<7Cy6a2rNQKY_DXgEgtjr%U;7~Q7l&`zCHYK`(S ztmrS=!>}}@mSL&bELVw0+F!k^@Mlj2h-o_8frBSnrc%t&p4r^}hVvysP zq<=XTuBPsuDH#UO2{ag$`u0xvc0)*|EmeI`uiS6HZX4_7HddM1O!wNWfM&;>*B2?r zHC4S%10JdBL305BN1^10EEOgj2sgori<=sg07zM{Ay*_$*EVw=Qd;*`=M|egg zHVPo>(zLU8ofBcla{}1(13Cmyt6y~Ti~Z||B)V|zsP?dNBu zK}il5lENP;GSF~FSE*NfqnqXpYnfIJ5ZMTUNJFp9Nw0Tz-4ZDKC96|fslpdbPZMsd zU3zTO8_PYQ03gC60KS3JAdw{6%YG(vIqS0$7JB3gAejnW*|4AO7NV5-H#OyLTg6} zFW4j?zyS^T3#>ZC1_Pzulh~aIWqES&(GhXsMhAWY9>MiryN<~@FJ-pk^8-zqT#o%F zF@*r7`3kNQfN{L?Rj={w3cf$3Ez5%KYJR`)ang%U(bvw7`0i0(<8M^rF;xyu8h@&- z^jb@NIR()7y%llk8Yi&2J+By$;a0VcI8Ok%5)ZI}!O0Mh@u?ycCA6WM4z1OGYVnK5 zZfLtjRossa?{W5N?0ZRUfi-G+>4pn!seC9evij@A4W{)4P>Wpk%i6d=?tTRX z)Et8Rd-8L)8o?jw&EyoK{(AM48eoWo=Xx=M>^x5AHA!+*YaS84-=A*WFN2|m6Aj3D zx)E@Cf?)H~m%Yrl0)PMaKlMF(QYzvii=EJxNI$T0xN z)?$Vr=I5&^^dymO#0ixq5U@+fq69zx${jlxy!hjnu$&wQt7-P*}GVAe;90r4&fT@@yP_$74o^ji4xAN5C z%jWqnc%eC*v;MrzH+3}f##E-B9f>Z9Yf5Fqm*djf5vaz!8e)*xNPDh7nMg-%1(DXI zrvg{to;;)l93YRMjHd7{cK77XZ+`R3`OW>~cq5hVG57n~DaZ=(PyMQFk1M?%5f6<~ zFg4!N&S=!>8+T+&9bB)MPG~!154{8Q5?#6$P;$$%_3N`^tzwDMZmrk+!p6yc6PtJj zTki6hD0{K6z#D+tt`5-v;}6Wf2RWY*Wi{Fes2P)RrYuM!ZZI+xQj{yVWJZDyY!;{> zU}<*itv7pTUkHDIBfN@Lhp-;A!=y17DbtFee8l(P%%&_CG18C4il9%V5ie&>zDD~! zzkvTlRsqI&@b)`!%R!zEDT7J99UDJBJODtT0^J#0i}HqZeKx8R3*aj2(MmLW;B>go?uI zTRQj{BTRN{6P2u13<4XB{V-aDQ7YOGWcmG+5q{tV-~+sXTMbB-gQ%vb@+@>+JO|(y zU*`XyTtwtJCK4$@NJ2C&divw_Y`yS8Pq}^z(}7`tn*INb4iLrS-g@x`{@>vR03AjS z8y$7u8AIedhBM89r9rwLE48`HyFC3G7_7Mk?Y$KCa*gfdFwpb^ zlQZA+rkAk8%~7F;5Q#q>d#I5GV|(U~T?%j4;ly-(%Nm7L9n7$f1>XDFnaJtm1xZazQW*`hM#=IC2ZUKT1#&}P8+EXjvyfCW6 z*bXP+*RTJ&SHFR}4DLVGk!`lH{f6vKu+e`Vj>ed^U&Ts1I3VHs597xLsD_EfqURMX zsgYCt_-J21Zw}<1i#%R{`0ZnlJ3e9#Ya(|(umurWQqsqDcr!YRP9+6FXVs0H-WM!t*r#E<_!&<2 zy@MwNa94&yFQx=xtY{*7AMU?|Y=f|L@4a{Xo*$r~Gzhg4@Qd_w=mEa^rK^4B;%oo( zpZMpwDL|RX@|;Qe}6i3)p$?;yKV=piVmCb z#<=X*1_FKn_h;L1OJ<~QF}ajTK?6!*lpINC$9?tU!p0W=7>~a{G8~D#egub;K1OgO2n56#P-S6sipm3K zDF6~-ONK>?avWCX-Y;>Oh$|SRzAh^GCQ*EYEX8YCfU`H3`LAO)W+W}+HOLUx^!}wd zHkADsgTk#B8`Z1XA`Ga$Z*|O&oHk4Xz89H5^rfplZ@$Hbr3m+cSqS9SR_FmFc}HIV zCa^<+cxl;NT^fpebj&{8DC>G@h1PefGSrv4kL775l)qm5wIx``tJ9Yb43<7Swpf zQ{NhEYN*?x@*4T-HK0S&%!W9Txr&9mrG)#n@;Tj95ywoDDu*FyPkY0+^3Txp!5IM zXP`?X=(;?J^%^yqqHnDZ>q);m5(pn&SXfzG;5XpMshr4)B9SE~0|gTAJgI1k7ofBQ ztAIYDxhK5_Q6%X?`=9GE&g@jx@l1A7twUex*)CK8k}7T0Tj|27rWaEW9q#cJIvbHZ zMTGAgZe*0bn5!d6_XzI2K@v*7epp1%3cQH>ah%Zb)q63!+<-@bNddsGkR@2$d*pi$ zjseI+iH%nfe`<(S)|Bg#LQIpT8On8313OuW^b=M$ev=pDqa3I9Yz`%bF$em8AGq4> zg+t|l6CV9OumEPTxSk7pI&af~S>6O*o{qi3{f`a~ev zvenL1q+dXDNHi^kO4#aG$hNp|tuGiS9MpLuD2l)W5N6k)lwKARMbguMWNB&R+5)Yu z-4}NRp)UwdLm{fG>$h)>k^@(-&YDI5lfBER4}__?siT%H*J#v*l|pB+QIB&>BF~7Z zD@$(1K4WgLjj02*AW>J#VEV2w(O{__Bce|R&mF{`E3UYlSL zSZGMV{TUlxyplEgSX4QLqf=PsbR)q+Ye!?1ggRb>i6;+j_8So#hA^^3P665l;04e! zz;h2w!X+jsS(PkYyaYi8T3lRUF2FavXb97UxAAMlMu#z9_Dd8$3(Nr0=W94X1YC*Z zPY8=Y2bl^+;8@z!xGPT+lV?Vd<#N!G(8+dW=cZZs?S#vT#Xl;#cXJN zhTr}E>ZUIA&bm~iPUyqPMo|HTJ#^SswBWj`k;IRwm-+_M%m?28KUjbN$INVdeGE6% z0UqG|;}k`I{{VB~pUZB(P!*;zT#@<;D{pI5)?tIgNUs2EBbVQLT|FFny#o@{ieVd$ z81g4EPzW!CmDOc0aI)SHLAQPBTA*_HB_sA%fBB0X2y#1vp3gHc;A#hB!Qc!$=&OZ2 zZxVLBtC((#QRqo*l=A#zkMkiPoHpEa4m|K+w%eo;a5=`}gZBS>Oa}(|_MPwCXpUz&@srt2C*>g| z)t*aMaM9r?6+%+2hYbk*H#QuM*I=a$vr!m=APx9Xb{2Z(v;M?S7cjbnRQb`zejR-h zCW84Gi8?-9WrJU^==0$b0MPFZ{G1Ov^l<;Ymc{Fmi9kLBfuGBc%eMJ8;iadZ=Joxn z@#HUdS(2(f9^O%tcIs-gX)`4$rl)-E0D-+x;J9(F>AH{73>9i?np=QDsn3ZX6gFl1 z@b}$)%{A9B2H@N>YXR=!H}_zc2ljrstOAl1d7}rQS=PyBWFzRz@y(c+!qyvs6yDfw zX8V|3vsUEwqnKM7@JQw%nRdhISc{SEIlgMA7el5xg>0q>V*x@6d@8#Yqs+%*KKhtr zLe!Qh_qDQr}NnS25bu~C3xoD!7sAsOkvc!8UunF+iBP`>ZvD&=Sv z{v3DQasKuA)Z{qjNE4eBqXze55P?4X=_~zn!$jfmBc34^zr4yeo6FP@U$cZcb-H!W z4Y(F`NzV$~n*wy8XPR+>&vU*`tot-|OB1+c5@IWza~{cNVBZpOp;hO08lRrLu&|qB zf`7)l_0hDtnlT?+JTz6mpPUj3$32;bb`Q>(iojlJ-#D=tN=Uz&vaO z2=#zK2>tmTciiTY`I|T0>=$mx{2M&6XvGVIR{iq;un6n{Mxpz%Eg6hTfUY0R6l0KU zFbznK#@^E7rL#)JW6phBQ57=HxM);>nJ6;<|_GtEBcFl$F; z*ZCb%G<>V{H`zYVD+W!nHVHssO2)R6JNyRln%BJMv2EM7-OO+D=^^v#3zCgZ0OlOv&|ks~Iq=@WcgN|ODX-$j!Xm*3yahlTM%w`Ie0cl8FCbJ9 z;bsA82lINwkt18+0OkN-@#1k;qof!cg+_!(8E~Y9i&Gp`27VtHga{0Q#~-ZILDV_o z>Cf=*!(&T@w~)LQQ~cEegM+{kVCD7o>#t)H(GliUmPEsI5st>`Jt5Qgg|wq#8||!~ zG!0JKk2Mt0E^|mzUXL&kEFE4B)6r1&%#>7y#Jp`W1?RMV=7t;>)wk zL{C885zJ2@>m-RpNHD@0WdK?RLLyI~qnl55i#7T`iokX)yozfKbvWBb%!J3lz_HMf zJQ3)TgPbIUI6)*C2R3IV9SdMaaPX<`2-~?CW&~bjRf(A{(hXp!qH@FL(VG z@%7!&DR*BM2Vv$vi9sZ?0>SAJ!c3-q@xr_`W5?pE8(a6Fg>VJ~&c_(E#E9;eTfXm0 z_e0pu{I#!poexz(D}aqGVGpgFF8K}ap1bcdU*X9BGzh1>dFt-ti3O90A|7d1S~P|K}aNxm4J}Km8G7#E@uR2??4g`J>%09u~5m3 z#xqeRqwUFcyNRt8%Wq5rKs2HWAJGlio?WxfZ0q)et>?R~6k%5Nh_0Fqn`{8G>U(^Y zYJVGxtmOGG3fB(QS+q?bZr`PaH79JeKwR z8+{1La^?i+z!9T0^D`8GJq)06z#4gsQPEm+FWBZft_ScN+Z0AP0Cr^a(4JRe^k=Dn zBr8n)UgQnI-g8zUhKdIJmHAJ!ABT3fXo;R6msOd@4Cx#{y_kGB2O7$NQwXtn1TW% z^kco1HDyJS*?j2py>4LQCk=>Og9hNhgAVjn1M~=R1Wt12nt`eK45NxXQgrR!m~QUE zHtU$_k8I+_-*3+19~%p{A1an0I|FGuhe8z=T41oXmzN#`+IkDVa>Tv&+J*DJ{p}5B zA9Kv=#g}Zmjz>9ftmpeg-M+~d*tdChwjKG;GxY38f60uJCdO>ONWD8f5l-9-9W_{k z#(-0wJF-}nnFncuk6I7VsaW!+XH7Qy=#_$4e30=XYWsB@xOu{fC;IX^A^bX|bH&qo zU(3Q7r00T;3n$>zLiW48(8KARUe=Ep++4@4e9q(`~;2ZJwp z2Ld>}mop0S-%5--15Ft!L}V90Km^clC8i(Az)dT*_;li9Q(5F{1$hI|4SemoYq(T@wiGFNVH;hrn6^xw6C>@5u3>||Nx7QO*;8{k9~ zQMDRuO-a=8#Ul}1V;ceWZ8ES<$ghrKq+V^Dm7%qM|QO((@E4MrgR^-g4dJZ!-L62jjfezN^Z0dDYVpY z0Ba$I7>gJIqf?(bIQ0}`@CD#B^n9iqBM5cB{SNSd!Wt}$@!%u)Xv7;jbMhF0h0`9y zpPdif@6(8}!NIHW=%b%!p3PGVxcGR>GN0}<2rmX2*L}>Q^V&9SLwd4EH5zp}94=x6 z9RS&MvMc6X>wcL(^XEt!?$p|v)X4cwV6aKTWD9*d8T^Ebj*tIuZNKosAD(p5Neh=; za>?4lIp>^%l7Be;jMJ~VcsW%p%9%Eg&h&s83~tt%Kut+D6G&fS|EkK3lT|BwJcTr`k>V3~FCMHjM0 z{vIE{jg+984xHKo{M;kV)C1^;k{tHlDFygJw$px)PjgHX@b^c(4S3k^7Zx^|efU&= z0{aU3pb-ujg~uR_Hxa@CG7cPk;jf3*1B)J{98VFpgUI3&D0h5LL(B|BbQwUxA21gH zo7FRz+Weg79OWwr7<#~#e2CKtBI<^7i~_n<1rUAhy6I77&grh`^POozd;JrE-ma_1 z?~CGE;asZ7lfh@5_>;#3)`yfPSlP<^%>_r03F9b`VSdw>|3@5QnF$zf=dD|}E}n75 z87utfpAVCTzu*f&x|~;(!%PDvy&+I;55&MAV+dBqc8%MI^}W}rC(>|KS@F}j$6&|I zW7Z}!Kf-x_IcGb1?*7d;Had(V5q=Xf{<@!;d6%;tHte_{{&@-8ap5EnW}T~fKx<2W zd>%D{!;i8Xr+w;4(Mn836qw0z+=DvBF%q1*wGgH`b=qE1u_#H|gr{&4{M^0w+{4KJ zbsjUo?>y?LW6V*^WZY}dy*#R)gg?gGjx@J&<{=u<%Io*B4r3XAulvTEWVAkD3@30+ zuEl(LZxdWQazCYopX7fOM}EF82z(10vH>VyZ;~lku8ns9;*I-9w6b*U<(FUnK-yzG z*mvu#x30yD_DNp+@7%b!_+&O{bjd$JQV#PNGQ<%?Bf`K!B;BxE(UNpocqCBWZVc0m zGT)dH+HVC4QYA#OrW(&sFR-KT0rkyRLTcs+HtHL3w9FevfzyeCp85bTx#6A*$6pwJ z!dUYKtb&7!@c#Snm*d9P(&*0wXI~o^rk@5tE1y02ZNrJ+njdblU4+|)v{RMfn^S5~ zL2QWe=U&D5sPFH=`a@O#G7C|~K=AXAS?7<60`&nvK1wlVfU;@uQ zp7nRv&+lZwaUbvMa|FBefc=N>^|D>3Leplqd9FAvcTxWJY^WnWejn7+W7;CsmwAG2 zj^fYs9jxbZ{R@sp(t&}L>NfQJLnQwsp`W(XFL`y?MR=S2^{`-{%l-fD%9&4;8i)&{ z3of|eNINWC%MZQ}mu_hcNMyA6K8dT95XW34=!GKhCEJTqnpMJ&#V$8D0#TFb)6D=F zbNm?-aZqRGU*BX+I?+6AV6NFi1)|FFXwuOKFzpw=xYx^Kp^HbkjeNnUu_G{yJK-C+ z5Q#Nu4lmPxsT++}ZIB@6ctX$+`~2X)hm#vv!cGMF8~IjygYQ zyNSm%r$iieRPGqW$xY2guK=L=_py=Zo$TNXu@8&coFWzm=u8TzaPtkaW4YR zUyDK?D=~wL(MgrdZZn;!JlK?Udo*pg0FmA6sIvHMQzaItdH=|D zClR;hQr318luY3ywWJH(PdsIlG{bFcIi#{0lx&DuH~ZY{Psbb%`uhE>MnJ8aQzs`Lo9G|#(l%%o%7(!1|VQH$5| zz3Ni_W+(>2>8>f2vf^}+7tG6M%GZ2>H{pM?G)+?8$!K;+I^ITp2J2?{^;{kNd9Q2J~NA0>bTHKg*inLj! z6(4bIUC*?j|FyAxminX1XxC;1`kulu(im+mE?kI!p#B3WcmMr6{U!lwElff2@77v% zx`KKOa}PcMJW7u=qP=;+@5>d$9{hK-9?$?Jrl9lEV3!ktvK7@>>aOkNFsPE;7EGo> z=xHY1cZAV7Be0`y;z=`n{YUVbSQXUwa=9)0D%q(7_)6$!Q|fC6;8FolWRjuG4-9&|E+6l)<(sTjdj<@&2M@Avk-nNX#R}v2iIdt@nFvp-c)lBMk?7KkZ3?}r&sX@)Cn&XZ{OrVCf^c$S>axxt!xa$} zblj?e_WQxhw{LxyBshQIRgx7ufIeFT7z(>C( zDmS6($1*(Y1mV9}FH@7eOXU{^-#&lhio3^=kRNCtL;sv*v`PHg{5X`uDg;0xxQGP=u#)!%0yhhxj6BBs06W)e4fRaR9?H(syUgkTi)}$N8lc$H1s-6hWllpBDvizi^L-%5IwXE)opkL)B)9C>sc7wO((lJ&%}N`7Qfwv? zB&}+Fg}*yKu(A06cEg1J1HR6u|1hR48uK{xAKWu6aL=&z$xnUi11uwa7msnsW=kw9 z=!T@DF5^fr+m)4)Ss<5VvRrj?kjX}db!e$ReXY@!--WoOMiQ-&sjTzZf)f1EddqBtFJB9#jJL7h<&tS5k-A2ddM6s;vzt~?DO{LRY1s$H z(t8jRr71Yp2wq5m*;~Y342ERttr6K2`Fff(Vs`0w$oGJvfXXV>V8>~fWRE>d9=twa zR;Fc<%fM&J*GM%&w34GNvlnhxrUvnXxr&X}YKh;Qfj>w20?VIsO#dDjSGb?)f+t_O z?V?*H>8JNGnAsox8Gq}B8{WgjpzE1{Vm<%RSX&8oY;){o@RcMLAbw8Ngm>7myBFLf z9z^-IrwbOw8}*$Z_u$s=U$~+QGn>4rq-R`i-(HeT8JjBLv;4fBVR=sV%|ayKDryO0 zBtRk63zOA-aXx8D%!zSPQ&e=GBnit5WsITc*`z4IloyM&)Q=VrNk)YroKN_4mRaGp zMdcZ|2yK@QK6=-QdniAYN#${jPMaq1OIv;T1w)Q>8%S-Mt-Ln%JiT?`DV0bVuldgt zO;;po1q-fHJ*4eNYfLkt%29bu<^Cw66N*}jfZ*WQ12f11ALkz-zGiYr{Pp?~OaBRr zAASF<#Xq+Kh*f|`4nO|!j~_TpHhzBd9*ajU?Oq0PdxIuqG&`lp^;C;pE;XBOFkwC+ zhak3`!X49tQPW1M{50cP5;tQi1AO5n8xAk(FW`y^*<>JO=VY1LCY_S%EH|f#29}!F zR=C|pS0T2vvK=hNXw8MB?TXnrrIM38m^#cHwV7t_EKB-$;>Sg27OU5fUdoHSLVk*&2cWh^tinZ#l{eIvYUE{mR$-JQ{PKO{Y~1&GBJ=OAxcrj;*07F#(+xmQ2F^S0 zZ=SZe(SC+qXO3WX8SV}AC9hGbG7aszimRn=?qJ*@?A&N| zIm${{fYa#=rfu2u`&G&WNH{n{Bu< zK9f>)nVpvw*qAy~oaTC?jy<^eyZ^j@ecQyJ60;YDJVj5V?lGA!NS&lm`Pg#k;pT&SaD;kvL98xoxsld?~3-#FMO1qdnD!ltPV)y|U3A z4~a}g1q@YHQO;|Zk5`#JD(Xg@)0#3PgH}0l*Bm+lS;RGxg(r(;A~tm9LY8}pQ9!Q z`3&sn%_Bea-_zp9T3yfLUv~wNKECpmuPmH=^2uu-{_uwn+qh}-B`i95HrqD4;vz(r zx~?qs*eF%ySzJ_yjf~5AOHX0N*1sn#kOszA+gkZm2GQ@yCW83OBl z%p!mGt&;Qjhqa0nO{KCSo&Be(60Hmo3N>i=!c+)S;QcFDH&B*^rB z&S?MV)@rmOs(5WlS%QrAEI0W(nY4es`(^iYQocMB|DX1$ES zHL+;IO0e=xB-JVf0GyxCG%bFH4F3IZy7(XW27nKY1yN%B*}LC;AGztPmzH;b)&wsB z=AZhOz$P@vgh_-%99D3D?~O(3mHwsl|WqVoc&(_3aQG`(U-?~F4e8>OQ~p^+AR zI__j1MpMe#$h5TLlnQ%HLb@ye{rnhCv|h0)5L^l+MWtFCqpY^I=*lkv=km*!@1ctq zp7JyxeD4rhcJg;Ci|uxvPqn#b@m|!Mbb34|rb59=WuBnyQXvdgWxVF@%dbl~u+034x(P+bDC;S8dQd_B>q&qNNA21&kL$d1 zH8203v5U~#@S-(NwJbUzN&C!6E-)&~StYy?J4FM3=H(`YJE|0}VAvarQ) zuB?~@-zC7$7~wcw%I)wB$dP(oRrtKA!PYUBrfoR@W?uAm!~j(5plKk*B*ecK8`>Ld zE@x((TnM%x*%?SP1-x=` zq84rT6#LFd=AR}fL>8mYqqmt^;ZnKF^! zP~r%b)7yEpvXr@^*iC?D@+&Xkpqkyw(>s;+>iw-+xE@F=AFlv`g|H#WG3EP)OmMQf z(aXQanPTD*t1(K~uWSe|wOi?p)J{lvBUUGndRm{w@IK=b#r-C^s4q8hJh|XZ3LYc+LNX-8OtVgZ2kR{n+b zRHHrHkO5PfTe4@E1t zRZK&Fk9nL3WZrlp91CmtKLKs#)1A?1hyyHfB`Vzqxr-P1^w zF2x(R$6hN$7FSOFbto+L#**8j(hsLMT*Yl^4Mjbo(;Kdo#Hm01ZA$*%>jq%Tc*Z1v z5Fa1>;78ubh~azq?RgR-m%2(I+ZyLHvg z)j$U5Dla@&F&sN|;Cylw0FjDcouC(|6Su7HGK!1MUQMq}Et(UILsM+GNr*Lor!+~37 z4(+u+=U2SVPFFv;W5*V4RH+^QTnQ8-c#cDap0d17R7N|omw?+5=U)cbRpeE+N z{Gg zmHaV>Pa;=VlIPMyL;sBRzm5uMT?Lkm8+wWxQT?89h#DFrheTHD1&gf*?Og%?s?Cq|a!QV2<|42s=& z;kqipch0B~in5@P%RPL2^9QPlJ7o}Q^AsZ1A|$*9nl?oXc}yEXPW^^s=r znoMGna{Ait-AlXpRou$E`cL?`|A1@d?=rssgUc_z_y_56e;=j(Kj;P^rv)5HI3RyO z{=@s;_r86(H8_ahzysH4*up_%hw}log^i64;eS4eZ;gZauG+$%@5Qae-f+TAAdRaP zUeaj7no$+lSp0GwJKb1tb)wSe8IXW~oPkTI<(YSq8z=UWke67e8SCWa`U?K@wIPrll7S24me#x6PY>XJVB9=A8s4oEjgGr7waRV5V z#Z`%PED#vKHkw8~avU7Q#{zY8>LX=L__~`4w#^Xc*!*uCChqg%9cfNPNEM!jSC0K* zg8n*T0O36kJ=_$@l>RZNWnt%v!})i@srRmY8e1J25;ZpLX5t$|`(^D=T?_vn`AyQ3 z11w7JX*wNzF*D$eobXRS!~nuWAhb45ATed8c!cSL!_@5NFHiVgeVAYWL;O1c+&wg1 zqF+rn_!l=FHf&g)EG@0Atn9tIYuAr=kB#e2I_VsKTfYk1cKS_Ne7}p2{{y)HN*!M9 R-_rm9002ovPDHLkV1iYbZ{Gj_ literal 0 HcmV?d00001 diff --git a/desktop/src-tauri/icons/icon.png b/desktop/src-tauri/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..aae1a36e0abaad6db2d7437b73cafb4465c276de GIT binary patch literal 244327 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 z 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 rdNHxY~qOhSgNrc6%0pF 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