diff --git a/Dockerfile b/Dockerfile index 945a35d..b0f2aa5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,8 +25,9 @@ RUN curl -fsSL https://astral.sh/uv/install.sh | sh \ COPY pyproject.toml uv.lock* Readme.md LICENSE* ./ # --- install dependencies only (skip local project) --------------------------- +ARG OWILIX_EXTRAS=http RUN uv venv .venv \ - && uv sync --frozen --no-install-project \ + && if [ -n "$OWILIX_EXTRAS" ]; then uv sync --frozen --extra "$OWILIX_EXTRAS" --no-install-project; else uv sync --frozen --no-install-project; fi \ && .venv/bin/python -c "import sys; print('VENV:', sys.executable)" # --- copy project sources ----------------------------------------------------- @@ -34,7 +35,7 @@ COPY owilix/ ./owilix/ # COPY any other src/package dirs if you have them # --- install the project itself ----------------------------------------------- -RUN uv sync --frozen \ +RUN if [ -n "$OWILIX_EXTRAS" ]; then uv sync --frozen --extra "$OWILIX_EXTRAS"; else uv sync --frozen; fi \ && .venv/bin/owilix --help >/dev/null || true # --- runtime setup ------------------------------------------------------------ @@ -44,6 +45,8 @@ ENV PYTHONPATH=/app VOLUME ["/home/owi/.owi", "/data"] +EXPOSE 8080 + USER owi ENTRYPOINT ["owilix"] diff --git a/docker-compose/docker-compose-http.yml b/docker-compose/docker-compose-http.yml new file mode 100644 index 0000000..46275f0 --- /dev/null +++ b/docker-compose/docker-compose-http.yml @@ -0,0 +1,18 @@ +version: "3.9" + +services: + owilix-http: + build: + context: .. + dockerfile: Dockerfile + args: + OWILIX_EXTRAS: http + image: owilix:http + command: ["http", "serve", "--host", "0.0.0.0", "--port", "8080"] + environment: + OWS_OWI_PATH: /home/owi/.owi + volumes: + - "${HOME}/.owi:/home/owi/.owi" + - "../data:/data" + ports: + - "8080:8080" diff --git a/docs/branch/feature-http-server.md b/docs/branch/feature-http-server.md new file mode 100644 index 0000000..2fd73cd --- /dev/null +++ b/docs/branch/feature-http-server.md @@ -0,0 +1,59 @@ +# Branch: feature/http-server + +## Current Status + +**Phase**: Implemented and smoke-tested +**Started**: 2026-05-01 + +This branch adds an optional HTTP API server for OWILIX. The normal CLI install remains lightweight: FastAPI and Uvicorn are only installed through the `http` extra. Docker builds install that extra by default and expose port `8080`. + +## Implemented Surface + +- `owilix http serve` starts the API server. +- `GET /health` reports service name, API title, version, and OK state. +- `GET /auth/status` reports whether a refresh token or `PY4LEXIS_TOKEN` is available. +- `POST /auth/device/start` starts OAuth device login. +- `GET /auth/device/{state_id}` polls device login state. +- `GET /local/ls` lists local datasets with response pagination metadata. +- `GET /remote/ls` lists remote datasets with response pagination metadata. +- `POST /remote/search` runs remote index search. +- `POST /remote/pull` submits a background pull job. +- `GET /jobs` and `GET /jobs/{job_id}` expose in-process job state and captured logs. + +## Verification + +- Unit tests: `uv run pytest tests/owilix/http -q` passed. +- CLI import: `uv run owilix http --help` works. +- Stdlib shadowing check: importing `http.client`, `urllib3`, and `owilix.http_api.server` from inside `owilix/` works. +- End-to-end smoke test: + - started the server on `127.0.0.1:18080` + - verified `/health` and `/auth/status` + - submitted `/remote/pull` for one parquet file from dataset `22e8ad18-3fed-11f1-80b3-4e551b7c73be` + - polled `/jobs/{job_id}` until `succeeded` + - verified the pulled file under `/data/owishards/public/licenses/22e8ad18-3fed-11f1-80b3-4e551b7c73be` + +## Key Decisions + +1. **HTTP dependencies are optional** + - **Reason**: The CLI should remain installable without FastAPI/Uvicorn. + - **Implementation**: `pyproject.toml` defines an `http` extra. + +2. **Internal package is `owilix.http_api`, not `owilix.http`** + - **Reason**: A package named `owilix/http` shadowed Python's stdlib `http.client` when commands were run from inside the package directory. + - **Public CLI**: The command remains `owilix http`. + +3. **Long-running operations use jobs** + - **Reason**: Pull, push, upload, query, slice, and WARC operations can run for a long time and should not block a request. + - **Current scope**: Jobs are in-process and disappear on server restart. + +4. **Repository-level pagination is deferred** + - **Reason**: `AggregatedRepository.list()` currently fetches full lists and deduplicates across repositories. True backend pagination requires a repository contract change and probably py4lexis/DDI support for exposing `start`/offset. + - **Current behavior**: HTTP list endpoints return pagination metadata but slice after fetching. + +## Open Follow-Ups + +- Add HTTP-side caching for `remote/ls`: return cached results immediately, refresh in the background, and allow requests to force live data. +- Decide whether to expose local dataset files through native HTTP endpoints, an S3-compatible facade, or both. +- Evaluate mounting a FastAPI S3-compatible local filesystem server under the OWILIX HTTP app. +- Add durable job storage if the API is used as a long-running service. +- Port additional CLI commands on top of the job substrate. diff --git a/owilix/cli/__init__.py b/owilix/cli/__init__.py index 2c191e8..44e835b 100644 --- a/owilix/cli/__init__.py +++ b/owilix/cli/__init__.py @@ -125,6 +125,7 @@ from .config import app as config_app from .admin import app as admin_app from .plugin import app as plugin_app from .batch import app as batch_app +from .http import app as http_app app.add_typer(remote_app, name="remote") app.add_typer(local_app, name="local") app.add_typer(query_app, name="query") @@ -132,6 +133,7 @@ app.add_typer(config_app, name="config") app.add_typer(admin_app, name="admin") app.add_typer(plugin_app, name="plugin") app.add_typer(batch_app, name="batch") +app.add_typer(http_app, name="http") def cli_main(): diff --git a/owilix/cli/http.py b/owilix/cli/http.py new file mode 100644 index 0000000..8578ee1 --- /dev/null +++ b/owilix/cli/http.py @@ -0,0 +1,24 @@ +"""HTTP server CLI commands.""" + +import typer + +app = typer.Typer( + name="http", + help="Optional HTTP API server", + no_args_is_help=True, +) + + +@app.command() +def serve( + host: str = typer.Option("127.0.0.1", "--host", help="Bind host"), + port: int = typer.Option(8080, "--port", "-p", help="Bind port"), + reload: bool = typer.Option(False, "--reload", help="Enable uvicorn reload"), +): + """Run the optional HTTP API server.""" + try: + from owilix.http_api.server import serve as run_server + except RuntimeError as exc: + raise typer.BadParameter(str(exc)) from exc + + run_server(host=host, port=port, reload=reload) diff --git a/owilix/http_api/__init__.py b/owilix/http_api/__init__.py new file mode 100644 index 0000000..5a1890f --- /dev/null +++ b/owilix/http_api/__init__.py @@ -0,0 +1,2 @@ +"""Optional HTTP interface for owilix.""" + diff --git a/owilix/http_api/auth.py b/owilix/http_api/auth.py new file mode 100644 index 0000000..2afb86e --- /dev/null +++ b/owilix/http_api/auth.py @@ -0,0 +1,141 @@ +"""Device-flow authentication helpers for the optional HTTP server.""" + +from __future__ import annotations + +import os +import threading +import time +import uuid +from base64 import b64decode +from dataclasses import dataclass, field +from typing import Any + +from requests import post + + +@dataclass +class DeviceLoginState: + id: str + login_url: str + device_code: str + expires_at: float + interval: int = 3 + status: str = "pending" + error: str | None = None + username: str | None = None + refresh_token: str | None = field(default=None, repr=False) + + def public_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "login_url": self.login_url, + "expires_at": int(self.expires_at), + "interval": self.interval, + "status": self.status, + "error": self.error, + "username": self.username, + } + + +class DeviceAuthManager: + """Manage short-lived OAuth device-login attempts.""" + + def __init__(self, token_path: str): + self.token_path = token_path + self._lock = threading.Lock() + self._states: dict[str, DeviceLoginState] = {} + + def start(self) -> DeviceLoginState: + from py4lexis.core import kck_session as kck + from py4lexis.models.tokens import Tokens + + client = kck.kck_oi(offline_access=True) + headers = { + "ContentType": b64decode(b"YXBwbGljYXRpb24veC13d3ctZm9ybS11cmxlbmNvZGVk").decode("utf-8") + } + body = { + "client_id": client._cl, + "client_secret": client._cl_s, + "scope": f"{client._Clr.yhbrr(kck._sc)} {client._Clr.yhbrr(kck._sc_off)}", + } + device_url = f"{client.url}realms/{client.realm_name}/protocol/openid-connect/auth/device" + response = post(device_url, data=body, headers=headers, timeout=30) + response.raise_for_status() + content = response.json() + + state = DeviceLoginState( + id=str(uuid.uuid4()), + login_url=content["verification_uri_complete"], + device_code=content["device_code"], + expires_at=time.time() + int(content.get("expires_in", 120)), + interval=max(1, int(content.get("interval", 3))), + ) + with self._lock: + self._states[state.id] = state + + thread = threading.Thread( + target=self._poll_until_complete, + args=(state.id, client, Tokens, headers), + daemon=True, + ) + thread.start() + return state + + def get(self, state_id: str) -> DeviceLoginState | None: + with self._lock: + return self._states.get(state_id) + + def has_refresh_token(self) -> bool: + if os.environ.get("PY4LEXIS_TOKEN"): + return True + return os.path.exists(self.token_path) and os.path.getsize(self.token_path) > 0 + + def _poll_until_complete(self, state_id: str, client: Any, tokens_model: Any, headers: dict[str, str]) -> None: + from py4lexis.core import kck_session as kck + + token_url = f"{client.url}realms/{client.realm_name}/protocol/openid-connect/token" + while True: + with self._lock: + state = self._states[state_id] + if time.time() >= state.expires_at: + state.status = "expired" + state.error = "Device login expired before the user completed authentication." + return + + response = post( + token_url, + data={ + "device_code": state.device_code, + "client_id": client._cl, + "client_secret": client._cl_s, + "grant_type": client._Clr.yhbrr(kck._dvc_cd), + }, + headers=headers, + timeout=30, + ) + if response.status_code == 200: + content = response.json() + content.update({"username": client._get_username(content)}) + tokens = tokens_model.model_validate(content) + self._write_refresh_token(tokens.refresh_token) + with self._lock: + state.status = "authenticated" + state.username = tokens.username + state.refresh_token = tokens.refresh_token + return + + error = response.json().get("error", "") if response.content else "" + if error not in ("authorization_pending", "slow_down"): + with self._lock: + state.status = "failed" + state.error = error or f"Token endpoint returned HTTP {response.status_code}" + return + + time.sleep(state.interval + (2 if error == "slow_down" else 0)) + + def _write_refresh_token(self, token: str) -> None: + os.makedirs(os.path.dirname(self.token_path), exist_ok=True) + with open(self.token_path, "w", encoding="utf-8") as file: + file.write(token) + os.chmod(self.token_path, 0o600) + diff --git a/owilix/http_api/jobs.py b/owilix/http_api/jobs.py new file mode 100644 index 0000000..547c0d5 --- /dev/null +++ b/owilix/http_api/jobs.py @@ -0,0 +1,102 @@ +"""In-process job tracking for long-running HTTP operations.""" + +from __future__ import annotations + +import time +import traceback +import uuid +from concurrent.futures import Future, ThreadPoolExecutor +from dataclasses import dataclass, field +from inspect import signature +from threading import Lock +from typing import Any, Callable + +from rich.console import Console + +from owilix.core.types import CommandResult + + +@dataclass +class Job: + id: str + operation: str + status: str = "queued" + created_at: float = field(default_factory=time.time) + started_at: float | None = None + finished_at: float | None = None + result: dict[str, Any] | None = None + error: str | None = None + traceback: str | None = None + logs: str = "" + + def public_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "operation": self.operation, + "status": self.status, + "created_at": int(self.created_at), + "started_at": int(self.started_at) if self.started_at else None, + "finished_at": int(self.finished_at) if self.finished_at else None, + "result": self.result, + "error": self.error, + "logs": self.logs, + } + + +class JobManager: + """Run blocking functions in background threads and expose pollable state.""" + + def __init__(self, max_workers: int = 2): + self._executor = ThreadPoolExecutor(max_workers=max_workers) + self._lock = Lock() + self._jobs: dict[str, Job] = {} + self._futures: dict[str, Future] = {} + + def submit(self, operation: str, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Job: + job = Job(id=str(uuid.uuid4()), operation=operation) + with self._lock: + self._jobs[job.id] = job + future = self._executor.submit(self._run, job.id, func, args, kwargs) + with self._lock: + self._futures[job.id] = future + return job + + def get(self, job_id: str) -> Job | None: + with self._lock: + return self._jobs.get(job_id) + + def list(self) -> list[Job]: + with self._lock: + return list(self._jobs.values()) + + def _run(self, job_id: str, func: Callable[..., Any], args: tuple[Any, ...], kwargs: dict[str, Any]) -> None: + console = Console(record=True, width=120) + with self._lock: + job = self._jobs[job_id] + job.status = "running" + job.started_at = time.time() + + try: + try: + accepts_console = "console" in signature(func).parameters + except (TypeError, ValueError): + accepts_console = False + if accepts_console and "console" not in kwargs: + kwargs["console"] = console + value = func(*args, **kwargs) + result = value.as_dict() if isinstance(value, CommandResult) else {"success": True, "result": value} + status = "succeeded" if result.get("success", True) else "failed" + with self._lock: + job = self._jobs[job_id] + job.status = status + job.result = result + job.finished_at = time.time() + job.logs = console.export_text(clear=False) + except Exception as exc: + with self._lock: + job = self._jobs[job_id] + job.status = "failed" + job.error = str(exc) + job.traceback = traceback.format_exc() + job.finished_at = time.time() + job.logs = console.export_text(clear=False) diff --git a/owilix/http_api/server.py b/owilix/http_api/server.py new file mode 100644 index 0000000..421a304 --- /dev/null +++ b/owilix/http_api/server.py @@ -0,0 +1,228 @@ +"""FastAPI application for exposing selected owilix functionality.""" + +from __future__ import annotations + +import os +from functools import lru_cache +from typing import Any + +from owilix._version import __version__ +from owilix.core.manager import OWILIXEnv, OWIlixConfig +from owilix.core.manager.manager import OWIlixManager +from owilix.core.tasks.search import remote_search +from owilix.core.utils import split_query_access +from owilix.http_api.auth import DeviceAuthManager +from owilix.http_api.jobs import JobManager + +try: + from fastapi import Depends, FastAPI, HTTPException, Query + from pydantic import BaseModel, Field +except ImportError as exc: # pragma: no cover - exercised by CLI import path + raise RuntimeError("Install owilix with the 'http' extra to use the HTTP server.") from exc + + +class RemoteSearchRequest(BaseModel): + terms: str + language: str = "eng" + representation: str = "main_content" + limit: int = Field(default=10, ge=1, le=1000) + conjunctive: bool = False + + +class RemotePullRequest(BaseModel): + specifier: str = "all" + files: str = "**/*" + language: str | None = None + overwrite: bool = False + push_to_remote: str | None = None + threads: int = Field(default=1, ge=1, le=32) + + +def _manager() -> OWIlixManager: + owi_path = OWILIXEnv.values.owi_path + config_path = os.getenv("OWILIX_CONFIG") or os.path.join(owi_path, "owilix.cfg") + config = OWIlixConfig(config_path) + return OWIlixManager(owi_path=owi_path, config=config) + + +@lru_cache(maxsize=1) +def get_manager() -> OWIlixManager: + return _manager() + + +@lru_cache(maxsize=1) +def get_auth_manager() -> DeviceAuthManager: + manager = get_manager() + return DeviceAuthManager(manager._refresh_token_fn) + + +@lru_cache(maxsize=1) +def get_job_manager() -> JobManager: + return JobManager() + + +def require_auth(auth: DeviceAuthManager = Depends(get_auth_manager)) -> None: + if not auth.has_refresh_token(): + raise HTTPException(status_code=401, detail={"error": "auth_required"}) + + +def _dataset_record(ds: Any) -> dict[str, Any]: + metadata = ds.metadata + record = { + "id": metadata.get("internalID") or metadata.get("id"), + "title": metadata.get("title"), + "collectionName": metadata.get("collectionName"), + "dataCenter": getattr(ds, "dataCenter", None) or metadata.get("dataCenter"), + "zone": getattr(ds, "zone", None) or metadata.get("zone"), + "access": getattr(ds, "access", None) or metadata.get("access"), + "startDate": str(metadata.get("startDate")) if metadata.get("startDate") else None, + "endDate": str(metadata.get("endDate")) if metadata.get("endDate") else None, + "size": metadata.get("totalSize"), + "fileCount": metadata.get("fileCount"), + "objectCount": metadata.get("objectCount"), + } + path = getattr(ds, "path", None) + if path is not None: + record["path"] = path + return record + + +def _page(records: list[dict[str, Any]], offset: int, limit: int | None) -> dict[str, Any]: + end = None if limit is None else offset + limit + page_records = records[offset:end] + return { + "count": len(page_records), + "total": len(records), + "offset": offset, + "limit": limit, + "has_more": offset + len(page_records) < len(records), + "datasets": page_records, + } + + +def create_app() -> FastAPI: + app = FastAPI(title="OWILIX HTTP API", version=__version__) + + @app.get("/health") + def health() -> dict[str, Any]: + return { + "ok": True, + "service": "owilix-http", + "title": "OWILIX HTTP API", + "version": __version__, + } + + @app.get("/auth/status") + def auth_status(auth: DeviceAuthManager = Depends(get_auth_manager)) -> dict[str, Any]: + return {"authenticated": auth.has_refresh_token()} + + @app.post("/auth/device/start") + def auth_device_start(auth: DeviceAuthManager = Depends(get_auth_manager)) -> dict[str, Any]: + state = auth.start() + return state.public_dict() + + @app.get("/auth/device/{state_id}") + def auth_device_status(state_id: str, auth: DeviceAuthManager = Depends(get_auth_manager)) -> dict[str, Any]: + state = auth.get(state_id) + if state is None: + raise HTTPException(status_code=404, detail={"error": "unknown_login"}) + return state.public_dict() + + @app.get("/jobs") + def jobs(job_manager: JobManager = Depends(get_job_manager)) -> dict[str, Any]: + records = [job.public_dict() for job in job_manager.list()] + return {"count": len(records), "jobs": records} + + @app.get("/jobs/{job_id}") + def job_status(job_id: str, job_manager: JobManager = Depends(get_job_manager)) -> dict[str, Any]: + job = job_manager.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail={"error": "unknown_job"}) + return job.public_dict() + + @app.get("/local/ls") + def local_ls( + specifier: str = Query("all"), + offset: int = Query(0, ge=0), + limit: int | None = Query(None, ge=1), + manager: OWIlixManager = Depends(get_manager), + ) -> dict[str, Any]: + spec = manager.parse_specifier(specifier) + access, query = split_query_access(spec.get("query")) + datasets = manager.local.list( + access=access, + day=spec.get("day"), + duration=spec.get("duration") or 0, + query=query, + ) + records = [_dataset_record(ds) for ds in datasets] + return _page(records, offset=offset, limit=limit) + + @app.get("/remote/ls", dependencies=[Depends(require_auth)]) + def remote_ls( + specifier: str = Query("all"), + offset: int = Query(0, ge=0), + limit: int | None = Query(None, ge=1), + manager: OWIlixManager = Depends(get_manager), + ) -> dict[str, Any]: + spec = manager.parse_specifier(specifier) + access, query = split_query_access(spec.get("query")) + datasets = manager.remote_data.list( + datacenter=spec.get("data_center"), + access=access, + day=spec.get("day"), + duration=spec.get("duration") or 0, + query=query, + ) + records = [_dataset_record(ds) for ds in datasets] + return _page(records, offset=offset, limit=limit) + + @app.post("/remote/search", dependencies=[Depends(require_auth)]) + def search(payload: RemoteSearchRequest, manager: OWIlixManager = Depends(get_manager)) -> dict[str, Any]: + result = remote_search( + manager=manager, + terms=payload.terms, + language=payload.language, + representation=payload.representation, + limit=payload.limit, + conjunctive=payload.conjunctive, + ) + if not result.success: + raise HTTPException(status_code=500, detail={"error": result.msg, "type": result.error_type}) + return {"count": len(result.json), "results": result.json} + + @app.post("/remote/pull", dependencies=[Depends(require_auth)]) + def pull( + payload: RemotePullRequest, + manager: OWIlixManager = Depends(get_manager), + job_manager: JobManager = Depends(get_job_manager), + ) -> dict[str, Any]: + from owilix.core.tasks.remote import remote_pull + + job = job_manager.submit( + "remote.pull", + remote_pull, + manager=manager, + specifier=payload.specifier, + files=payload.files, + language=payload.language, + overwrite=payload.overwrite, + push_to_remote=payload.push_to_remote, + num_threads=payload.threads, + auto_yes=True, + ) + return job.public_dict() + + return app + + +app = create_app() + + +def serve(host: str = "127.0.0.1", port: int = 8080, reload: bool = False) -> None: + try: + import uvicorn + except ImportError as exc: + raise RuntimeError("Install owilix with the 'http' extra to run the HTTP server.") from exc + + uvicorn.run("owilix.http_api.server:app", host=host, port=port, reload=reload) diff --git a/pyproject.toml b/pyproject.toml index 68d1bb3..345e746 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,10 @@ owilix = "owilix.cli:cli_main" [project.optional-dependencies] plugins = [] +http = [ + "fastapi>=0.115,<1.0", + "uvicorn[standard]>=0.30,<1.0", +] irods-tcp = [ "python-irodsclient>=2.0.0", ] diff --git a/tests/owilix/http/test_auth.py b/tests/owilix/http/test_auth.py new file mode 100644 index 0000000..a21d33b --- /dev/null +++ b/tests/owilix/http/test_auth.py @@ -0,0 +1,18 @@ +from owilix.http_api.auth import DeviceLoginState + + +def test_device_login_state_public_dict_hides_refresh_token(): + state = DeviceLoginState( + id="login-1", + login_url="https://example.test/login", + device_code="secret-device-code", + expires_at=1234, + refresh_token="secret-refresh-token", + ) + + public = state.public_dict() + + assert public["id"] == "login-1" + assert public["login_url"] == "https://example.test/login" + assert "refresh_token" not in public + assert "device_code" not in public diff --git a/tests/owilix/http/test_server.py b/tests/owilix/http/test_server.py new file mode 100644 index 0000000..e3d68f2 --- /dev/null +++ b/tests/owilix/http/test_server.py @@ -0,0 +1,104 @@ +from types import SimpleNamespace + +from fastapi.testclient import TestClient + +from owilix.http_api.jobs import JobManager +from owilix.http_api.server import create_app, get_job_manager, get_manager + + +def test_health_endpoint(): + client = TestClient(create_app()) + + response = client.get("/health") + + assert response.status_code == 200 + body = response.json() + assert body["ok"] is True + assert body["service"] == "owilix-http" + assert body["title"] == "OWILIX HTTP API" + assert body["version"] + + +class FakeLocalRepo: + def __init__(self): + self.calls = [] + + def list(self, **kwargs): + self.calls.append(kwargs) + return [ + SimpleNamespace( + metadata={ + "internalID": "dataset-1", + "title": "Dataset One", + "collectionName": "main", + "startDate": "2026-01-01", + "totalSize": 123, + "fileCount": 2, + "objectCount": 1, + "access": "public", + }, + path="/tmp/dataset-1", + access="public", + ) + ] + + +class FakeManager: + def __init__(self): + self.local = FakeLocalRepo() + + def parse_specifier(self, specifier): + assert specifier == "all" + return {"query": None, "day": None, "duration": 0} + + +def test_local_ls_endpoint_returns_local_datasets(): + app = create_app() + manager = FakeManager() + app.dependency_overrides[get_manager] = lambda: manager + client = TestClient(app) + + response = client.get("/local/ls") + + assert response.status_code == 200 + body = response.json() + assert body == { + "count": 1, + "total": 1, + "offset": 0, + "limit": None, + "has_more": False, + "datasets": [ + { + "id": "dataset-1", + "title": "Dataset One", + "collectionName": "main", + "dataCenter": None, + "zone": None, + "access": "public", + "startDate": "2026-01-01", + "endDate": None, + "size": 123, + "fileCount": 2, + "objectCount": 1, + "path": "/tmp/dataset-1", + } + ], + } + assert manager.local.calls == [{"access": "public", "day": None, "duration": 0, "query": {}}] + + +def test_jobs_endpoint_returns_submitted_job(): + app = create_app() + job_manager = JobManager() + app.dependency_overrides[get_job_manager] = lambda: job_manager + client = TestClient(app) + + job = job_manager.submit("test.operation", lambda: {"value": 1}) + response = client.get(f"/jobs/{job.id}") + + assert response.status_code == 200 + body = response.json() + assert body["id"] == job.id + assert body["operation"] == "test.operation" + assert body["status"] in {"queued", "running", "succeeded"} diff --git a/uv.lock b/uv.lock index db9b980..4eed840 100644 --- a/uv.lock +++ b/uv.lock @@ -103,6 +103,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -472,6 +481,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] +[[package]] +name = "fastapi" +version = "0.136.1" +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/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, +] + [[package]] name = "fastwarc" version = "0.15.2" @@ -607,6 +632,21 @@ 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.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, +] + [[package]] name = "httpx" version = "0.27.2" @@ -1047,6 +1087,10 @@ dependencies = [ ] [package.optional-dependencies] +http = [ + { name = "fastapi" }, + { name = "uvicorn", extra = ["standard"] }, +] irods-tcp = [ { name = "python-irodsclient" }, ] @@ -1089,6 +1133,7 @@ requires-dist = [ { name = "ciff-toolkit", specifier = ">=0.1.1" }, { name = "click", specifier = ">8.1.6" }, { name = "duckdb", specifier = ">=1.5.0" }, + { name = "fastapi", marker = "extra == 'http'", specifier = ">=0.115,<1.0" }, { name = "fsspec", specifier = ">=2024.3.1" }, { name = "httpx", specifier = "<0.28" }, { name = "ipython", specifier = ">8.24.0" }, @@ -1111,8 +1156,9 @@ requires-dist = [ { name = "tqdm", specifier = ">=4.66.2" }, { name = "typer", specifier = ">=0.13.1" }, { name = "url-normalize", specifier = ">=2.2.1" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'http'", specifier = ">=0.30,<1.0" }, ] -provides-extras = ["plugins", "irods-tcp"] +provides-extras = ["plugins", "http", "irods-tcp"] [package.metadata.requires-dev] bloom = [ @@ -2089,6 +2135,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, ] +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + [[package]] name = "strictyaml" version = "1.7.3" @@ -2291,6 +2350,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "uvicorn" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] + [[package]] name = "wcwidth" version = "0.2.14" @@ -2300,6 +2425,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, ] +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + [[package]] name = "wrapt" version = "2.0.1"