From 5932d573509911c60d770b1d145441b85f0e054b Mon Sep 17 00:00:00 2001 From: Rory Byrne Date: Mon, 16 Mar 2026 01:29:35 +0000 Subject: [PATCH] feat: add authentication configuration to deployment environment This commit adds ORCiD OAuth and JWT authentication configuration to the deployment environment example file, providing developers with the necessary environment variables to set up authentication for their OSA instances. refactor: remove Python SDK from repository This commit removes the entire Python SDK codebase from the repository as part of a restructuring effort. The SDK included CLI tools, runtime components, type definitions, testing utilities, and deployment functionality that are being relocated or redesigned. refactor: remove CLI module and restructure config Remove CLI commands, console utilities, and daemon management to focus on server-only architecture. Restructure config by promoting server fields to top-level and moving auth providers to nested structure. feat: add admin ORCiD bootstrapping support Add auth.admins.orcid config field with validation for automatic SUPERADMIN role assignment on first login for specified ORCiD IDs. --- deploy/.env.example | 12 + sdk/py/README.md | 1 - sdk/py/osa/__init__.py | 29 - sdk/py/osa/_registry.py | 128 ---- sdk/py/osa/authoring/__init__.py | 13 - sdk/py/osa/authoring/convention.py | 36 - sdk/py/osa/authoring/hook.py | 18 - sdk/py/osa/authoring/source.py | 36 - sdk/py/osa/authoring/validator.py | 5 - sdk/py/osa/cli/credentials.py | 167 ----- sdk/py/osa/cli/deploy.py | 341 --------- sdk/py/osa/cli/link.py | 71 -- sdk/py/osa/cli/login.py | 154 ---- sdk/py/osa/cli/logout.py | 22 - sdk/py/osa/cli/main.py | 173 ----- sdk/py/osa/manifest.py | 168 ----- sdk/py/osa/py.typed | 0 sdk/py/osa/runtime/__init__.py | 7 - sdk/py/osa/runtime/entrypoint.py | 154 ---- sdk/py/osa/runtime/source_context.py | 66 -- sdk/py/osa/runtime/source_entrypoint.py | 155 ----- sdk/py/osa/testing/__init__.py | 7 - sdk/py/osa/testing/harness.py | 69 -- sdk/py/osa/types/__init__.py | 16 - sdk/py/osa/types/files.py | 65 -- sdk/py/osa/types/record.py | 58 -- sdk/py/osa/types/schema.py | 107 --- sdk/py/osa/types/source.py | 41 -- sdk/py/pyproject.toml | 34 - sdk/py/tests/__init__.py | 0 sdk/py/tests/fixtures/config.json | 1 - sdk/py/tests/fixtures/files/ligands.sdf | 6 - sdk/py/tests/fixtures/files/sample.pdb | 6 - sdk/py/tests/fixtures/record.json | 1 - sdk/py/tests/test_cli.py | 116 ---- sdk/py/tests/test_convention.py | 147 ---- sdk/py/tests/test_convention_v2.py | 248 ------- sdk/py/tests/test_credentials.py | 259 ------- sdk/py/tests/test_deploy.py | 49 -- sdk/py/tests/test_deploy_auth.py | 97 --- sdk/py/tests/test_deploy_v2.py | 335 --------- sdk/py/tests/test_entrypoint_new.py | 195 ------ sdk/py/tests/test_files.py | 64 -- sdk/py/tests/test_harness.py | 144 ---- sdk/py/tests/test_hook_decorator.py | 197 ------ sdk/py/tests/test_link.py | 121 ---- sdk/py/tests/test_login.py | 181 ----- sdk/py/tests/test_logout.py | 45 -- sdk/py/tests/test_new_manifest.py | 184 ----- sdk/py/tests/test_record.py | 106 --- sdk/py/tests/test_schema.py | 71 -- sdk/py/tests/test_source_types.py | 66 -- sdk/py/tests/test_to_field_definitions.py | 101 --- sdk/py/uv.lock | 656 ------------------ server/migrations/env.py | 2 +- server/osa/__init__.py | 7 - server/osa/application/api/rest/app.py | 8 +- server/osa/application/di.py | 2 +- server/osa/cli/__init__.py | 5 - server/osa/cli/commands/__init__.py | 1 - server/osa/cli/commands/admin.py | 71 -- server/osa/cli/commands/events.py | 74 -- server/osa/cli/commands/local.py | 436 ------------ server/osa/cli/commands/search.py | 154 ---- server/osa/cli/commands/show.py | 65 -- server/osa/cli/commands/stats.py | 61 -- server/osa/cli/console.py | 261 ------- server/osa/cli/main.py | 21 - server/osa/cli/models.py | 40 -- server/osa/cli/util/__init__.py | 26 - server/osa/cli/util/daemon.py | 251 ------- server/osa/cli/util/search_cache.py | 60 -- server/osa/cli/util/server_state.py | 65 -- server/osa/config.py | 74 +- server/osa/domain/auth/service/auth.py | 15 +- server/osa/domain/auth/util/di/provider.py | 8 +- .../osa/domain/deposition/util/di/provider.py | 4 +- .../osa/domain/semantics/util/di/provider.py | 4 +- .../osa/domain/validation/util/di/provider.py | 2 +- server/osa/infrastructure/auth/di.py | 4 +- server/osa/infrastructure/event/di.py | 5 - server/osa/infrastructure/index/di.py | 46 +- server/osa/infrastructure/persistence/di.py | 4 +- server/osa/{cli => }/util/paths.py | 0 server/pyproject.toml | 6 - .../api/v1/routes/test_device_flow.py | 1 + server/tests/unit/cli/__init__.py | 0 server/tests/unit/cli/util/__init__.py | 0 server/tests/unit/config/test_config.py | 169 +++++ .../unit/domain/auth/test_admin_bootstrap.py | 247 +++++++ .../unit/domain/auth/test_auth_service.py | 1 + .../unit/domain/auth/test_device_service.py | 1 + .../index/test_vector_config.py | 2 +- .../tests/unit/util}/__init__.py | 0 .../tests/unit/{cli => }/util/test_paths.py | 2 +- server/uv.lock | 58 +- 96 files changed, 513 insertions(+), 7299 deletions(-) delete mode 100644 sdk/py/README.md delete mode 100644 sdk/py/osa/__init__.py delete mode 100644 sdk/py/osa/_registry.py delete mode 100644 sdk/py/osa/authoring/__init__.py delete mode 100644 sdk/py/osa/authoring/convention.py delete mode 100644 sdk/py/osa/authoring/hook.py delete mode 100644 sdk/py/osa/authoring/source.py delete mode 100644 sdk/py/osa/authoring/validator.py delete mode 100644 sdk/py/osa/cli/credentials.py delete mode 100644 sdk/py/osa/cli/deploy.py delete mode 100644 sdk/py/osa/cli/link.py delete mode 100644 sdk/py/osa/cli/login.py delete mode 100644 sdk/py/osa/cli/logout.py delete mode 100644 sdk/py/osa/cli/main.py delete mode 100644 sdk/py/osa/manifest.py delete mode 100644 sdk/py/osa/py.typed delete mode 100644 sdk/py/osa/runtime/__init__.py delete mode 100644 sdk/py/osa/runtime/entrypoint.py delete mode 100644 sdk/py/osa/runtime/source_context.py delete mode 100644 sdk/py/osa/runtime/source_entrypoint.py delete mode 100644 sdk/py/osa/testing/__init__.py delete mode 100644 sdk/py/osa/testing/harness.py delete mode 100644 sdk/py/osa/types/__init__.py delete mode 100644 sdk/py/osa/types/files.py delete mode 100644 sdk/py/osa/types/record.py delete mode 100644 sdk/py/osa/types/schema.py delete mode 100644 sdk/py/osa/types/source.py delete mode 100644 sdk/py/pyproject.toml delete mode 100644 sdk/py/tests/__init__.py delete mode 100644 sdk/py/tests/fixtures/config.json delete mode 100644 sdk/py/tests/fixtures/files/ligands.sdf delete mode 100644 sdk/py/tests/fixtures/files/sample.pdb delete mode 100644 sdk/py/tests/fixtures/record.json delete mode 100644 sdk/py/tests/test_cli.py delete mode 100644 sdk/py/tests/test_convention.py delete mode 100644 sdk/py/tests/test_convention_v2.py delete mode 100644 sdk/py/tests/test_credentials.py delete mode 100644 sdk/py/tests/test_deploy.py delete mode 100644 sdk/py/tests/test_deploy_auth.py delete mode 100644 sdk/py/tests/test_deploy_v2.py delete mode 100644 sdk/py/tests/test_entrypoint_new.py delete mode 100644 sdk/py/tests/test_files.py delete mode 100644 sdk/py/tests/test_harness.py delete mode 100644 sdk/py/tests/test_hook_decorator.py delete mode 100644 sdk/py/tests/test_link.py delete mode 100644 sdk/py/tests/test_login.py delete mode 100644 sdk/py/tests/test_logout.py delete mode 100644 sdk/py/tests/test_new_manifest.py delete mode 100644 sdk/py/tests/test_record.py delete mode 100644 sdk/py/tests/test_schema.py delete mode 100644 sdk/py/tests/test_source_types.py delete mode 100644 sdk/py/tests/test_to_field_definitions.py delete mode 100644 sdk/py/uv.lock delete mode 100644 server/osa/cli/__init__.py delete mode 100644 server/osa/cli/commands/__init__.py delete mode 100644 server/osa/cli/commands/admin.py delete mode 100644 server/osa/cli/commands/events.py delete mode 100644 server/osa/cli/commands/local.py delete mode 100644 server/osa/cli/commands/search.py delete mode 100644 server/osa/cli/commands/show.py delete mode 100644 server/osa/cli/commands/stats.py delete mode 100644 server/osa/cli/console.py delete mode 100644 server/osa/cli/main.py delete mode 100644 server/osa/cli/models.py delete mode 100644 server/osa/cli/util/__init__.py delete mode 100644 server/osa/cli/util/daemon.py delete mode 100644 server/osa/cli/util/search_cache.py delete mode 100644 server/osa/cli/util/server_state.py rename server/osa/{cli => }/util/paths.py (100%) delete mode 100644 server/tests/unit/cli/__init__.py delete mode 100644 server/tests/unit/cli/util/__init__.py create mode 100644 server/tests/unit/config/test_config.py create mode 100644 server/tests/unit/domain/auth/test_admin_bootstrap.py rename {sdk/py/osa/cli => server/tests/unit/util}/__init__.py (100%) rename server/tests/unit/{cli => }/util/test_paths.py (99%) diff --git a/deploy/.env.example b/deploy/.env.example index 17b6265..a00a86e 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -35,6 +35,18 @@ WEB_PORT=8080 # In Docker, this should use the service name 'server' API_URL=http://server:8000 +# ============================================================================= +# Authentication Configuration +# ============================================================================= +# ORCiD OAuth credentials (required for login) +# Register at https://orcid.org/developer-tools +ORCID_CLIENT_ID= +ORCID_CLIENT_SECRET= + +# JWT secret for signing access tokens (min 32 characters) +# Generate with: openssl rand -hex 32 +JWT_SECRET=change-me-in-production-must-be-32-chars-long + # ============================================================================= # Development Settings (optional) # ============================================================================= diff --git a/sdk/py/README.md b/sdk/py/README.md deleted file mode 100644 index 3498ade..0000000 --- a/sdk/py/README.md +++ /dev/null @@ -1 +0,0 @@ -# sdk-py diff --git a/sdk/py/osa/__init__.py b/sdk/py/osa/__init__.py deleted file mode 100644 index 6e94645..0000000 --- a/sdk/py/osa/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -"""OSA Python SDK — hooks and conventions for the Open Scientific Archive.""" - -from osa.authoring.convention import convention -from osa.authoring.hook import hook -from osa.authoring.source import Source -from osa.authoring.validator import Reject -from osa.runtime.source_context import SourceContext -from osa.types.record import Record -from osa.types.schema import Field, MetadataSchema -from osa.types.source import InitialRun, SourceFileRef, SourceRecord, SourceSchedule - -# Schema is a user-friendly alias for MetadataSchema -Schema = MetadataSchema - -__all__ = [ - "Field", - "InitialRun", - "MetadataSchema", - "Record", - "Reject", - "Schema", - "Source", - "SourceContext", - "SourceFileRef", - "SourceRecord", - "SourceSchedule", - "convention", - "hook", -] diff --git a/sdk/py/osa/_registry.py b/sdk/py/osa/_registry.py deleted file mode 100644 index ba75d27..0000000 --- a/sdk/py/osa/_registry.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Global hook and convention registry for @hook decorator and convention().""" - -from __future__ import annotations - -import typing -from collections.abc import Callable -from dataclasses import dataclass, field - - -@dataclass -class HookInfo: - """Metadata extracted from a decorated hook function.""" - - fn: Callable - name: str - hook_type: str - schema_type: type - return_type: type | None = None - output_type: type | None = None - cardinality: str = "one" - dependencies: dict[str, type] = field(default_factory=dict) - - -@dataclass -class SourceInfo: - """Metadata from a registered source class.""" - - source_cls: type - name: str - schedule: object | None = None - initial_run: object | None = None - - -@dataclass -class ConventionInfo: - """Metadata from a convention() declaration.""" - - title: str - version: str - schema_type: type - file_requirements: dict - hooks: list[Callable] - source_type: type | None = None - source_info: SourceInfo | None = None - - -_hooks: list[HookInfo] = [] -_conventions: list[ConventionInfo] = [] -_sources: list[SourceInfo] = [] - - -def clear() -> None: - """Remove all registered hooks, conventions, and sources. Used in tests.""" - _hooks.clear() - _conventions.clear() - _sources.clear() - - -def register_source(source_cls: type) -> SourceInfo: - """Register a source class and return its SourceInfo.""" - name = getattr(source_cls, "name", source_cls.__name__) - schedule = getattr(source_cls, "schedule", None) - initial_run = getattr(source_cls, "initial_run", None) - info = SourceInfo( - source_cls=source_cls, - name=name, - schedule=schedule, - initial_run=initial_run, - ) - _sources.append(info) - return info - - -def _extract_hook_info(fn: Callable, hook_type: str) -> HookInfo: - """Introspect a hook function's type hints to extract metadata.""" - hints = typing.get_type_hints(fn) - - schema_type: type | None = None - return_type: type | None = None - dependencies: dict[str, type] = {} - - for param_name, hint in hints.items(): - if param_name == "return": - return_type = hint - continue - - origin = typing.get_origin(hint) - if origin is not None: - args = typing.get_args(hint) - if getattr(origin, "__name__", "") == "Record" and args: - schema_type = args[0] - continue - - # Any other typed parameter is a dependency - dependencies[param_name] = hint - - if schema_type is None: - msg = f"Hook {fn.__name__} must have a Record[T] parameter" - raise TypeError(msg) - - # Determine output_type and cardinality from return type - output_type: type | None = None - cardinality = "one" - if return_type is not None: - if typing.get_origin(return_type) is list: - cardinality = "many" - args = typing.get_args(return_type) - output_type = args[0] if args else None - else: - cardinality = "one" - output_type = return_type - - return HookInfo( - fn=fn, - name=fn.__name__, - hook_type=hook_type, - schema_type=schema_type, - return_type=return_type, - output_type=output_type, - cardinality=cardinality, - dependencies=dependencies, - ) - - -def register(fn: Callable, hook_type: str) -> None: - """Register a decorated function as a hook.""" - info = _extract_hook_info(fn, hook_type) - _hooks.append(info) diff --git a/sdk/py/osa/authoring/__init__.py b/sdk/py/osa/authoring/__init__.py deleted file mode 100644 index 8753d71..0000000 --- a/sdk/py/osa/authoring/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Decorators and registration functions for authoring hooks and conventions.""" - -from osa.authoring.convention import convention -from osa.authoring.hook import hook -from osa.authoring.source import Source -from osa.authoring.validator import Reject - -__all__ = [ - "Reject", - "Source", - "convention", - "hook", -] diff --git a/sdk/py/osa/authoring/convention.py b/sdk/py/osa/authoring/convention.py deleted file mode 100644 index b116def..0000000 --- a/sdk/py/osa/authoring/convention.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Convention registration function.""" - -from __future__ import annotations - -from collections.abc import Callable -from typing import Any - -from osa._registry import ConventionInfo, _conventions, register_source -from osa.types.schema import MetadataSchema - - -def convention( - *, - title: str, - version: str = "0.0.0", - schema: type[MetadataSchema], - source: type | None = None, - files: dict[str, Any], - hooks: list[Callable], -) -> None: - """Register a convention that composes schemas, hooks, and an optional source.""" - source_info = None - if source is not None: - source_info = register_source(source) - - _conventions.append( - ConventionInfo( - title=title, - version=version, - schema_type=schema, - file_requirements=files, - hooks=hooks, - source_type=source, - source_info=source_info, - ) - ) diff --git a/sdk/py/osa/authoring/hook.py b/sdk/py/osa/authoring/hook.py deleted file mode 100644 index f9f082f..0000000 --- a/sdk/py/osa/authoring/hook.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Unified @hook decorator — replaces @validator and @transform.""" - -from __future__ import annotations - -from collections.abc import Callable - -from osa._registry import register - - -def hook[F: Callable](fn: F) -> F: - """Decorator that marks a function as an OSA hook. - - Registers the function in the global hook registry and - introspects type hints to extract the schema type, output type, - and cardinality (``-> T`` = one, ``-> list[T]`` = many). - """ - register(fn, "hook") - return fn diff --git a/sdk/py/osa/authoring/source.py b/sdk/py/osa/authoring/source.py deleted file mode 100644 index 24da3db..0000000 --- a/sdk/py/osa/authoring/source.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Source protocol for SDK convention packages — OCI container model.""" - -from __future__ import annotations - -from collections.abc import AsyncIterator -from datetime import datetime -from typing import Any, ClassVar, Protocol - -from pydantic import BaseModel - -from osa.runtime.source_context import SourceContext -from osa.types.source import InitialRun, SourceRecord, SourceSchedule - - -class Source(Protocol): - """Protocol for pluggable data sources running as OCI containers. - - Implement this in your convention package to define a source. - Sources are built into Docker images and executed by the server. - """ - - name: ClassVar[str] - schedule: ClassVar[SourceSchedule | None] - initial_run: ClassVar[InitialRun | None] - - class RuntimeConfig(BaseModel): ... - - async def pull( - self, - *, - ctx: SourceContext, - since: datetime | None = None, - limit: int | None = None, - offset: int = 0, - session: dict[str, Any] | None = None, - ) -> AsyncIterator[SourceRecord]: ... diff --git a/sdk/py/osa/authoring/validator.py b/sdk/py/osa/authoring/validator.py deleted file mode 100644 index 69f3644..0000000 --- a/sdk/py/osa/authoring/validator.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Reject exception for hooks that reject depositions.""" - - -class Reject(Exception): - """Raised by a @hook function to reject a deposition.""" diff --git a/sdk/py/osa/cli/credentials.py b/sdk/py/osa/cli/credentials.py deleted file mode 100644 index 8c7a0c7..0000000 --- a/sdk/py/osa/cli/credentials.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Credential storage for OSA CLI. - -Stores and retrieves authentication tokens keyed by server URL. -Credentials file: ~/.config/osa/credentials.json (0600 permissions). -""" - -from __future__ import annotations - -import json -import os -from pathlib import Path -from typing import Any - -_DEFAULT_PATH = Path.home() / ".config" / "osa" / "credentials.json" - - -def _normalize_server(server: str) -> str: - """Normalize server URL by stripping trailing slashes.""" - return server.rstrip("/") - - -def _read_file(path: Path) -> dict[str, Any]: - """Read the credentials file, returning empty dict if missing or invalid.""" - if not path.exists(): - return {} - try: - return json.loads(path.read_text()) - except (json.JSONDecodeError, OSError): - return {} - - -def _write_file(path: Path, data: dict[str, Any]) -> None: - """Write data to credentials file with 0600 permissions. - - Uses os.open with O_CREAT to create the file with 0600 from the start, - avoiding a TOCTOU window where the file is briefly world-readable. - """ - path.parent.mkdir(parents=True, exist_ok=True) - content = json.dumps(data, indent=2).encode() - fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - try: - os.write(fd, content) - finally: - os.close(fd) - - -def write_credentials( - server: str, - *, - access_token: str, - refresh_token: str, - path: Path = _DEFAULT_PATH, -) -> None: - """Store credentials for a server URL. - - Creates the file if it doesn't exist. Overwrites existing entry - for the same server. Preserves entries for other servers. - """ - server = _normalize_server(server) - data = _read_file(path) - data[server] = { - "access_token": access_token, - "refresh_token": refresh_token, - } - _write_file(path, data) - - -def read_credentials( - server: str, - *, - path: Path = _DEFAULT_PATH, -) -> dict[str, str] | None: - """Read credentials for a server URL. - - Returns dict with access_token and refresh_token, or None if not found. - """ - server = _normalize_server(server) - data = _read_file(path) - entry = data.get(server) - if entry and "access_token" in entry: - return entry - return None - - -def remove_credentials( - server: str, - *, - path: Path = _DEFAULT_PATH, -) -> bool: - """Remove credentials for a server URL. - - Returns True if credentials were removed, False if not found. - """ - server = _normalize_server(server) - data = _read_file(path) - if server not in data: - return False - del data[server] - _write_file(path, data) - return True - - -def refresh_access_token( - server: str, - *, - path: Path = _DEFAULT_PATH, -) -> str | None: - """Attempt to refresh the access token using the stored refresh token. - - On success, updates stored credentials and returns the new access token. - On failure, returns None. - """ - import httpx - - creds = read_credentials(server, path=path) - if creds is None or "refresh_token" not in creds: - return None - - url = f"{_normalize_server(server)}/api/v1/auth/refresh" - try: - resp = httpx.post( - url, - json={"refresh_token": creds["refresh_token"]}, - timeout=10.0, - ) - if resp.status_code != 200: - return None - - data = resp.json() - write_credentials( - server, - access_token=data["access_token"], - refresh_token=data["refresh_token"], - path=path, - ) - return data["access_token"] - except (httpx.HTTPError, ValueError, KeyError): - return None - - -def resolve_token( - server: str, - *, - path: Path = _DEFAULT_PATH, -) -> str | None: - """Resolve an access token for a server URL. - - Resolution chain: - 1. OSA_TOKEN environment variable (for CI/CD) - 2. Stored credentials — refresh first so we return a fresh access token - 3. None (not authenticated) - """ - env_token = os.environ.get("OSA_TOKEN") - if env_token: - return env_token - - creds = read_credentials(server, path=path) - if creds: - # Attempt refresh to get a fresh access token. - # The refresh endpoint is cheap and idempotent. - refreshed = refresh_access_token(server, path=path) - if refreshed is not None: - return refreshed - # Refresh failed — return stored token (server will reject if expired) - return creds["access_token"] - - return None diff --git a/sdk/py/osa/cli/deploy.py b/sdk/py/osa/cli/deploy.py deleted file mode 100644 index 6cac068..0000000 --- a/sdk/py/osa/cli/deploy.py +++ /dev/null @@ -1,341 +0,0 @@ -"""OSA deploy CLI: build hook/source images and register conventions with the server.""" - -from __future__ import annotations - -import logging -import re -import shutil -import subprocess -from pathlib import Path -from typing import Any - -import httpx - -from osa._registry import ConventionInfo, HookInfo, SourceInfo, _conventions, _hooks -from osa.manifest import generate_columns - -logger = logging.getLogger(__name__) - - -def _read_python_version(project_dir: Path) -> str: - """Read requires-python from pyproject.toml, default to 3.13.""" - pyproject_path = project_dir / "pyproject.toml" - if not pyproject_path.exists(): - raise FileNotFoundError(f"pyproject.toml not found in {project_dir}") - content = pyproject_path.read_text() - match = re.search(r'requires-python\s*=\s*">=(\d+\.\d+)"', content) - return match.group(1) if match else "3.13" - - -def _find_sdk_path(project_dir: Path) -> Path | None: - """Resolve the local OSA SDK path from [tool.uv.sources] in pyproject.toml.""" - pyproject_path = project_dir / "pyproject.toml" - if not pyproject_path.exists(): - return None - content = pyproject_path.read_text() - match = re.search(r'osa\s*=\s*\{\s*path\s*=\s*"([^"]+)"', content) - if match: - sdk_path = (project_dir / match.group(1)).resolve() - if sdk_path.exists(): - return sdk_path - return None - - -def generate_hook_dockerfile(project_dir: Path) -> str: - """Generate a Dockerfile for an OCI hook container.""" - python_version = _read_python_version(project_dir) - sdk_path = _find_sdk_path(project_dir) - - if sdk_path: - return f"""\ -FROM python:{python_version}-slim -WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists/* -COPY .osa-sdk /app/.osa-sdk -RUN pip install --no-cache-dir /app/.osa-sdk -COPY . . -RUN pip install --no-cache-dir . -ENTRYPOINT ["osa-run-hook"] -""" - - return f"""\ -FROM python:{python_version}-slim -WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists/* -COPY . . -RUN pip install --no-cache-dir . -ENTRYPOINT ["osa-run-hook"] -""" - - -def generate_source_dockerfile(project_dir: Path) -> str: - """Generate a Dockerfile for an OCI source container.""" - python_version = _read_python_version(project_dir) - sdk_path = _find_sdk_path(project_dir) - - if sdk_path: - return f"""\ -FROM python:{python_version}-slim -WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists/* -COPY .osa-sdk /app/.osa-sdk -RUN pip install --no-cache-dir /app/.osa-sdk -COPY . . -RUN pip install --no-cache-dir . -ENTRYPOINT ["osa-run-source"] -""" - - return f"""\ -FROM python:{python_version}-slim -WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists/* -COPY . . -RUN pip install --no-cache-dir . -ENTRYPOINT ["osa-run-source"] -""" - - -def _stage_sdk(project_dir: Path) -> Path | None: - """Copy the local SDK into the build context. Returns the staged path or None.""" - sdk_path = _find_sdk_path(project_dir) - if sdk_path is None: - return None - staged = project_dir / ".osa-sdk" - if staged.exists(): - shutil.rmtree(staged) - shutil.copytree( - sdk_path, - staged, - ignore=shutil.ignore_patterns( - "__pycache__", - "*.pyc", - ".venv", - "*.egg-info", - ), - ) - return staged - - -def _build_image( - name: str, - dockerfile_content: str, - project_dir: Path, - tag_prefix: str, -) -> tuple[str, str]: - """Build a Docker image and return (image_tag, digest).""" - dockerfile_path = project_dir / f".osa-Dockerfile.{name}" - dockerfile_path.write_text(dockerfile_content) - staged_sdk = _stage_sdk(project_dir) - - tag = f"{tag_prefix}/{name}:latest" - - try: - logger.info("Building image for %s → %s", name, tag) - build = subprocess.run( - [ - "docker", - "build", - "-f", - str(dockerfile_path), - "-t", - tag, - str(project_dir), - ], - capture_output=True, - text=True, - ) - if build.returncode != 0: - logger.error( - "Docker build failed for %s:\n%s", name, build.stderr or build.stdout - ) - build.check_returncode() - - result = subprocess.run( - ["docker", "inspect", "--format", "{{.Id}}", tag], - check=True, - capture_output=True, - text=True, - ) - digest = result.stdout.strip() - logger.info("Built %s → %s", tag, digest) - return tag, digest - finally: - dockerfile_path.unlink(missing_ok=True) - if staged_sdk and staged_sdk.exists(): - shutil.rmtree(staged_sdk) - - -def _build_hook_image( - hook: HookInfo, - project_dir: Path, - tag_prefix: str, -) -> tuple[str, str]: - """Build a Docker image for a hook and return (image_tag, digest).""" - dockerfile_content = generate_hook_dockerfile(project_dir) - return _build_image(hook.name, dockerfile_content, project_dir, tag_prefix) - - -def _build_source_image( - source: SourceInfo, - project_dir: Path, - tag_prefix: str, -) -> tuple[str, str]: - """Build a Docker image for a source and return (image_tag, digest).""" - dockerfile_content = generate_source_dockerfile(project_dir) - return _build_image( - source.name, dockerfile_content, project_dir, f"{tag_prefix}-sources" - ) - - -def _hook_to_definition( - hook: HookInfo, - image: str, - digest: str, -) -> dict[str, Any]: - """Build a HookDefinition dict from a HookInfo + image details.""" - columns: list[dict[str, Any]] = [] - if hook.output_type is not None and hasattr(hook.output_type, "model_fields"): - columns = [c.model_dump() for c in generate_columns(hook.output_type)] - - return { - "name": hook.name, - "runtime": { - "type": "oci", - "image": image, - "digest": digest, - "config": {}, - "limits": { - "timeout_seconds": 300, - "memory": "2g", - "cpu": "2.0", - }, - }, - "feature": { - "kind": "table", - "cardinality": hook.cardinality, - "columns": columns, - }, - } - - -def _convention_to_payload( - conv: ConventionInfo, - hook_definitions: list[dict[str, Any]], - source_image: tuple[str, str] | None = None, -) -> dict[str, Any]: - """Build the CreateConvention request payload.""" - schema_fields = conv.schema_type.to_field_definitions() - - file_reqs = conv.file_requirements - if "min_count" not in file_reqs: - file_reqs = {**file_reqs, "min_count": 0} - - source: dict[str, Any] | None = None - if source_image is not None and conv.source_type is not None: - image, digest = source_image - config = None - if hasattr(conv.source_type, "RuntimeConfig"): - config = conv.source_type.RuntimeConfig().model_dump() - - schedule = None - initial_run = None - if conv.source_info is not None: - if conv.source_info.schedule is not None: - schedule = conv.source_info.schedule.model_dump() - if conv.source_info.initial_run is not None: - initial_run = conv.source_info.initial_run.model_dump() - - source = { - "image": image, - "digest": digest, - "runner": "oci", - "config": config, - "limits": { - "timeout_seconds": 3600, - "memory": "4g", - "cpu": "2.0", - }, - "schedule": schedule, - "initial_run": initial_run, - } - - return { - "title": conv.title, - "version": conv.version, - "schema": schema_fields, - "file_requirements": file_reqs, - "hooks": hook_definitions, - "source": source, - } - - -def deploy( - server: str, - project_dir: Path | None = None, - tag_prefix: str = "osa-hooks", - token: str | None = None, -) -> dict[str, Any]: - """Build hook/source images and register conventions with the OSA server. - - Returns the server response for the created convention. - """ - if project_dir is None: - project_dir = Path.cwd() - - if not _conventions: - raise RuntimeError( - "No conventions registered. " - "Make sure the convention package is imported before calling deploy." - ) - - # Build images for each hook - hook_images: dict[str, tuple[str, str]] = {} - for hook in _hooks: - image, digest = _build_hook_image(hook, project_dir, tag_prefix) - hook_images[hook.name] = (image, digest) - - # Build images for sources - source_images: dict[str, tuple[str, str]] = {} - for conv in _conventions: - if conv.source_info is not None: - name = conv.source_info.name - if name not in source_images: - image, digest = _build_source_image( - conv.source_info, project_dir, tag_prefix - ) - source_images[name] = (image, digest) - - results: list[dict[str, Any]] = [] - - for conv in _conventions: - # Match hooks to this convention - hook_defs = [] - for h in conv.hooks: - name = h.__name__ - if name in hook_images: - image, digest = hook_images[name] - hook_info = next(hi for hi in _hooks if hi.name == name) - hook_defs.append(_hook_to_definition(hook_info, image, digest)) - - # Get source image if applicable - source_img = None - if conv.source_info is not None and conv.source_info.name in source_images: - source_img = source_images[conv.source_info.name] - - payload = _convention_to_payload(conv, hook_defs, source_img) - - logger.info("Registering convention '%s' with %s", conv.title, server) - - headers: dict[str, str] = {"Content-Type": "application/json"} - if token: - headers["Authorization"] = f"Bearer {token}" - - url = f"{server.rstrip('/')}/api/v1/conventions" - resp = httpx.post(url, json=payload, headers=headers, timeout=30.0) - resp.raise_for_status() - result = resp.json() - results.append(result) - - logger.info("Convention registered: %s", result.get("srn", "")) - - return results[0] if len(results) == 1 else {"conventions": results} diff --git a/sdk/py/osa/cli/link.py b/sdk/py/osa/cli/link.py deleted file mode 100644 index d0f9d24..0000000 --- a/sdk/py/osa/cli/link.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Per-directory project linking for OSA CLI. - -Stores server URL in .osa/config.json so commands don't need --server every time. -Resolution chain: --server flag → OSA_SERVER env → .osa/config.json → error. -""" - -from __future__ import annotations - -import json -import os -import sys -from pathlib import Path - - -def write_link(server: str, *, project_dir: Path | None = None) -> Path: - """Write .osa/config.json in project_dir (default: cwd). - - Returns path to the config file. - """ - project_dir = project_dir or Path.cwd() - server = server.rstrip("/") - - config_dir = project_dir / ".osa" - config_dir.mkdir(parents=True, exist_ok=True) - - config_path = config_dir / "config.json" - config_path.write_text(json.dumps({"server": server}, indent=2) + "\n") - - return config_path - - -def read_link(*, project_dir: Path | None = None) -> str | None: - """Read server URL from .osa/config.json. - - Returns the server URL or None if not found/invalid. - """ - project_dir = project_dir or Path.cwd() - config_path = project_dir / ".osa" / "config.json" - - if not config_path.exists(): - return None - - try: - data = json.loads(config_path.read_text()) - server = data.get("server") - if isinstance(server, str) and server: - return server - return None - except (json.JSONDecodeError, OSError): - return None - - -def resolve_server(*, flag: str | None = None, project_dir: Path | None = None) -> str: - """Resolve server URL: --server flag → OSA_SERVER env → .osa/config.json → error.""" - if flag: - return flag.rstrip("/") - - env = os.environ.get("OSA_SERVER") - if env: - return env.rstrip("/") - - linked = read_link(project_dir=project_dir) - if linked: - return linked - - print( - "Error: No server specified. Use --server , set OSA_SERVER, " - "or run `osa link --server ` in your project directory.", - file=sys.stderr, - ) - sys.exit(1) diff --git a/sdk/py/osa/cli/login.py b/sdk/py/osa/cli/login.py deleted file mode 100644 index 15bb1fe..0000000 --- a/sdk/py/osa/cli/login.py +++ /dev/null @@ -1,154 +0,0 @@ -"""OSA CLI login command — device flow authentication.""" - -from __future__ import annotations - -import logging -import sys -import time -import webbrowser -from pathlib import Path -from typing import Any - -import httpx - -from osa.cli.credentials import _DEFAULT_PATH, write_credentials - -logger = logging.getLogger(__name__) - -DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code" - - -def _poll_for_token( - *, - client: httpx.Client, - server: str, - device_code: str, - interval: int, - expires_in: int, -) -> dict[str, Any] | None: - """Poll the device token endpoint until authorized, expired, or timed out. - - Returns token dict on success, None on expiry/timeout. - """ - url = f"{server.rstrip('/')}/api/v1/auth/device/token" - payload = { - "device_code": device_code, - "grant_type": DEVICE_CODE_GRANT_TYPE, - } - - start = time.monotonic() - backoff = interval - - while True: - elapsed = time.monotonic() - start - if elapsed >= expires_in: - return None - - try: - resp = client.post(url, json=payload) - except httpx.HTTPError: - # Transient network error — backoff and retry - time.sleep(min(backoff, 30)) - backoff = min(backoff * 2, 30) - continue - - if resp.status_code == 200: - return resp.json() - - if resp.status_code >= 500: - # Server error — backoff and retry - time.sleep(min(backoff, 30)) - backoff = min(backoff * 2, 30) - continue - - # 400-level: check error code - data = resp.json() - error = data.get("error", "") - - if error == "authorization_pending": - time.sleep(interval) - backoff = interval # Reset backoff on normal pending - continue - - if error == "slow_down": - interval = interval + 5 # RFC 8628: increase interval - backoff = interval # Sync backoff with new interval - time.sleep(interval) - continue - - if error == "expired_token": - return None - - # Unknown error - logger.error( - "Device token error: %s — %s", error, data.get("error_description", "") - ) - return None - - -def login( - server: str, - *, - cred_path: Path = _DEFAULT_PATH, -) -> bool: - """Run the device flow login. - - Returns True on success, False on failure. - """ - server = server.rstrip("/") - - with httpx.Client(timeout=30.0) as client: - # Step 1: Initiate device authorization - try: - resp = client.post(f"{server}/api/v1/auth/device") - resp.raise_for_status() - except httpx.HTTPError as e: - print(f"Error: Could not reach server at {server}", file=sys.stderr) - logger.debug("Initiation failed: %s", e) - return False - - data = resp.json() - device_code = data["device_code"] - user_code = data["user_code"] - verification_uri = data["verification_uri"] - expires_in = data["expires_in"] - interval = data["interval"] - - # Step 2: Display code and URL - print(f"Open this URL in your browser: {verification_uri}") - print(f"Enter code: {user_code}") - print() - - # Try to open browser - try: - webbrowser.open(verification_uri) - except Exception: - pass # Non-critical — user can open manually - - # Step 3: Poll for token - print("Waiting for authorization...", end=" ", flush=True) - result = _poll_for_token( - client=client, - server=server, - device_code=device_code, - interval=interval, - expires_in=expires_in, - ) - - if result is None: - print("failed") - print("Device code expired. Please try again.", file=sys.stderr) - return False - - print("done") - - # Step 4: Store credentials - write_credentials( - server, - access_token=result["access_token"], - refresh_token=result["refresh_token"], - path=cred_path, - ) - - print("Token stored.") - return True diff --git a/sdk/py/osa/cli/logout.py b/sdk/py/osa/cli/logout.py deleted file mode 100644 index 4029755..0000000 --- a/sdk/py/osa/cli/logout.py +++ /dev/null @@ -1,22 +0,0 @@ -"""OSA CLI logout command.""" - -from __future__ import annotations - -from pathlib import Path - -from osa.cli.credentials import _DEFAULT_PATH, remove_credentials - - -def logout( - server: str, - *, - cred_path: Path = _DEFAULT_PATH, -) -> None: - """Remove stored credentials for a server URL.""" - server = server.rstrip("/") - removed = remove_credentials(server, path=cred_path) - - if removed: - print(f"Logged out from {server}") - else: - print(f"No credentials found for {server}") diff --git a/sdk/py/osa/cli/main.py b/sdk/py/osa/cli/main.py deleted file mode 100644 index 09007cb..0000000 --- a/sdk/py/osa/cli/main.py +++ /dev/null @@ -1,173 +0,0 @@ -"""OSA CLI commands: meta, emit, progress, reject, deploy.""" - -from __future__ import annotations - -import json -import os -import sys -from pathlib import Path - - -def meta_command() -> str: - """Generate and return manifest JSON from the hook registry.""" - from osa.manifest import generate_manifest - - manifest = generate_manifest() - return manifest.model_dump_json(indent=2) - - -def emit_command(data: str) -> None: - """Write feature data to $OSA_OUT/features.json.""" - output_dir = Path(os.environ.get("OSA_OUT", "/osa/out")) - output_dir.mkdir(parents=True, exist_ok=True) - parsed = json.loads(data) - (output_dir / "features.json").write_text(json.dumps(parsed, indent=2)) - - -def progress_command( - *, - step: str | None = None, - status: str, - message: str | None = None, -) -> None: - """Append a progress entry to $OSA_OUT/progress.jsonl.""" - output_dir = Path(os.environ.get("OSA_OUT", "/osa/out")) - output_dir.mkdir(parents=True, exist_ok=True) - entry: dict = {"status": status} - if step is not None: - entry["step"] = step - if message is not None: - entry["message"] = message - with (output_dir / "progress.jsonl").open("a") as f: - f.write(json.dumps(entry) + "\n") - - -def reject_command(*, reason: str) -> None: - """Write a rejection entry to $OSA_OUT/progress.jsonl.""" - output_dir = Path(os.environ.get("OSA_OUT", "/osa/out")) - output_dir.mkdir(parents=True, exist_ok=True) - entry = {"status": "rejected", "message": reason} - with (output_dir / "progress.jsonl").open("a") as f: - f.write(json.dumps(entry) + "\n") - - -def _parse_flag(args: list[str], flag: str) -> str | None: - """Extract the value of a --flag from args, or None if absent.""" - for i, arg in enumerate(args): - if arg == flag and i + 1 < len(args): - return args[i + 1] - return None - - -def app() -> None: - """CLI entry point for the `osa` command.""" - args = sys.argv[1:] - if not args: - print("Usage: osa [options]") - print("Commands: meta, emit, progress, reject, deploy, login, logout, link") - sys.exit(1) - - command = args[0] - - if command == "meta": - print(meta_command()) - - elif command == "emit": - if len(args) < 2: - print("Usage: osa emit ", file=sys.stderr) - sys.exit(1) - emit_command(args[1]) - - elif command == "progress": - kwargs: dict[str, str | None] = {"status": "info"} - i = 1 - while i < len(args): - if args[i] == "--step" and i + 1 < len(args): - kwargs["step"] = args[i + 1] - i += 2 - elif args[i] == "--status" and i + 1 < len(args): - kwargs["status"] = args[i + 1] - i += 2 - elif args[i] == "--message" and i + 1 < len(args): - kwargs["message"] = args[i + 1] - i += 2 - else: - i += 1 - progress_command(**kwargs) # type: ignore[arg-type] - - elif command == "reject": - if len(args) < 2: - print("Usage: osa reject ", file=sys.stderr) - sys.exit(1) - reason = " ".join(args[1:]) - reject_command(reason=reason) - - elif command == "link": - from osa.cli.link import write_link - - server_url = _parse_flag(args, "--server") - if not server_url: - print("Usage: osa link --server ", file=sys.stderr) - sys.exit(1) - - config_path = write_link(server_url) - print(f"Linked to {server_url.rstrip('/')}") - print(f"Config written to {config_path}") - - elif command == "login": - import logging - - from osa.cli.link import resolve_server - from osa.cli.login import login - - logging.basicConfig(level=logging.INFO, format="%(message)s") - logging.getLogger("httpx").setLevel(logging.WARNING) - logging.getLogger("httpcore").setLevel(logging.WARNING) - - server_url = resolve_server(flag=_parse_flag(args, "--server")) - success = login(server_url) - if not success: - sys.exit(1) - - elif command == "logout": - from osa.cli.link import resolve_server - from osa.cli.logout import logout - - server_url = resolve_server(flag=_parse_flag(args, "--server")) - logout(server_url) - - elif command == "deploy": - import importlib - import importlib.metadata - import logging - - from osa.cli.deploy import deploy - from osa.cli.link import resolve_server - - logging.basicConfig(level=logging.INFO, format="%(message)s") - - # Auto-discover convention packages via entry points - for ep in importlib.metadata.entry_points(group="osa.conventions"): - importlib.import_module(ep.value) - - server_url = resolve_server(flag=_parse_flag(args, "--server")) - token = _parse_flag(args, "--token") - - # Resolve token: --token flag → OSA_TOKEN env → stored credentials - if not token: - from osa.cli.credentials import resolve_token - - token = resolve_token(server_url) - if token is None: - print( - "Error: Not authenticated. Run `osa login` first.", - file=sys.stderr, - ) - sys.exit(1) - - result = deploy(server=server_url, token=token) - print(json.dumps(result, indent=2, default=str)) - - else: - print(f"Unknown command: {command}", file=sys.stderr) - sys.exit(1) diff --git a/sdk/py/osa/manifest.py b/sdk/py/osa/manifest.py deleted file mode 100644 index 7fe7ee3..0000000 --- a/sdk/py/osa/manifest.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Manifest generation for OSA deployments. - -Introspects the hook registry to produce a typed, serializable -manifest describing all hooks and conventions in a project. -""" - -from __future__ import annotations - -import typing -from datetime import date, datetime -from typing import Any, get_args, get_origin -from uuid import UUID - -from pydantic import BaseModel - -from osa._registry import HookInfo, _hooks - - -class ColumnDef(BaseModel): - """Definition of a single column in a feature table.""" - - name: str - json_type: str - format: str | None = None - required: bool - - -class HookManifestEntry(BaseModel): - """Manifest entry for a single hook (display/introspection only).""" - - name: str - record_schema: str - cardinality: str - columns: list[ColumnDef] - runner: str = "oci" - - -class ConventionManifest(BaseModel): - """Manifest entry for a convention.""" - - title: str - version: str - record_schema: str - file_requirements: dict[str, Any] - hook_names: list[str] - source_name: str | None = None - - -class Manifest(BaseModel): - """Full deployment manifest.""" - - hooks: list[HookManifestEntry] - conventions: list[ConventionManifest] = [] - schemas: dict[str, dict] - - -# ---- Type mapping for column generation ---- - -_PYTHON_TYPE_TO_JSON: dict[type, tuple[str, str | None]] = { - str: ("string", None), - float: ("number", None), - int: ("integer", None), - bool: ("boolean", None), - datetime: ("string", "date-time"), - date: ("string", "date"), - UUID: ("string", "uuid"), -} - - -def _resolve_json_type(annotation: Any) -> tuple[str, str | None]: - """Map a Python type annotation to (json_type, format).""" - # Unwrap Optional[T] / T | None - origin = get_origin(annotation) - args = get_args(annotation) - - if origin is typing.Union: - non_none = [a for a in args if a is not type(None)] - if non_none: - return _resolve_json_type(non_none[0]) - - # Handle list[X] → array - if origin is list: - return ("array", None) - - # Handle dict[X, Y] → object - if origin is dict: - return ("object", None) - - # Direct type lookup - if annotation in _PYTHON_TYPE_TO_JSON: - return _PYTHON_TYPE_TO_JSON[annotation] - - return ("string", None) - - -def _is_required(field_info: Any) -> bool: - """Determine if a Pydantic field is required (non-optional).""" - return field_info.is_required() - - -def generate_columns(model_cls: type[BaseModel]) -> list[ColumnDef]: - """Generate column definitions from a Pydantic BaseModel.""" - columns: list[ColumnDef] = [] - for name, field_info in model_cls.model_fields.items(): - json_type, fmt = _resolve_json_type(field_info.annotation) - columns.append( - ColumnDef( - name=name, - json_type=json_type, - format=fmt, - required=_is_required(field_info), - ) - ) - return columns - - -# ---- Manifest generation ---- - - -def _json_schema(cls: type) -> dict: - """Extract JSON Schema from a Pydantic model.""" - if hasattr(cls, "model_json_schema"): - return cls.model_json_schema() - return {} - - -def _build_hook(info: HookInfo) -> HookManifestEntry: - """Build a HookManifestEntry from introspected HookInfo.""" - columns: list[ColumnDef] = [] - if info.output_type is not None and hasattr(info.output_type, "model_fields"): - columns = generate_columns(info.output_type) - - return HookManifestEntry( - name=info.name, - record_schema=info.schema_type.__name__, - cardinality=info.cardinality, - columns=columns, - runner="oci", - ) - - -def generate_manifest() -> Manifest: - """Generate the full deployment manifest from all registered hooks.""" - from osa._registry import _conventions - - hooks = [_build_hook(info) for info in _hooks] - - conventions = [ - ConventionManifest( - title=c.title, - version=c.version, - record_schema=c.schema_type.__name__, - file_requirements=c.file_requirements, - hook_names=[h.__name__ for h in c.hooks], - source_name=getattr(c.source_type, "name", None) if c.source_type else None, - ) - for c in _conventions - ] - - return Manifest( - hooks=hooks, - conventions=conventions, - schemas={ - info.schema_type.__name__: _json_schema(info.schema_type) - for info in _hooks - if info.schema_type is not None - }, - ) diff --git a/sdk/py/osa/py.typed b/sdk/py/osa/py.typed deleted file mode 100644 index e69de29..0000000 diff --git a/sdk/py/osa/runtime/__init__.py b/sdk/py/osa/runtime/__init__.py deleted file mode 100644 index b25c46e..0000000 --- a/sdk/py/osa/runtime/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Container runtime — OCI filesystem contract execution.""" - -from osa.runtime.entrypoint import run_hook_entrypoint - -__all__ = [ - "run_hook_entrypoint", -] diff --git a/sdk/py/osa/runtime/entrypoint.py b/sdk/py/osa/runtime/entrypoint.py deleted file mode 100644 index 0ba8e5a..0000000 --- a/sdk/py/osa/runtime/entrypoint.py +++ /dev/null @@ -1,154 +0,0 @@ -"""Container entrypoint for the OCI filesystem contract.""" - -from __future__ import annotations - -import json -import os -import sys -import uuid -from collections.abc import Callable -from datetime import datetime -from pathlib import Path -from typing import Any - -from pydantic import BaseModel - -from osa._registry import _hooks -from osa.authoring.validator import Reject -from osa.types.files import FileCollection -from osa.types.record import Record -from osa.types.schema import MetadataSchema - - -def _discover_conventions() -> None: - """Auto-discover convention packages via entry points.""" - import importlib - import importlib.metadata - - for ep in importlib.metadata.entry_points(group="osa.conventions"): - importlib.import_module(ep.value) - - -def _get_schema_type(fn: Callable[..., Any]) -> type[MetadataSchema]: - """Look up the schema type for a hook function from the registry.""" - for info in _hooks: - if info.fn is fn: - return info.schema_type - msg = f"Function {fn.__name__} is not a registered hook" - raise ValueError(msg) - - -def run_hook_entrypoint( - *, - hook_fn: Callable[..., Any], - input_dir: Path | None = None, - output_dir: Path | None = None, -) -> int: - """Run a single hook against the OCI filesystem contract. - - Reads ``record.json`` and ``files/`` from the input directory, - runs the hook, and writes ``features.json`` to the output directory. - On Reject, writes rejection to ``progress.jsonl`` and returns 0. - - Returns 0 on successful execution, non-zero on unhandled errors. - """ - _discover_conventions() - - if input_dir is None: - input_dir = Path(os.environ.get("OSA_IN", "/osa/in")) - if output_dir is None: - output_dir = Path(os.environ.get("OSA_OUT", "/osa/out")) - - if not input_dir.exists(): - print(f"Error: input directory not found: {input_dir}", file=sys.stderr) - return 1 - - record_path = input_dir / "record.json" - if not record_path.exists(): - print(f"Error: record.json not found in {input_dir}", file=sys.stderr) - return 1 - - try: - meta_dict = json.loads(record_path.read_text()) - except (json.JSONDecodeError, OSError) as exc: - print(f"Error reading record.json: {exc}", file=sys.stderr) - return 1 - - # Build file collection - files_dir = input_dir / "files" - if files_dir.is_dir(): - file_collection = FileCollection(files_dir) - else: - import tempfile - - file_collection = FileCollection(Path(tempfile.mkdtemp())) - - # Detect envelope vs flat format - if "metadata" in meta_dict and "srn" in meta_dict: - srn = meta_dict["srn"] - metadata_fields = meta_dict["metadata"] - else: - srn = "" - metadata_fields = meta_dict - - # Build record - schema_type = _get_schema_type(hook_fn) - metadata = schema_type(**metadata_fields) - record: Record = Record( - id=str(uuid.uuid4()), - created_at=datetime.now(), - metadata=metadata, - files=file_collection, - srn=srn, - ) - - output_dir.mkdir(parents=True, exist_ok=True) - - try: - result = hook_fn(record) - except Reject as e: - # Write rejection to progress.jsonl - entry = {"status": "rejected", "message": str(e)} - with (output_dir / "progress.jsonl").open("a") as f: - f.write(json.dumps(entry) + "\n") - return 0 - - # Write features.json - if isinstance(result, list): - features = [ - item.model_dump() if isinstance(item, BaseModel) else item - for item in result - ] - elif isinstance(result, BaseModel): - features = result.model_dump() - else: - features = result - - (output_dir / "features.json").write_text(json.dumps(features, indent=2)) - return 0 - - -def _resolve_hook_fn() -> Callable[..., Any]: - """Resolve the hook function from OSA_HOOK_NAME or single-hook fallback.""" - _discover_conventions() - - hook_name = os.environ.get("OSA_HOOK_NAME") - if hook_name: - matches = [h for h in _hooks if h.name == hook_name] - if not matches: - print(f"Error: hook '{hook_name}' not found in registry", file=sys.stderr) - sys.exit(1) - return matches[0].fn - elif len(_hooks) == 1: - return _hooks[0].fn - else: - print( - f"Error: OSA_HOOK_NAME not set and {len(_hooks)} hooks registered", - file=sys.stderr, - ) - sys.exit(1) - - -def main() -> None: - """Console script entry point for osa-run-hook.""" - sys.exit(run_hook_entrypoint(hook_fn=_resolve_hook_fn())) diff --git a/sdk/py/osa/runtime/source_context.py b/sdk/py/osa/runtime/source_context.py deleted file mode 100644 index dd82ed0..0000000 --- a/sdk/py/osa/runtime/source_context.py +++ /dev/null @@ -1,66 +0,0 @@ -"""SourceContext — manages file downloads and session state during source execution.""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - import httpx - -from osa.types.source import SourceFileRef - - -class SourceContext: - """Context object provided to source.pull() for file downloads and session management. - - The SDK owns file placement — developers call add_file() with a URL and the context - downloads the file to the correct location under $OSA_FILES. - """ - - def __init__(self, files_dir: Path, output_dir: Path) -> None: - self._files_dir = files_dir - self._output_dir = output_dir - self._session: dict[str, Any] | None = None - self._client: httpx.AsyncClient | None = None - - async def _get_client(self) -> httpx.AsyncClient: - import httpx as _httpx - - if self._client is None: - self._client = _httpx.AsyncClient() - return self._client - - async def add_file(self, source_id: str, name: str, *, url: str) -> SourceFileRef: - """Download a file from url to $OSA_FILES/{source_id}/{name}. - - Returns a SourceFileRef for inclusion in the SourceRecord. - """ - target_dir = self._files_dir / source_id - target_dir.mkdir(parents=True, exist_ok=True) - target = target_dir / name - - client = await self._get_client() - resp = await client.get(url) - resp.raise_for_status() - target.write_bytes(resp.content) - - relative_path = f"{source_id}/{name}" - return SourceFileRef(name=name, relative_path=relative_path) - - def set_session(self, state: dict[str, Any]) -> None: - """Set continuation state — written to $OSA_OUT/session.json on exit.""" - self._session = state - - def write_session(self) -> None: - """Write session.json if session state was set. Called by the entrypoint.""" - if self._session is not None: - session_path = self._output_dir / "session.json" - session_path.write_text(json.dumps(self._session)) - - async def close(self) -> None: - """Close the HTTP client.""" - if self._client is not None: - await self._client.aclose() - self._client = None diff --git a/sdk/py/osa/runtime/source_entrypoint.py b/sdk/py/osa/runtime/source_entrypoint.py deleted file mode 100644 index 008b3c0..0000000 --- a/sdk/py/osa/runtime/source_entrypoint.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Container entrypoint for the OCI source filesystem contract. - -Run as: python -m osa.runtime.source_entrypoint - -Flow: -1. Read $OSA_IN/config.json → source config -2. Parse env vars: OSA_SINCE, OSA_LIMIT, OSA_OFFSET -3. Discover source class from _sources registry -4. Create SourceContext(files_dir=$OSA_FILES, output_dir=$OSA_OUT) -5. Call source.pull() → AsyncIterator[SourceRecord] -6. Write each record as a JSON line to $OSA_OUT/records.jsonl -7. If session state set, write $OSA_OUT/session.json -8. Exit 0 -""" - -from __future__ import annotations - -import asyncio -import json -import os -import sys -from datetime import datetime -from pathlib import Path - -from osa._registry import _sources -from osa.runtime.source_context import SourceContext - - -def _discover_conventions() -> None: - """Auto-discover convention packages via entry points.""" - import importlib - import importlib.metadata - - for ep in importlib.metadata.entry_points(group="osa.conventions"): - importlib.import_module(ep.value) - - -async def _run( - *, - input_dir: Path | None = None, - output_dir: Path | None = None, - files_dir: Path | None = None, -) -> int: - """Run the source entrypoint. Returns exit code.""" - _discover_conventions() - - if input_dir is None: - input_dir = Path(os.environ.get("OSA_IN", "/osa/in")) - if output_dir is None: - output_dir = Path(os.environ.get("OSA_OUT", "/osa/out")) - if files_dir is None: - files_dir = Path(os.environ.get("OSA_FILES", "/osa/files")) - - # Discover source - if not _sources: - print("Error: no sources registered", file=sys.stderr) - return 1 - - source_info = _sources[0] - source_cls = source_info.source_cls - - # Read config - config = None - config_path = input_dir / "config.json" - if config_path.exists(): - try: - config_data = json.loads(config_path.read_text()) - if hasattr(source_cls, "RuntimeConfig"): - config = source_cls.RuntimeConfig(**config_data) - else: - config = config_data - except (json.JSONDecodeError, OSError) as exc: - print(f"Error reading config.json: {exc}", file=sys.stderr) - return 1 - - # Parse env vars - since: datetime | None = None - since_str = os.environ.get("OSA_SINCE") - if since_str: - since = datetime.fromisoformat(since_str) - - limit: int | None = None - limit_str = os.environ.get("OSA_LIMIT") - if limit_str: - limit = int(limit_str) - - offset = int(os.environ.get("OSA_OFFSET", "0")) - - # Read session from input if available - session = None - session_path = input_dir / "session.json" - if session_path.exists(): - try: - session = json.loads(session_path.read_text()) - except (json.JSONDecodeError, OSError): - pass - - # Instantiate source - if config is not None: - source = source_cls(config) - else: - source = source_cls() - - # Create context - files_dir.mkdir(parents=True, exist_ok=True) - output_dir.mkdir(parents=True, exist_ok=True) - ctx = SourceContext(files_dir=files_dir, output_dir=output_dir) - - try: - # Write records.jsonl - records_path = output_dir / "records.jsonl" - count = 0 - with records_path.open("w") as f: - async for record in source.pull( - ctx=ctx, - since=since, - limit=limit, - offset=offset, - session=session, - ): - f.write(record.model_dump_json() + "\n") - count += 1 - - # Write session if set - ctx.write_session() - - print(f"Wrote {count} records to records.jsonl", file=sys.stderr) - return 0 - - except Exception as exc: - print(f"Error during source pull: {exc}", file=sys.stderr) - return 1 - finally: - await ctx.close() - - -def run_source_entrypoint( - *, - input_dir: Path | None = None, - output_dir: Path | None = None, - files_dir: Path | None = None, -) -> int: - """Synchronous wrapper for the source entrypoint.""" - return asyncio.run( - _run(input_dir=input_dir, output_dir=output_dir, files_dir=files_dir) - ) - - -def main() -> None: - """Console script entry point for osa-run-source.""" - sys.exit(run_source_entrypoint()) - - -if __name__ == "__main__": - main() diff --git a/sdk/py/osa/testing/__init__.py b/sdk/py/osa/testing/__init__.py deleted file mode 100644 index 522cdfe..0000000 --- a/sdk/py/osa/testing/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""In-process testing utilities for hooks.""" - -from osa.testing.harness import run_hook - -__all__ = [ - "run_hook", -] diff --git a/sdk/py/osa/testing/harness.py b/sdk/py/osa/testing/harness.py deleted file mode 100644 index d49bb7e..0000000 --- a/sdk/py/osa/testing/harness.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Test harness for running hooks in-process.""" - -from __future__ import annotations - -import uuid -from collections.abc import Callable -from datetime import datetime -from pathlib import Path -from typing import Any - -from osa._registry import _hooks -from osa.types.files import FileCollection -from osa.types.record import Record -from osa.types.schema import MetadataSchema - - -def _get_schema_type(fn: Callable[..., Any]) -> type[MetadataSchema]: - """Look up the schema type for a hook function from the registry.""" - for info in _hooks: - if info.fn is fn: - return info.schema_type - msg = f"Function {fn.__name__} is not a registered hook" - raise ValueError(msg) - - -def _build_record( - fn: Callable[..., Any], - meta: dict[str, Any] | MetadataSchema, - files: Path | None, - srn: str | None = None, -) -> Record[Any]: - """Construct a Record[T] for testing from a decorated function.""" - schema_type = _get_schema_type(fn) - - if isinstance(meta, dict): - metadata = schema_type(**meta) - else: - metadata = meta - - if files is not None: - file_collection = FileCollection(files) - else: - import tempfile - - file_collection = FileCollection(Path(tempfile.mkdtemp())) - - return Record( - id=str(uuid.uuid4()), - created_at=datetime.now(), - metadata=metadata, - files=file_collection, - srn=srn or "", - ) - - -def run_hook( - fn: Callable[..., Any], - *, - meta: dict[str, Any] | MetadataSchema, - files: Path | None = None, - srn: str | None = None, -) -> Any: - """Run a hook function in-process for testing. - - Constructs a :class:`Record[T]` from the provided metadata and - optional files directory, then executes the hook. - """ - record = _build_record(fn, meta, files, srn=srn) - return fn(record) diff --git a/sdk/py/osa/types/__init__.py b/sdk/py/osa/types/__init__.py deleted file mode 100644 index 2683ab8..0000000 --- a/sdk/py/osa/types/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Core data types — pure types with no behaviour beyond validation.""" - -from osa.types.files import File, FileCollection -from osa.types.record import Record -from osa.types.schema import Field, MetadataSchema -from osa.types.source import SourceFileRef, SourceRecord - -__all__ = [ - "Field", - "File", - "FileCollection", - "MetadataSchema", - "Record", - "SourceFileRef", - "SourceRecord", -] diff --git a/sdk/py/osa/types/files.py b/sdk/py/osa/types/files.py deleted file mode 100644 index d03245d..0000000 --- a/sdk/py/osa/types/files.py +++ /dev/null @@ -1,65 +0,0 @@ -"""File access abstractions for OSA records.""" - -from __future__ import annotations -from typing import List - -import fnmatch -from collections.abc import Iterator -from pathlib import Path - - -class File: - """A single data file within a deposition.""" - - def __init__(self, path: Path) -> None: - self._path = path - - @property - def path(self) -> Path: - """Absolute path to the file.""" - return self._path - - @property - def name(self) -> str: - """Filename string.""" - return self._path.name - - @property - def size(self) -> int: - """File size in bytes.""" - return self._path.stat().st_size - - def read(self) -> bytes: - """Read and return the full file contents.""" - return self._path.read_bytes() - - -class FileCollection: - """Ordered collection of files associated with a record.""" - - def __init__(self, directory: Path) -> None: - self._files = sorted( - (File(p) for p in directory.iterdir() if p.is_file()), - key=lambda f: f.name, - ) - - def list(self) -> List[File]: - """Return all files.""" - return list(self._files) - - def glob(self, pattern: str) -> List[File]: - """Filter files by glob pattern on filenames.""" - return [f for f in self._files if fnmatch.fnmatch(f.name, pattern)] - - def __getitem__(self, name: str) -> File: - """Access a file by name. Raises KeyError if not found.""" - for f in self._files: - if f.name == name: - return f - raise KeyError(name) - - def __iter__(self) -> Iterator[File]: - return iter(self._files) - - def __len__(self) -> int: - return len(self._files) diff --git a/sdk/py/osa/types/record.py b/sdk/py/osa/types/record.py deleted file mode 100644 index c65c91d..0000000 --- a/sdk/py/osa/types/record.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Generic record container for OSA depositions.""" - -from datetime import datetime -from typing import Generic, TypeVar - -from osa.types.files import FileCollection -from osa.types.schema import MetadataSchema - -T = TypeVar("T", bound=MetadataSchema) - - -class Record(Generic[T]): - """A scientific data record parameterized by its metadata schema. - - Provides typed access to metadata, associated files, and record identity. - """ - - __slots__ = ("_id", "_srn", "_created_at", "_metadata", "_files") - - def __init__( - self, - *, - id: str, - created_at: datetime, - metadata: T, - files: FileCollection, - srn: str = "", - ) -> None: - self._id = id - self._srn = srn - self._created_at = created_at - self._metadata = metadata - self._files = files - - @property - def id(self) -> str: - """Unique record identifier.""" - return self._id - - @property - def srn(self) -> str: - """Structured Resource Name (deposition SRN during validation).""" - return self._srn - - @property - def created_at(self) -> datetime: - """Timestamp of record creation.""" - return self._created_at - - @property - def metadata(self) -> T: - """Typed metadata instance.""" - return self._metadata - - @property - def files(self) -> FileCollection: - """Associated data files.""" - return self._files diff --git a/sdk/py/osa/types/schema.py b/sdk/py/osa/types/schema.py deleted file mode 100644 index b490b9f..0000000 --- a/sdk/py/osa/types/schema.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Metadata schema definitions for OSA validators and transforms.""" - -from __future__ import annotations - -import typing -from datetime import date, datetime -from typing import Any - -from pydantic import BaseModel, ConfigDict -from pydantic import Field as _PydanticField -from pydantic.fields import FieldInfo - -# Python type → (FieldType, extra_constraints) -_TYPE_MAP: dict[type, str] = { - str: "text", - int: "number", - float: "number", - bool: "boolean", - date: "date", - datetime: "date", -} - - -class MetadataSchema(BaseModel): - """Base class for defining typed metadata schemas. - - Subclass this to declare the metadata fields a record must provide. - Uses Pydantic validation under the hood — fields support constraints - like ``ge``, ``le``, ``pattern``, ``Literal``, etc. - - Extra fields not declared in the schema are rejected. - """ - - model_config = ConfigDict(extra="forbid") - - @classmethod - def to_field_definitions(cls) -> list[dict[str, Any]]: - """Convert this schema's fields to server FieldDefinition dicts. - - Maps Python type hints to the server's FieldType format: - str → text, int → number (integer_only), float → number, - bool → boolean, date/datetime → date, T | None → required=False. - """ - result: list[dict[str, Any]] = [] - for name, field_info in cls.model_fields.items(): - annotation = field_info.annotation - required = field_info.is_required() - - # Unwrap Optional[T] / T | None - inner = _unwrap_optional(annotation) - - # Resolve field type - field_type = _TYPE_MAP.get(inner, "text") - - field_def: dict[str, Any] = { - "name": name, - "type": field_type, - "required": required, - "cardinality": "exactly_one", - } - - # Build constraints (discriminated union with "type" key) - constraints: dict[str, Any] | None = None - if field_type == "number": - c: dict[str, Any] = {"type": "number"} - if inner is int: - c["integer_only"] = True - extra = field_info.json_schema_extra - if isinstance(extra, dict) and "unit" in extra: - c["unit"] = extra["unit"] - constraints = c - elif field_type == "text": - extra = field_info.json_schema_extra - if isinstance(extra, dict): - constraints = {"type": "text", **extra} - - if constraints: - field_def["constraints"] = constraints - - result.append(field_def) - return result - - -def _unwrap_optional(annotation: Any) -> type: - """Unwrap Optional[T] / T | None to get the inner type.""" - origin = typing.get_origin(annotation) - args = typing.get_args(annotation) - if origin is typing.Union: - non_none = [a for a in args if a is not type(None)] - if non_none: - return non_none[0] - return annotation - - -def Field(*, unit: str | None = None, **kwargs: Any) -> FieldInfo: - """Declare a metadata field with optional unit annotation. - - Thin wrapper around :func:`pydantic.Field` that adds an optional - ``unit`` keyword. The unit value is stored in ``json_schema_extra`` - and appears in the generated JSON Schema. - """ - extra: dict[str, Any] = kwargs.pop("json_schema_extra", None) or {} - if unit is not None: - extra["unit"] = unit - if extra: - kwargs["json_schema_extra"] = extra - return _PydanticField(**kwargs) diff --git a/sdk/py/osa/types/source.py b/sdk/py/osa/types/source.py deleted file mode 100644 index 014ba96..0000000 --- a/sdk/py/osa/types/source.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Source types for SDK — SourceFileRef and SourceRecord.""" - -from __future__ import annotations - -from datetime import datetime -from typing import Any - -from pydantic import BaseModel - - -class SourceSchedule(BaseModel, frozen=True): - """Cron schedule for periodic source runs.""" - - cron: str - limit: int | None = None - - -class InitialRun(BaseModel, frozen=True): - """Configuration for the first source run on server startup.""" - - limit: int | None = None - - -class SourceFileRef(BaseModel, frozen=True): - """A reference to a file written by a source container. - - The source writes files to $OSA_FILES/{source_id}/{name}. - The server renames this directory into the deposition's canonical location. - """ - - name: str # e.g. "structure.cif" - relative_path: str # e.g. "{source_id}/structure.cif" (relative to $OSA_FILES) - - -class SourceRecord(BaseModel, frozen=True): - """A record produced by a source container, written to records.jsonl.""" - - source_id: str - metadata: dict[str, Any] - files: list[SourceFileRef] = [] - fetched_at: datetime | None = None diff --git a/sdk/py/pyproject.toml b/sdk/py/pyproject.toml deleted file mode 100644 index 67efefd..0000000 --- a/sdk/py/pyproject.toml +++ /dev/null @@ -1,34 +0,0 @@ -[project] -name = "osa" -version = "0.1.0" -description = "OSA Python SDK — validators and transforms for the Open Scientific Archive" -readme = "README.md" -authors = [ - { name = "Rory Byrne", email = "rory@rory.bio" } -] -requires-python = ">=3.13" -dependencies = [ - "httpx>=0.27", - "pydantic>=2", -] - -[project.scripts] -osa = "osa.cli.main:app" -osa-run-hook = "osa.runtime.entrypoint:main" -osa-run-source = "osa.runtime.source_entrypoint:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.ruff] -line-length = 88 -target-version = "py313" - -[dependency-groups] -dev = [ - "pocketeer>=0.3.0", - "pytest>=9.0.2", - "ruff>=0.15.1", - "ty>=0.0.17", -] diff --git a/sdk/py/tests/__init__.py b/sdk/py/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/sdk/py/tests/fixtures/config.json b/sdk/py/tests/fixtures/config.json deleted file mode 100644 index 0967ef4..0000000 --- a/sdk/py/tests/fixtures/config.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/sdk/py/tests/fixtures/files/ligands.sdf b/sdk/py/tests/fixtures/files/ligands.sdf deleted file mode 100644 index faee4dc..0000000 --- a/sdk/py/tests/fixtures/files/ligands.sdf +++ /dev/null @@ -1,6 +0,0 @@ -ligand_001 - RDKit 3D - - 0 0 0 0 0 0 0 0 0 0999 V2000 -M END -$$$$ diff --git a/sdk/py/tests/fixtures/files/sample.pdb b/sdk/py/tests/fixtures/files/sample.pdb deleted file mode 100644 index 40b916b..0000000 --- a/sdk/py/tests/fixtures/files/sample.pdb +++ /dev/null @@ -1,6 +0,0 @@ -HEADER TEST PROTEIN -ATOM 1 N ALA A 1 1.000 2.000 3.000 1.00 10.00 N -ATOM 2 CA ALA A 1 2.000 3.000 4.000 1.00 10.00 C -ATOM 3 C ALA A 1 3.000 4.000 5.000 1.00 10.00 C -ATOM 4 O ALA A 1 4.000 5.000 6.000 1.00 10.00 O -END diff --git a/sdk/py/tests/fixtures/record.json b/sdk/py/tests/fixtures/record.json deleted file mode 100644 index 46ac27a..0000000 --- a/sdk/py/tests/fixtures/record.json +++ /dev/null @@ -1 +0,0 @@ -{"organism": "H. sapiens", "method": "xray", "resolution": 2.1, "uniprot_id": "P12345"} diff --git a/sdk/py/tests/test_cli.py b/sdk/py/tests/test_cli.py deleted file mode 100644 index 1d21bb8..0000000 --- a/sdk/py/tests/test_cli.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Tests for osa CLI commands: meta, emit, progress, reject.""" - -from __future__ import annotations - -import json -import os - -from pydantic import BaseModel - -from osa.types.record import Record -from osa.types.schema import MetadataSchema - - -class SampleSchema(MetadataSchema): - organism: str - - -class PocketResult(BaseModel): - pocket_id: str - score: float - - -class TestOsaMeta: - def setup_method(self) -> None: - from osa._registry import clear - - clear() - - def test_meta_outputs_manifest_json(self) -> None: - from osa.authoring.hook import hook - from osa.cli.main import meta_command - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - output = meta_command() - data = json.loads(output) - assert "hooks" in data - assert "schemas" in data - assert len(data["hooks"]) == 1 - assert data["hooks"][0]["name"] == "detect" - - def test_meta_includes_columns(self) -> None: - from osa.authoring.hook import hook - from osa.cli.main import meta_command - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - output = meta_command() - data = json.loads(output) - hook_data = data["hooks"][0] - assert "columns" in hook_data - assert len(hook_data["columns"]) == 2 - - -class TestOsaEmit: - def test_emit_writes_single_object(self, tmp_path) -> None: - from osa.cli.main import emit_command - - os.environ["OSA_OUT"] = str(tmp_path) - try: - emit_command('{"atom_count": 42}') - result = json.loads((tmp_path / "features.json").read_text()) - assert result == {"atom_count": 42} - finally: - del os.environ["OSA_OUT"] - - def test_emit_writes_array(self, tmp_path) -> None: - from osa.cli.main import emit_command - - os.environ["OSA_OUT"] = str(tmp_path) - try: - emit_command('[{"id": "P1"}, {"id": "P2"}]') - result = json.loads((tmp_path / "features.json").read_text()) - assert isinstance(result, list) - assert len(result) == 2 - finally: - del os.environ["OSA_OUT"] - - -class TestOsaProgress: - def test_progress_appends_jsonl(self, tmp_path) -> None: - from osa.cli.main import progress_command - - os.environ["OSA_OUT"] = str(tmp_path) - try: - progress_command(step="Loading", status="running", message="Starting...") - progress_command(step="Loading", status="completed", message="Done") - - lines = (tmp_path / "progress.jsonl").read_text().strip().split("\n") - assert len(lines) == 2 - first = json.loads(lines[0]) - assert first["step"] == "Loading" - assert first["status"] == "running" - finally: - del os.environ["OSA_OUT"] - - -class TestOsaReject: - def test_reject_writes_rejection(self, tmp_path) -> None: - from osa.cli.main import reject_command - - os.environ["OSA_OUT"] = str(tmp_path) - try: - reject_command(reason="Bad data format") - - lines = (tmp_path / "progress.jsonl").read_text().strip().split("\n") - assert len(lines) == 1 - data = json.loads(lines[0]) - assert data["status"] == "rejected" - assert data["message"] == "Bad data format" - finally: - del os.environ["OSA_OUT"] diff --git a/sdk/py/tests/test_convention.py b/sdk/py/tests/test_convention.py deleted file mode 100644 index 928cb1c..0000000 --- a/sdk/py/tests/test_convention.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Tests for convention() registration function.""" - -from __future__ import annotations - -from pydantic import BaseModel - -from osa.types.record import Record -from osa.types.schema import MetadataSchema - - -class SampleSchema(MetadataSchema): - organism: str - - -class PocketResult(BaseModel): - pocket_id: str - score: float - - -class QualityResult(BaseModel): - atom_count: int - - -class TestConventionRegistration: - def setup_method(self) -> None: - from osa._registry import clear - - clear() - - def test_convention_records_in_registry(self) -> None: - from osa._registry import _conventions - from osa.authoring.convention import convention - from osa.authoring.hook import hook - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - convention( - title="Test Convention", - schema=SampleSchema, - files={"extensions": [".cif"], "min": 1, "max": 10}, - hooks=[detect], - ) - assert len(_conventions) == 1 - - def test_convention_stores_title(self) -> None: - from osa._registry import _conventions - from osa.authoring.convention import convention - from osa.authoring.hook import hook - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - convention( - title="Protein Structure", - schema=SampleSchema, - files={"extensions": [".cif"]}, - hooks=[detect], - ) - assert _conventions[0].title == "Protein Structure" - - def test_convention_stores_schema_type(self) -> None: - from osa._registry import _conventions - from osa.authoring.convention import convention - from osa.authoring.hook import hook - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - convention( - title="Test", - schema=SampleSchema, - files={}, - hooks=[detect], - ) - assert _conventions[0].schema_type is SampleSchema - - def test_convention_stores_file_requirements(self) -> None: - from osa._registry import _conventions - from osa.authoring.convention import convention - from osa.authoring.hook import hook - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - files = {"extensions": [".cif", ".pdb"], "min": 1, "max": 10} - convention( - title="Test", - schema=SampleSchema, - files=files, - hooks=[detect], - ) - assert _conventions[0].file_requirements == files - - def test_convention_stores_hook_references(self) -> None: - from osa._registry import _conventions - from osa.authoring.convention import convention - from osa.authoring.hook import hook - - @hook - def check(record: Record[SampleSchema]) -> QualityResult: - return QualityResult(atom_count=100) - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - convention( - title="Full", - schema=SampleSchema, - files={}, - hooks=[check, detect], - ) - assert _conventions[0].hooks == [check, detect] - - def test_multiple_conventions(self) -> None: - from osa._registry import _conventions - from osa.authoring.convention import convention - from osa.authoring.hook import hook - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - @hook - def check(record: Record[SampleSchema]) -> QualityResult: - return QualityResult(atom_count=100) - - convention( - title="Simple", - schema=SampleSchema, - files={}, - hooks=[detect], - ) - convention( - title="Detailed", - schema=SampleSchema, - files={}, - hooks=[check, detect], - ) - assert len(_conventions) == 2 - assert _conventions[0].title == "Simple" - assert _conventions[1].title == "Detailed" diff --git a/sdk/py/tests/test_convention_v2.py b/sdk/py/tests/test_convention_v2.py deleted file mode 100644 index 56d7e27..0000000 --- a/sdk/py/tests/test_convention_v2.py +++ /dev/null @@ -1,248 +0,0 @@ -"""Tests for updated convention() with version and source support.""" - -from __future__ import annotations - -from collections.abc import AsyncIterator -from datetime import datetime -from typing import Any - -from pydantic import BaseModel - -from osa.types.record import Record -from osa.types.schema import MetadataSchema - - -class SampleSchema(MetadataSchema): - organism: str - - -class PocketResult(BaseModel): - pocket_id: str - score: float - - -class TestConventionVersion: - def setup_method(self) -> None: - from osa._registry import clear - - clear() - - def test_convention_stores_version(self) -> None: - from osa._registry import _conventions - from osa.authoring.convention import convention - from osa.authoring.hook import hook - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - convention( - title="Test Convention", - version="1.0.0", - schema=SampleSchema, - files={"accepted_types": [".cif"]}, - hooks=[detect], - ) - assert _conventions[0].version == "1.0.0" - - def test_convention_stores_source_type(self) -> None: - from osa._registry import _conventions - from osa.authoring.convention import convention - from osa.authoring.hook import hook - from osa.authoring.source import Source - from osa.runtime.source_context import SourceContext - from osa.types.source import SourceRecord - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - class MySource(Source): - name = "test-source" - - class RuntimeConfig(BaseModel): - api_key: str - - async def pull( - self, - *, - ctx: SourceContext, - since: datetime | None = None, - limit: int | None = None, - offset: int = 0, - session: dict[str, Any] | None = None, - ) -> AsyncIterator[SourceRecord]: - yield # type: ignore[misc] # pragma: no cover - - convention( - title="Test Convention", - version="1.0.0", - schema=SampleSchema, - source=MySource, - files={"accepted_types": [".cif"]}, - hooks=[detect], - ) - assert _conventions[0].source_type is MySource - - def test_convention_populates_source_info(self) -> None: - from osa._registry import _conventions - from osa.authoring.convention import convention - from osa.authoring.hook import hook - from osa.authoring.source import Source - from osa.runtime.source_context import SourceContext - from osa.types.source import SourceRecord - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - class MySource(Source): - name = "test-source" - - class RuntimeConfig(BaseModel): - api_key: str - - async def pull( - self, - *, - ctx: SourceContext, - since: datetime | None = None, - limit: int | None = None, - offset: int = 0, - session: dict[str, Any] | None = None, - ) -> AsyncIterator[SourceRecord]: - yield # type: ignore[misc] # pragma: no cover - - convention( - title="Test Convention", - version="1.0.0", - schema=SampleSchema, - source=MySource, - files={"accepted_types": [".cif"]}, - hooks=[detect], - ) - assert _conventions[0].source_info is not None - assert _conventions[0].source_info.name == "test-source" - assert _conventions[0].source_info.source_cls is MySource - - def test_convention_source_defaults_to_none(self) -> None: - from osa._registry import _conventions - from osa.authoring.convention import convention - from osa.authoring.hook import hook - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - convention( - title="No Source", - version="1.0.0", - schema=SampleSchema, - files={}, - hooks=[detect], - ) - assert _conventions[0].source_type is None - assert _conventions[0].source_info is None - - def test_backward_compatible_without_version(self) -> None: - """version defaults to '0.0.0' if omitted.""" - from osa._registry import _conventions - from osa.authoring.convention import convention - from osa.authoring.hook import hook - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - convention( - title="No Version", - schema=SampleSchema, - files={}, - hooks=[detect], - ) - assert _conventions[0].version == "0.0.0" - - -class TestManifestWithVersion: - def setup_method(self) -> None: - from osa._registry import clear - - clear() - - def test_manifest_convention_has_version(self) -> None: - from osa.authoring.convention import convention - from osa.authoring.hook import hook - from osa.manifest import generate_manifest - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - convention( - title="Test", - version="2.1.0", - schema=SampleSchema, - files={}, - hooks=[detect], - ) - m = generate_manifest() - assert m.conventions[0].version == "2.1.0" - - def test_manifest_convention_has_source_name(self) -> None: - from osa.authoring.convention import convention - from osa.authoring.hook import hook - from osa.authoring.source import Source - from osa.manifest import generate_manifest - from osa.runtime.source_context import SourceContext - from osa.types.source import SourceRecord - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - class MySource(Source): - name = "my-source" - - class RuntimeConfig(BaseModel): - pass - - async def pull( - self, - *, - ctx: SourceContext, - since: datetime | None = None, - limit: int | None = None, - offset: int = 0, - session: dict[str, Any] | None = None, - ) -> AsyncIterator[SourceRecord]: - yield # type: ignore[misc] # pragma: no cover - - convention( - title="Test", - version="1.0.0", - schema=SampleSchema, - source=MySource, - files={}, - hooks=[detect], - ) - m = generate_manifest() - assert m.conventions[0].source_name == "my-source" - - def test_manifest_convention_source_name_none_when_no_source(self) -> None: - from osa.authoring.convention import convention - from osa.authoring.hook import hook - from osa.manifest import generate_manifest - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - convention( - title="Test", - version="1.0.0", - schema=SampleSchema, - files={}, - hooks=[detect], - ) - m = generate_manifest() - assert m.conventions[0].source_name is None diff --git a/sdk/py/tests/test_credentials.py b/sdk/py/tests/test_credentials.py deleted file mode 100644 index c6d1134..0000000 --- a/sdk/py/tests/test_credentials.py +++ /dev/null @@ -1,259 +0,0 @@ -"""Unit tests for credentials module (T038).""" - -import json -import stat -from pathlib import Path - -import pytest - -from unittest.mock import patch - -from osa.cli.credentials import ( - read_credentials, - remove_credentials, - resolve_token, - write_credentials, -) - - -@pytest.fixture -def cred_file(tmp_path: Path) -> Path: - """Return a temporary credentials file path.""" - return tmp_path / "credentials.json" - - -class TestWriteCredentials: - """Tests for write_credentials.""" - - def test_creates_file_with_tokens(self, cred_file: Path): - write_credentials( - "https://archive.example.com", - access_token="at-123", - refresh_token="rt-456", - path=cred_file, - ) - - data = json.loads(cred_file.read_text()) - assert data["https://archive.example.com"]["access_token"] == "at-123" - assert data["https://archive.example.com"]["refresh_token"] == "rt-456" - - def test_creates_parent_directory(self, tmp_path: Path): - cred_file = tmp_path / "subdir" / "credentials.json" - write_credentials( - "https://example.com", - access_token="at", - refresh_token="rt", - path=cred_file, - ) - - assert cred_file.exists() - - def test_sets_file_permissions_0600(self, cred_file: Path): - write_credentials( - "https://example.com", - access_token="at", - refresh_token="rt", - path=cred_file, - ) - - mode = stat.S_IMODE(cred_file.stat().st_mode) - assert mode == 0o600 - - def test_overwrites_existing_server_entry(self, cred_file: Path): - write_credentials( - "https://example.com", - access_token="old", - refresh_token="old", - path=cred_file, - ) - write_credentials( - "https://example.com", - access_token="new", - refresh_token="new", - path=cred_file, - ) - - data = json.loads(cred_file.read_text()) - assert data["https://example.com"]["access_token"] == "new" - - def test_preserves_other_server_entries(self, cred_file: Path): - write_credentials( - "https://server-a.com", - access_token="a", - refresh_token="a", - path=cred_file, - ) - write_credentials( - "https://server-b.com", - access_token="b", - refresh_token="b", - path=cred_file, - ) - - data = json.loads(cred_file.read_text()) - assert "https://server-a.com" in data - assert "https://server-b.com" in data - - def test_normalizes_trailing_slash(self, cred_file: Path): - write_credentials( - "https://example.com/", - access_token="at", - refresh_token="rt", - path=cred_file, - ) - - data = json.loads(cred_file.read_text()) - assert "https://example.com" in data - assert "https://example.com/" not in data - - -class TestReadCredentials: - """Tests for read_credentials.""" - - def test_returns_tokens_for_known_server(self, cred_file: Path): - write_credentials( - "https://example.com", - access_token="at-123", - refresh_token="rt-456", - path=cred_file, - ) - - result = read_credentials("https://example.com", path=cred_file) - - assert result is not None - assert result["access_token"] == "at-123" - assert result["refresh_token"] == "rt-456" - - def test_returns_none_for_unknown_server(self, cred_file: Path): - write_credentials( - "https://example.com", - access_token="at", - refresh_token="rt", - path=cred_file, - ) - - result = read_credentials("https://other.com", path=cred_file) - assert result is None - - def test_returns_none_when_file_missing(self, tmp_path: Path): - result = read_credentials( - "https://example.com", - path=tmp_path / "nonexistent.json", - ) - assert result is None - - def test_normalizes_trailing_slash(self, cred_file: Path): - write_credentials( - "https://example.com", - access_token="at", - refresh_token="rt", - path=cred_file, - ) - - result = read_credentials("https://example.com/", path=cred_file) - assert result is not None - - -class TestRemoveCredentials: - """Tests for remove_credentials.""" - - def test_removes_server_entry(self, cred_file: Path): - write_credentials( - "https://example.com", - access_token="at", - refresh_token="rt", - path=cred_file, - ) - - removed = remove_credentials("https://example.com", path=cred_file) - - assert removed is True - assert read_credentials("https://example.com", path=cred_file) is None - - def test_returns_false_for_unknown_server(self, cred_file: Path): - removed = remove_credentials("https://example.com", path=cred_file) - assert removed is False - - def test_preserves_other_entries(self, cred_file: Path): - write_credentials( - "https://a.com", access_token="a", refresh_token="a", path=cred_file - ) - write_credentials( - "https://b.com", access_token="b", refresh_token="b", path=cred_file - ) - - remove_credentials("https://a.com", path=cred_file) - - assert read_credentials("https://a.com", path=cred_file) is None - assert read_credentials("https://b.com", path=cred_file) is not None - - -class TestResolveToken: - """Tests for resolve_token credential resolution chain.""" - - def test_env_var_takes_precedence(self, cred_file: Path, monkeypatch): - monkeypatch.setenv("OSA_TOKEN", "env-token") - write_credentials( - "https://example.com", - access_token="stored", - refresh_token="rt", - path=cred_file, - ) - - token = resolve_token("https://example.com", path=cred_file) - assert token == "env-token" - - def test_stored_credentials_used_when_no_env(self, cred_file: Path, monkeypatch): - monkeypatch.delenv("OSA_TOKEN", raising=False) - write_credentials( - "https://example.com", - access_token="stored-at", - refresh_token="rt", - path=cred_file, - ) - - with patch("osa.cli.credentials.refresh_access_token", return_value=None): - token = resolve_token("https://example.com", path=cred_file) - assert token == "stored-at" - - def test_returns_none_when_no_credentials(self, cred_file: Path, monkeypatch): - monkeypatch.delenv("OSA_TOKEN", raising=False) - token = resolve_token("https://example.com", path=cred_file) - assert token is None - - def test_attempts_refresh_when_stored_creds_exist( - self, cred_file: Path, monkeypatch - ): - """resolve_token should call refresh_access_token and return refreshed token.""" - monkeypatch.delenv("OSA_TOKEN", raising=False) - write_credentials( - "https://example.com", - access_token="old-at", - refresh_token="rt", - path=cred_file, - ) - - with patch( - "osa.cli.credentials.refresh_access_token", return_value="fresh-at" - ) as mock_refresh: - token = resolve_token("https://example.com", path=cred_file) - - assert token == "fresh-at" - mock_refresh.assert_called_once_with("https://example.com", path=cred_file) - - def test_falls_back_to_stored_token_when_refresh_fails( - self, cred_file: Path, monkeypatch - ): - """resolve_token should return stored token if refresh fails.""" - monkeypatch.delenv("OSA_TOKEN", raising=False) - write_credentials( - "https://example.com", - access_token="stored-at", - refresh_token="rt", - path=cred_file, - ) - - with patch("osa.cli.credentials.refresh_access_token", return_value=None): - token = resolve_token("https://example.com", path=cred_file) - - assert token == "stored-at" diff --git a/sdk/py/tests/test_deploy.py b/sdk/py/tests/test_deploy.py deleted file mode 100644 index fe418b7..0000000 --- a/sdk/py/tests/test_deploy.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Tests for osa deploy CLI: Dockerfile generation.""" - -from __future__ import annotations - - -import pytest - - -class TestDockerfileGeneration: - def test_generates_dockerfile_from_pyproject(self, tmp_path) -> None: - from osa.cli.deploy import generate_hook_dockerfile - - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text( - '[project]\nname = "test-hooks"\nrequires-python = ">=3.13"\n' - ) - - dockerfile = generate_hook_dockerfile(tmp_path) - assert "FROM python:3.13-slim" in dockerfile - assert "pip install" in dockerfile - - def test_dockerfile_uses_python_version_from_pyproject(self, tmp_path) -> None: - from osa.cli.deploy import generate_hook_dockerfile - - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text( - '[project]\nname = "test-hooks"\nrequires-python = ">=3.12"\n' - ) - - dockerfile = generate_hook_dockerfile(tmp_path) - assert "FROM python:3.12-slim" in dockerfile - - def test_dockerfile_includes_entrypoint(self, tmp_path) -> None: - from osa.cli.deploy import generate_hook_dockerfile - - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text( - '[project]\nname = "test-hooks"\nrequires-python = ">=3.13"\n' - ) - - dockerfile = generate_hook_dockerfile(tmp_path) - assert "ENTRYPOINT" in dockerfile - assert "osa-run-hook" in dockerfile - - def test_raises_if_no_pyproject(self, tmp_path) -> None: - from osa.cli.deploy import generate_hook_dockerfile - - with pytest.raises(FileNotFoundError): - generate_hook_dockerfile(tmp_path) diff --git a/sdk/py/tests/test_deploy_auth.py b/sdk/py/tests/test_deploy_auth.py deleted file mode 100644 index ca74785..0000000 --- a/sdk/py/tests/test_deploy_auth.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Unit tests for deploy credential resolution and token refresh (T043-T044).""" - -from pathlib import Path - -import pytest - -from osa.cli.credentials import resolve_token, write_credentials - - -@pytest.fixture -def cred_file(tmp_path: Path) -> Path: - return tmp_path / "credentials.json" - - -class TestCredentialResolutionChain: - """Tests for credential resolution: OSA_TOKEN → stored → error.""" - - def test_osa_token_env_takes_precedence(self, cred_file: Path, monkeypatch): - monkeypatch.setenv("OSA_TOKEN", "env-token") - write_credentials( - "https://example.com", - access_token="stored-token", - refresh_token="rt", - path=cred_file, - ) - - token = resolve_token("https://example.com", path=cred_file) - assert token == "env-token" - - def test_stored_credentials_used_without_env(self, cred_file: Path, monkeypatch): - monkeypatch.delenv("OSA_TOKEN", raising=False) - write_credentials( - "https://example.com", - access_token="stored-at", - refresh_token="rt", - path=cred_file, - ) - - from unittest.mock import patch - - with patch("osa.cli.credentials.refresh_access_token", return_value=None): - token = resolve_token("https://example.com", path=cred_file) - assert token == "stored-at" - - def test_returns_none_when_no_credentials(self, cred_file: Path, monkeypatch): - monkeypatch.delenv("OSA_TOKEN", raising=False) - token = resolve_token("https://example.com", path=cred_file) - assert token is None - - -class TestTokenRefresh: - """Tests for token refresh on expiry.""" - - def test_refresh_success_updates_stored_credentials(self, cred_file: Path): - from osa.cli.credentials import read_credentials, write_credentials - - write_credentials( - "https://example.com", - access_token="expired-at", - refresh_token="valid-rt", - path=cred_file, - ) - - # Simulate calling the refresh endpoint - - # Mock successful refresh response - new_at = "refreshed-at" - new_rt = "refreshed-rt" - - # Write updated credentials (simulating what refresh logic does) - write_credentials( - "https://example.com", - access_token=new_at, - refresh_token=new_rt, - path=cred_file, - ) - - creds = read_credentials("https://example.com", path=cred_file) - assert creds is not None - assert creds["access_token"] == new_at - assert creds["refresh_token"] == new_rt - - def test_refresh_failure_clears_nothing(self, cred_file: Path): - """Failed refresh should not modify stored credentials.""" - from osa.cli.credentials import read_credentials - - write_credentials( - "https://example.com", - access_token="old-at", - refresh_token="old-rt", - path=cred_file, - ) - - # After a failed refresh, credentials should remain unchanged - creds = read_credentials("https://example.com", path=cred_file) - assert creds is not None - assert creds["access_token"] == "old-at" diff --git a/sdk/py/tests/test_deploy_v2.py b/sdk/py/tests/test_deploy_v2.py deleted file mode 100644 index a03f2ed..0000000 --- a/sdk/py/tests/test_deploy_v2.py +++ /dev/null @@ -1,335 +0,0 @@ -"""Tests for osa deploy — convention payload building and server registration.""" - -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -import pytest - -from osa._registry import ConventionInfo, HookInfo, SourceInfo, clear - - -class FakeSchema: - """Fake schema class that mimics MetadataSchema.""" - - __name__ = "FakeSchema" - - @classmethod - def to_field_definitions(cls) -> list[dict]: - return [ - { - "name": "title", - "type": "text", - "required": True, - "cardinality": "exactly_one", - }, - { - "name": "score", - "type": "number", - "required": False, - "cardinality": "exactly_one", - "constraints": {"type": "number", "unit": "\u00c5"}, - }, - ] - - -class FakeSource: - name = "test-source" - schedule = None - initial_run = None - - class RuntimeConfig: - def model_dump(self) -> dict: - return {"email": "", "batch_size": 100} - - -def fake_hook(record): - pass - - -fake_hook.__name__ = "detect_pockets" - - -class TestConventionToPayload: - def test_builds_payload_with_schema_fields(self) -> None: - from osa.cli.deploy import _convention_to_payload - - conv = ConventionInfo( - title="Test Convention", - version="1.0.0", - schema_type=FakeSchema, - file_requirements={ - "accepted_types": [".csv"], - "max_count": 10, - "max_file_size": 1000, - }, - hooks=[], - source_type=None, - source_info=None, - ) - - payload = _convention_to_payload(conv, []) - assert payload["title"] == "Test Convention" - assert payload["version"] == "1.0.0" - assert len(payload["schema"]) == 2 - assert payload["schema"][0]["name"] == "title" - assert payload["source"] is None - - def test_includes_source_definition(self) -> None: - from osa.cli.deploy import _convention_to_payload - - source_info = SourceInfo(source_cls=FakeSource, name="test-source") - - conv = ConventionInfo( - title="Test", - version="1.0.0", - schema_type=FakeSchema, - file_requirements={ - "accepted_types": [".cif"], - "max_count": 5, - "max_file_size": 500, - }, - hooks=[], - source_type=FakeSource, - source_info=source_info, - ) - - payload = _convention_to_payload( - conv, - [], - source_image=("osa-hooks-sources/test-source:latest", "sha256:abc123"), - ) - assert payload["source"] is not None - assert payload["source"]["image"] == "osa-hooks-sources/test-source:latest" - assert payload["source"]["digest"] == "sha256:abc123" - assert payload["source"]["runner"] == "oci" - assert payload["source"]["config"] == {"email": "", "batch_size": 100} - assert payload["source"]["limits"]["timeout_seconds"] == 3600 - - def test_source_none_when_no_source(self) -> None: - from osa.cli.deploy import _convention_to_payload - - conv = ConventionInfo( - title="Test", - version="1.0.0", - schema_type=FakeSchema, - file_requirements={"accepted_types": [".csv"]}, - hooks=[], - source_type=None, - source_info=None, - ) - - payload = _convention_to_payload(conv, []) - assert payload["source"] is None - - def test_source_none_when_no_source_image_provided(self) -> None: - """Even with a source_type, if no source_image tuple is given, source is None.""" - from osa.cli.deploy import _convention_to_payload - - source_info = SourceInfo(source_cls=FakeSource, name="test-source") - - conv = ConventionInfo( - title="Test", - version="1.0.0", - schema_type=FakeSchema, - file_requirements={"accepted_types": [".cif"]}, - hooks=[], - source_type=FakeSource, - source_info=source_info, - ) - - payload = _convention_to_payload(conv, []) - assert payload["source"] is None - - def test_adds_min_count_if_missing(self) -> None: - from osa.cli.deploy import _convention_to_payload - - conv = ConventionInfo( - title="Test", - version="1.0.0", - schema_type=FakeSchema, - file_requirements={ - "accepted_types": [".csv"], - "max_count": 10, - "max_file_size": 1000, - }, - hooks=[], - source_type=None, - source_info=None, - ) - - payload = _convention_to_payload(conv, []) - assert payload["file_requirements"]["min_count"] == 0 - - def test_includes_hook_definitions(self) -> None: - from osa.cli.deploy import _convention_to_payload - - hook_defs = [ - { - "name": "detect_pockets", - "runtime": { - "type": "oci", - "image": "osa-hooks/detect_pockets:latest", - "digest": "sha256:abc123", - "config": {}, - "limits": {"timeout_seconds": 300, "memory": "2g", "cpu": "2.0"}, - }, - "feature": { - "kind": "table", - "cardinality": "many", - "columns": [], - }, - } - ] - - conv = ConventionInfo( - title="Test", - version="1.0.0", - schema_type=FakeSchema, - file_requirements={ - "accepted_types": [".csv"], - "max_count": 10, - "max_file_size": 1000, - }, - hooks=[fake_hook], - source_type=None, - source_info=None, - ) - - payload = _convention_to_payload(conv, hook_defs) - assert len(payload["hooks"]) == 1 - assert ( - payload["hooks"][0]["runtime"]["image"] == "osa-hooks/detect_pockets:latest" - ) - - -class TestHookToDefinition: - def test_builds_hook_definition(self) -> None: - from pydantic import BaseModel - - from osa.cli.deploy import _hook_to_definition - - class Pocket(BaseModel): - pocket_id: int - score: float - - hook_info = HookInfo( - fn=fake_hook, - name="detect_pockets", - hook_type="hook", - schema_type=FakeSchema, - output_type=Pocket, - cardinality="many", - ) - - defn = _hook_to_definition( - hook_info, "osa-hooks/detect_pockets:latest", "sha256:abc" - ) - assert defn["runtime"]["image"] == "osa-hooks/detect_pockets:latest" - assert defn["runtime"]["digest"] == "sha256:abc" - assert defn["name"] == "detect_pockets" - assert defn["feature"]["cardinality"] == "many" - assert len(defn["feature"]["columns"]) == 2 - - def test_empty_columns_when_no_output_type(self) -> None: - from osa.cli.deploy import _hook_to_definition - - hook_info = HookInfo( - fn=fake_hook, - name="simple_hook", - hook_type="hook", - schema_type=FakeSchema, - output_type=None, - cardinality="one", - ) - - defn = _hook_to_definition(hook_info, "img:latest", "sha256:xyz") - assert defn["feature"]["columns"] == [] - - -class TestDeployRaisesWithoutConventions: - def setup_method(self) -> None: - clear() - - def test_raises_if_no_conventions(self) -> None: - from osa.cli.deploy import deploy - - with pytest.raises(RuntimeError, match="No conventions registered"): - deploy(server="http://localhost:8000") - - -class TestDeployEndToEnd: - def setup_method(self) -> None: - clear() - - def test_builds_and_registers(self) -> None: - from osa._registry import _conventions, _hooks - - from osa.cli.deploy import deploy - - source_info = SourceInfo(source_cls=FakeSource, name="test-source") - - # Register a fake convention and hook - _hooks.append( - HookInfo( - fn=fake_hook, - name="detect_pockets", - hook_type="hook", - schema_type=FakeSchema, - output_type=None, - cardinality="many", - ) - ) - _conventions.append( - ConventionInfo( - title="PDB Structures", - version="1.0.0", - schema_type=FakeSchema, - file_requirements={ - "accepted_types": [".cif"], - "max_count": 5, - "max_file_size": 500_000_000, - }, - hooks=[fake_hook], - source_type=FakeSource, - source_info=source_info, - ) - ) - - # Mock docker build + inspect - mock_run = MagicMock() - mock_run.return_value = MagicMock(returncode=0, stdout="sha256:fakedigest\n") - - # Mock httpx.post - mock_response = MagicMock() - mock_response.json.return_value = { - "srn": "urn:osa:localhost:conv:abc", - "title": "PDB Structures", - } - mock_response.raise_for_status = MagicMock() - - mock_httpx = MagicMock() - mock_httpx.post.return_value = mock_response - - with ( - patch("osa.cli.deploy.subprocess.run", mock_run), - patch("osa.cli.deploy.httpx", mock_httpx), - patch("osa.cli.deploy.Path.write_text"), - patch("osa.cli.deploy.Path.unlink"), - ): - result = deploy( - server="http://localhost:8000", - token="fake-jwt", - ) - - assert result["srn"] == "urn:osa:localhost:conv:abc" - - # Verify POST was made to correct URL - call_args = mock_httpx.post.call_args - payload = call_args[1]["json"] - assert payload["title"] == "PDB Structures" - # source is now an object (not source_name/source_config) - assert payload["source"] is not None - assert payload["source"]["runner"] == "oci" - assert payload["source"]["config"] == {"email": "", "batch_size": 100} - assert "Bearer fake-jwt" in call_args[1]["headers"]["Authorization"] diff --git a/sdk/py/tests/test_entrypoint_new.py b/sdk/py/tests/test_entrypoint_new.py deleted file mode 100644 index 014fcd8..0000000 --- a/sdk/py/tests/test_entrypoint_new.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Tests for the updated runtime entrypoint: handles Reject, writes features.json.""" - -from __future__ import annotations - -import json - -from pydantic import BaseModel - -from osa.types.record import Record -from osa.types.schema import MetadataSchema - - -class SampleSchema(MetadataSchema): - organism: str - title: str - - -class PocketResult(BaseModel): - pocket_id: str - score: float - - -class QualityResult(BaseModel): - atom_count: int - - -class TestNewEntrypoint: - def setup_method(self) -> None: - from osa._registry import clear - - clear() - - def test_success_writes_features_json(self, tmp_path) -> None: - from osa.authoring.hook import hook - from osa.runtime.entrypoint import run_hook_entrypoint - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [PocketResult(pocket_id="P1", score=0.85)] - - input_dir = tmp_path / "in" - output_dir = tmp_path / "out" - input_dir.mkdir() - output_dir.mkdir() - (input_dir / "record.json").write_text( - json.dumps({"organism": "Human", "title": "Test"}) - ) - - exit_code = run_hook_entrypoint( - hook_fn=detect, input_dir=input_dir, output_dir=output_dir - ) - assert exit_code == 0 - - features = json.loads((output_dir / "features.json").read_text()) - assert isinstance(features, list) - assert features[0]["pocket_id"] == "P1" - - def test_scalar_result_writes_single_object(self, tmp_path) -> None: - from osa.authoring.hook import hook - from osa.runtime.entrypoint import run_hook_entrypoint - - @hook - def check(record: Record[SampleSchema]) -> QualityResult: - return QualityResult(atom_count=42) - - input_dir = tmp_path / "in" - output_dir = tmp_path / "out" - input_dir.mkdir() - output_dir.mkdir() - (input_dir / "record.json").write_text( - json.dumps({"organism": "Mouse", "title": "Test"}) - ) - - exit_code = run_hook_entrypoint( - hook_fn=check, input_dir=input_dir, output_dir=output_dir - ) - assert exit_code == 0 - - features = json.loads((output_dir / "features.json").read_text()) - assert isinstance(features, dict) - assert features["atom_count"] == 42 - - def test_reject_writes_to_progress(self, tmp_path) -> None: - from osa.authoring.hook import hook - from osa.authoring.validator import Reject - from osa.runtime.entrypoint import run_hook_entrypoint - - @hook - def check(record: Record[SampleSchema]) -> QualityResult: - raise Reject("Bad structure file") - - input_dir = tmp_path / "in" - output_dir = tmp_path / "out" - input_dir.mkdir() - output_dir.mkdir() - (input_dir / "record.json").write_text( - json.dumps({"organism": "Human", "title": "Test"}) - ) - - exit_code = run_hook_entrypoint( - hook_fn=check, input_dir=input_dir, output_dir=output_dir - ) - assert exit_code == 0 # Clean exit for rejections - - progress_file = output_dir / "progress.jsonl" - assert progress_file.exists() - lines = progress_file.read_text().strip().split("\n") - data = json.loads(lines[-1]) - assert data["status"] == "rejected" - assert "Bad structure file" in data["message"] - - def test_envelope_format_populates_srn_and_metadata(self, tmp_path) -> None: - from osa.authoring.hook import hook - from osa.runtime.entrypoint import run_hook_entrypoint - - captured_record = {} - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - captured_record["srn"] = record.srn - captured_record["organism"] = record.metadata.organism - return [PocketResult(pocket_id="P1", score=0.85)] - - input_dir = tmp_path / "in" - output_dir = tmp_path / "out" - input_dir.mkdir() - output_dir.mkdir() - (input_dir / "record.json").write_text( - json.dumps( - { - "srn": "urn:osa:localhost:dep:abc123", - "metadata": {"organism": "Human", "title": "Test"}, - } - ) - ) - - exit_code = run_hook_entrypoint( - hook_fn=detect, input_dir=input_dir, output_dir=output_dir - ) - assert exit_code == 0 - assert captured_record["srn"] == "urn:osa:localhost:dep:abc123" - assert captured_record["organism"] == "Human" - - def test_flat_format_still_works(self, tmp_path) -> None: - from osa.authoring.hook import hook - from osa.runtime.entrypoint import run_hook_entrypoint - - captured_record = {} - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - captured_record["srn"] = record.srn - captured_record["organism"] = record.metadata.organism - return [PocketResult(pocket_id="P1", score=0.85)] - - input_dir = tmp_path / "in" - output_dir = tmp_path / "out" - input_dir.mkdir() - output_dir.mkdir() - (input_dir / "record.json").write_text( - json.dumps({"organism": "Human", "title": "Test"}) - ) - - exit_code = run_hook_entrypoint( - hook_fn=detect, input_dir=input_dir, output_dir=output_dir - ) - assert exit_code == 0 - assert captured_record["srn"] == "" - assert captured_record["organism"] == "Human" - - def test_entrypoint_reads_files_dir(self, tmp_path) -> None: - from osa.authoring.hook import hook - from osa.runtime.entrypoint import run_hook_entrypoint - - input_dir = tmp_path / "in" - output_dir = tmp_path / "out" - input_dir.mkdir() - output_dir.mkdir() - files_dir = input_dir / "files" - files_dir.mkdir() - (files_dir / "test.cif").write_text("ATOM 1") - (input_dir / "record.json").write_text( - json.dumps({"organism": "Human", "title": "Test"}) - ) - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - cif_files = record.files.glob("*.cif") - assert len(cif_files) == 1 - return [PocketResult(pocket_id="P1", score=0.9)] - - exit_code = run_hook_entrypoint( - hook_fn=detect, input_dir=input_dir, output_dir=output_dir - ) - assert exit_code == 0 diff --git a/sdk/py/tests/test_files.py b/sdk/py/tests/test_files.py deleted file mode 100644 index 8d1061d..0000000 --- a/sdk/py/tests/test_files.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Tests for File and FileCollection.""" - -from pathlib import Path - -import pytest - -from osa.types.files import File, FileCollection - -FIXTURES_DIR = Path(__file__).parent / "fixtures" / "files" - - -class TestFile: - def test_name_returns_filename(self) -> None: - f = File(FIXTURES_DIR / "sample.pdb") - assert f.name == "sample.pdb" - - def test_size_returns_bytes(self) -> None: - f = File(FIXTURES_DIR / "sample.pdb") - assert f.size > 0 - - def test_read_returns_bytes(self) -> None: - f = File(FIXTURES_DIR / "sample.pdb") - content = f.read() - assert isinstance(content, bytes) - assert b"ATOM" in content - - -class TestFileCollection: - def test_list_returns_all_files(self) -> None: - col = FileCollection(FIXTURES_DIR) - files = col.list() - assert len(files) >= 2 - names = {f.name for f in files} - assert "sample.pdb" in names - assert "ligands.sdf" in names - - def test_glob_matches(self) -> None: - col = FileCollection(FIXTURES_DIR) - pdb_files = col.glob("*.pdb") - assert len(pdb_files) == 1 - assert pdb_files[0].name == "sample.pdb" - - def test_glob_no_match(self) -> None: - col = FileCollection(FIXTURES_DIR) - assert col.glob("*.xyz") == [] - - def test_getitem_found(self) -> None: - col = FileCollection(FIXTURES_DIR) - f = col["sample.pdb"] - assert f.name == "sample.pdb" - - def test_getitem_not_found(self) -> None: - col = FileCollection(FIXTURES_DIR) - with pytest.raises(KeyError): - col["nonexistent.txt"] - - def test_iter(self) -> None: - col = FileCollection(FIXTURES_DIR) - names = [f.name for f in col] - assert "sample.pdb" in names - - def test_len(self) -> None: - col = FileCollection(FIXTURES_DIR) - assert len(col) >= 2 diff --git a/sdk/py/tests/test_harness.py b/sdk/py/tests/test_harness.py deleted file mode 100644 index 99bbcf1..0000000 --- a/sdk/py/tests/test_harness.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Tests for run_hook() test harness.""" - -from __future__ import annotations - -import pytest -from pydantic import BaseModel - -from osa.types.record import Record -from osa.types.schema import MetadataSchema - - -class SampleSchema(MetadataSchema): - organism: str - title: str - - -class PocketResult(BaseModel): - pocket_id: str - score: float - - -class QualityResult(BaseModel): - atom_count: int - completeness: float - - -class TestRunHook: - def setup_method(self) -> None: - from osa._registry import clear - - clear() - - def test_passes_valid_metadata(self) -> None: - from osa.authoring.hook import hook - from osa.testing.harness import run_hook - - @hook - def check(record: Record[SampleSchema]) -> QualityResult: - assert record.metadata.organism == "Human" - return QualityResult(atom_count=100, completeness=0.9) - - result = run_hook( - check, - meta={"organism": "Human", "title": "Test"}, - ) - assert result.atom_count == 100 - - def test_returns_typed_result_scalar(self) -> None: - from osa.authoring.hook import hook - from osa.testing.harness import run_hook - - @hook - def check(record: Record[SampleSchema]) -> QualityResult: - return QualityResult(atom_count=42, completeness=0.5) - - result = run_hook(check, meta={"organism": "Mouse", "title": "Test"}) - assert isinstance(result, QualityResult) - assert result.atom_count == 42 - - def test_returns_typed_result_list(self) -> None: - from osa.authoring.hook import hook - from osa.testing.harness import run_hook - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [ - PocketResult(pocket_id="P1", score=0.9), - PocketResult(pocket_id="P2", score=0.7), - ] - - result = run_hook(detect, meta={"organism": "Human", "title": "Test"}) - assert isinstance(result, list) - assert len(result) == 2 - assert result[0].pocket_id == "P1" - - def test_catches_reject(self) -> None: - from osa.authoring.hook import hook - from osa.authoring.validator import Reject - from osa.testing.harness import run_hook - - @hook - def check(record: Record[SampleSchema]) -> QualityResult: - raise Reject("Bad data") - - with pytest.raises(Reject, match="Bad data"): - run_hook(check, meta={"organism": "Human", "title": "Test"}) - - def test_passes_files_directory(self, tmp_path) -> None: - from osa.authoring.hook import hook - from osa.testing.harness import run_hook - - # Create a test file - (tmp_path / "test.cif").write_text("ATOM 1") - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - files = record.files.glob("*.cif") - assert len(files) == 1 - return [PocketResult(pocket_id="P1", score=0.8)] - - result = run_hook( - detect, - meta={"organism": "Human", "title": "Test"}, - files=tmp_path, - ) - assert len(result) == 1 - - def test_passes_srn_to_record(self) -> None: - from osa.authoring.hook import hook - from osa.testing.harness import run_hook - - @hook - def check(record: Record[SampleSchema]) -> QualityResult: - assert record.srn == "urn:osa:localhost:dep:abc123" - return QualityResult(atom_count=50, completeness=1.0) - - result = run_hook( - check, - meta={"organism": "Human", "title": "Test"}, - srn="urn:osa:localhost:dep:abc123", - ) - assert result.completeness == 1.0 - - def test_srn_defaults_to_empty(self) -> None: - from osa.authoring.hook import hook - from osa.testing.harness import run_hook - - @hook - def check(record: Record[SampleSchema]) -> QualityResult: - assert record.srn == "" - return QualityResult(atom_count=50, completeness=1.0) - - run_hook(check, meta={"organism": "Human", "title": "Test"}) - - def test_works_without_files(self) -> None: - from osa.authoring.hook import hook - from osa.testing.harness import run_hook - - @hook - def check(record: Record[SampleSchema]) -> QualityResult: - return QualityResult(atom_count=50, completeness=1.0) - - result = run_hook(check, meta={"organism": "Human", "title": "Test"}) - assert result.completeness == 1.0 diff --git a/sdk/py/tests/test_hook_decorator.py b/sdk/py/tests/test_hook_decorator.py deleted file mode 100644 index 73f277c..0000000 --- a/sdk/py/tests/test_hook_decorator.py +++ /dev/null @@ -1,197 +0,0 @@ -"""Tests for the unified @hook decorator: registration, type introspection, cardinality detection.""" - -from __future__ import annotations - -from pydantic import BaseModel - -from osa.types.record import Record -from osa.types.schema import MetadataSchema - - -class SampleSchema(MetadataSchema): - organism: str - resolution: float | None = None - - -class PocketResult(BaseModel): - pocket_id: str - score: float - - -class QualityResult(BaseModel): - atom_count: int - completeness: float - - -class TestHookRegistration: - def setup_method(self) -> None: - from osa._registry import clear - - clear() - - def test_hook_registers_in_global_registry(self) -> None: - from osa._registry import _hooks - from osa.authoring.hook import hook - - @hook - def detect_pockets(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - assert len(_hooks) == 1 - - def test_hook_preserves_function_callable(self) -> None: - from osa.authoring.hook import hook - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - assert callable(detect) - - def test_hook_preserves_function_name(self) -> None: - from osa.authoring.hook import hook - - @hook - def detect_pockets(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - assert detect_pockets.__name__ == "detect_pockets" - - def test_multiple_hooks_register(self) -> None: - from osa._registry import _hooks - from osa.authoring.hook import hook - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - @hook - def quality(record: Record[SampleSchema]) -> QualityResult: - return QualityResult(atom_count=100, completeness=0.9) - - assert len(_hooks) == 2 - - -class TestHookTypeIntrospection: - def setup_method(self) -> None: - from osa._registry import clear - - clear() - - def test_extracts_schema_type(self) -> None: - from osa._registry import _hooks - from osa.authoring.hook import hook - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - assert _hooks[0].schema_type is SampleSchema - - def test_extracts_output_type_from_list(self) -> None: - from osa._registry import _hooks - from osa.authoring.hook import hook - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - assert _hooks[0].output_type is PocketResult - - def test_extracts_output_type_from_scalar(self) -> None: - from osa._registry import _hooks - from osa.authoring.hook import hook - - @hook - def check(record: Record[SampleSchema]) -> QualityResult: - return QualityResult(atom_count=100, completeness=0.9) - - assert _hooks[0].output_type is QualityResult - - def test_extracts_name(self) -> None: - from osa._registry import _hooks - from osa.authoring.hook import hook - - @hook - def detect_pockets(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - assert _hooks[0].name == "detect_pockets" - - -class TestHookCardinality: - def setup_method(self) -> None: - from osa._registry import clear - - clear() - - def test_list_return_is_many(self) -> None: - from osa._registry import _hooks - from osa.authoring.hook import hook - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - assert _hooks[0].cardinality == "many" - - def test_scalar_return_is_one(self) -> None: - from osa._registry import _hooks - from osa.authoring.hook import hook - - @hook - def check(record: Record[SampleSchema]) -> QualityResult: - return QualityResult(atom_count=100, completeness=0.9) - - assert _hooks[0].cardinality == "one" - - -class TestHookDependencies: - def setup_method(self) -> None: - from osa._registry import clear - - clear() - - def test_no_dependencies_for_simple_hook(self) -> None: - from osa._registry import _hooks - from osa.authoring.hook import hook - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - assert _hooks[0].dependencies == {} - - def test_extracts_dependencies(self) -> None: - from osa._registry import _hooks - from osa.authoring.hook import hook - - @hook - def detect( - record: Record[SampleSchema], - quality: QualityResult, - ) -> list[PocketResult]: - return [] - - assert "quality" in _hooks[0].dependencies - assert _hooks[0].dependencies["quality"] is QualityResult - - -class TestHookRegistrySchemaType: - """Verify @hook stores schema_type in the registry.""" - - def setup_method(self) -> None: - from osa._registry import clear - - clear() - - def test_schema_type_available_via_registry(self) -> None: - from osa._registry import _hooks - from osa.authoring.hook import hook - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - info = next(h for h in _hooks if h.fn is detect) - assert info.schema_type is SampleSchema diff --git a/sdk/py/tests/test_link.py b/sdk/py/tests/test_link.py deleted file mode 100644 index 119d89c..0000000 --- a/sdk/py/tests/test_link.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Unit tests for project linking (link.py).""" - -import json -from pathlib import Path - -import pytest - -from osa.cli.link import read_link, resolve_server, write_link - - -class TestWriteLink: - """Tests for write_link.""" - - def test_creates_config_file(self, tmp_path: Path): - path = write_link("https://archive.example.com", project_dir=tmp_path) - - assert path == tmp_path / ".osa" / "config.json" - assert path.exists() - - data = json.loads(path.read_text()) - assert data == {"server": "https://archive.example.com"} - - def test_strips_trailing_slash(self, tmp_path: Path): - write_link("https://example.com///", project_dir=tmp_path) - - data = json.loads((tmp_path / ".osa" / "config.json").read_text()) - assert data["server"] == "https://example.com" - - def test_creates_osa_directory(self, tmp_path: Path): - write_link("https://example.com", project_dir=tmp_path) - assert (tmp_path / ".osa").is_dir() - - def test_overwrites_existing_config(self, tmp_path: Path): - write_link("https://old.com", project_dir=tmp_path) - write_link("https://new.com", project_dir=tmp_path) - - data = json.loads((tmp_path / ".osa" / "config.json").read_text()) - assert data["server"] == "https://new.com" - - -class TestReadLink: - """Tests for read_link.""" - - def test_reads_server_url(self, tmp_path: Path): - write_link("https://example.com", project_dir=tmp_path) - - result = read_link(project_dir=tmp_path) - assert result == "https://example.com" - - def test_returns_none_when_no_config(self, tmp_path: Path): - result = read_link(project_dir=tmp_path) - assert result is None - - def test_returns_none_for_invalid_json(self, tmp_path: Path): - config_dir = tmp_path / ".osa" - config_dir.mkdir() - (config_dir / "config.json").write_text("not json") - - result = read_link(project_dir=tmp_path) - assert result is None - - def test_returns_none_for_missing_server_key(self, tmp_path: Path): - config_dir = tmp_path / ".osa" - config_dir.mkdir() - (config_dir / "config.json").write_text(json.dumps({"other": "value"})) - - result = read_link(project_dir=tmp_path) - assert result is None - - def test_returns_none_for_empty_server(self, tmp_path: Path): - config_dir = tmp_path / ".osa" - config_dir.mkdir() - (config_dir / "config.json").write_text(json.dumps({"server": ""})) - - result = read_link(project_dir=tmp_path) - assert result is None - - -class TestResolveServer: - """Tests for resolve_server.""" - - def test_flag_takes_highest_priority(self, tmp_path: Path, monkeypatch): - monkeypatch.setenv("OSA_SERVER", "https://env.com") - write_link("https://linked.com", project_dir=tmp_path) - - result = resolve_server(flag="https://flag.com", project_dir=tmp_path) - assert result == "https://flag.com" - - def test_env_takes_priority_over_config(self, tmp_path: Path, monkeypatch): - monkeypatch.setenv("OSA_SERVER", "https://env.com") - write_link("https://linked.com", project_dir=tmp_path) - - result = resolve_server(project_dir=tmp_path) - assert result == "https://env.com" - - def test_config_file_used_when_no_flag_or_env(self, tmp_path: Path, monkeypatch): - monkeypatch.delenv("OSA_SERVER", raising=False) - write_link("https://linked.com", project_dir=tmp_path) - - result = resolve_server(project_dir=tmp_path) - assert result == "https://linked.com" - - def test_exits_when_no_sources(self, tmp_path: Path, monkeypatch): - monkeypatch.delenv("OSA_SERVER", raising=False) - - with pytest.raises(SystemExit) as exc_info: - resolve_server(project_dir=tmp_path) - - assert exc_info.value.code == 1 - - def test_flag_strips_trailing_slash(self, tmp_path: Path, monkeypatch): - monkeypatch.delenv("OSA_SERVER", raising=False) - - result = resolve_server(flag="https://example.com/", project_dir=tmp_path) - assert result == "https://example.com" - - def test_env_strips_trailing_slash(self, tmp_path: Path, monkeypatch): - monkeypatch.setenv("OSA_SERVER", "https://example.com/") - - result = resolve_server(project_dir=tmp_path) - assert result == "https://example.com" diff --git a/sdk/py/tests/test_login.py b/sdk/py/tests/test_login.py deleted file mode 100644 index 318aa2b..0000000 --- a/sdk/py/tests/test_login.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Unit tests for login command polling loop (T039).""" - -from unittest.mock import MagicMock, patch - -import httpx - -from osa.cli.login import _poll_for_token - - -def _make_response(status_code: int, json_data: dict) -> httpx.Response: - """Create a mock httpx.Response.""" - return httpx.Response( - status_code=status_code, - json=json_data, - request=httpx.Request("POST", "https://example.com/api/v1/auth/device/token"), - ) - - -class TestPollForToken: - """Tests for the _poll_for_token polling loop.""" - - def test_returns_tokens_on_success(self): - """Poll should return tokens when server responds with 200.""" - client = MagicMock() - client.post.return_value = _make_response( - 200, - { - "access_token": "at-123", - "refresh_token": "rt-456", - "token_type": "Bearer", - "expires_in": 3600, - }, - ) - - result = _poll_for_token( - client=client, - server="https://example.com", - device_code="dc-abc", - interval=1, - expires_in=300, - ) - - assert result is not None - assert result["access_token"] == "at-123" - assert result["refresh_token"] == "rt-456" - - def test_polls_until_authorized(self): - """Poll should keep polling on authorization_pending, then return on success.""" - pending_resp = _make_response( - 400, - {"error": "authorization_pending", "error_description": "Not yet"}, - ) - success_resp = _make_response( - 200, - { - "access_token": "at", - "refresh_token": "rt", - "token_type": "Bearer", - "expires_in": 3600, - }, - ) - - client = MagicMock() - client.post.side_effect = [pending_resp, pending_resp, success_resp] - - with patch("osa.cli.login.time") as mock_time: - mock_time.monotonic.side_effect = [0, 1, 2, 3, 4, 5, 6] - mock_time.sleep = MagicMock() - - result = _poll_for_token( - client=client, - server="https://example.com", - device_code="dc", - interval=1, - expires_in=300, - ) - - assert result is not None - assert client.post.call_count == 3 - - def test_returns_none_on_expired(self): - """Poll should return None when server responds with expired_token.""" - client = MagicMock() - client.post.return_value = _make_response( - 400, - {"error": "expired_token", "error_description": "Code expired"}, - ) - - result = _poll_for_token( - client=client, - server="https://example.com", - device_code="dc", - interval=1, - expires_in=300, - ) - - assert result is None - - def test_retries_on_network_error(self): - """Poll should retry on transient network errors.""" - client = MagicMock() - client.post.side_effect = [ - httpx.ConnectError("Connection refused"), - _make_response( - 200, - { - "access_token": "at", - "refresh_token": "rt", - "token_type": "Bearer", - "expires_in": 3600, - }, - ), - ] - - with patch("osa.cli.login.time") as mock_time: - mock_time.monotonic.side_effect = [0, 1, 2, 3, 4] - mock_time.sleep = MagicMock() - - result = _poll_for_token( - client=client, - server="https://example.com", - device_code="dc", - interval=1, - expires_in=300, - ) - - assert result is not None - assert client.post.call_count == 2 - - def test_retries_on_server_error(self): - """Poll should retry on HTTP 5xx errors.""" - client = MagicMock() - client.post.side_effect = [ - _make_response(500, {"error": "internal"}), - _make_response( - 200, - { - "access_token": "at", - "refresh_token": "rt", - "token_type": "Bearer", - "expires_in": 3600, - }, - ), - ] - - with patch("osa.cli.login.time") as mock_time: - mock_time.monotonic.side_effect = [0, 1, 2, 3, 4] - mock_time.sleep = MagicMock() - - result = _poll_for_token( - client=client, - server="https://example.com", - device_code="dc", - interval=1, - expires_in=300, - ) - - assert result is not None - - def test_returns_none_on_timeout(self): - """Poll should return None when total time exceeds expires_in.""" - client = MagicMock() - client.post.return_value = _make_response( - 400, - {"error": "authorization_pending"}, - ) - - with patch("osa.cli.login.time") as mock_time: - # Simulate time passing beyond expires_in - mock_time.monotonic.side_effect = [0, 301] - mock_time.sleep = MagicMock() - - result = _poll_for_token( - client=client, - server="https://example.com", - device_code="dc", - interval=5, - expires_in=300, - ) - - assert result is None diff --git a/sdk/py/tests/test_logout.py b/sdk/py/tests/test_logout.py deleted file mode 100644 index 598d3d0..0000000 --- a/sdk/py/tests/test_logout.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Unit tests for logout command (T047).""" - -from pathlib import Path - -import pytest - -from osa.cli.credentials import read_credentials, write_credentials -from osa.cli.logout import logout - - -@pytest.fixture -def cred_file(tmp_path: Path) -> Path: - return tmp_path / "credentials.json" - - -class TestLogout: - """Tests for logout command.""" - - def test_removes_credentials(self, cred_file: Path, capsys): - write_credentials( - "https://a.com", access_token="at", refresh_token="rt", path=cred_file - ) - - logout("https://a.com", cred_path=cred_file) - - assert read_credentials("https://a.com", path=cred_file) is None - assert "Logged out" in capsys.readouterr().out - - def test_noop_when_none_exist(self, cred_file: Path, capsys): - logout("https://a.com", cred_path=cred_file) - - assert "No credentials found" in capsys.readouterr().out - - def test_multi_server_isolation(self, cred_file: Path): - write_credentials( - "https://a.com", access_token="a", refresh_token="a", path=cred_file - ) - write_credentials( - "https://b.com", access_token="b", refresh_token="b", path=cred_file - ) - - logout("https://a.com", cred_path=cred_file) - - assert read_credentials("https://a.com", path=cred_file) is None - assert read_credentials("https://b.com", path=cred_file) is not None diff --git a/sdk/py/tests/test_new_manifest.py b/sdk/py/tests/test_new_manifest.py deleted file mode 100644 index 3ae4a75..0000000 --- a/sdk/py/tests/test_new_manifest.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Tests for column generation from Pydantic BaseModel and manifest generation.""" - -from __future__ import annotations - -from datetime import datetime -from typing import Any -from uuid import UUID - -from pydantic import BaseModel - -from osa.types.record import Record -from osa.types.schema import MetadataSchema - - -class SampleSchema(MetadataSchema): - organism: str - - -class PocketResult(BaseModel): - pocket_id: str - score: float - volume: float - n_spheres: int - - -class SimpleResult(BaseModel): - name: str - ok: bool - count: int - - -class AllTypesResult(BaseModel): - text_field: str - number_field: float - integer_field: int - boolean_field: bool - optional_field: str | None = None - list_field: list[int] = [] - dict_field: dict[str, Any] = {} - - -class DateResult(BaseModel): - created: datetime - id: UUID - - -class TestColumnGeneration: - def setup_method(self) -> None: - from osa._registry import clear - - clear() - - def test_generates_column_defs_from_model(self) -> None: - from osa.manifest import generate_columns - - columns = generate_columns(PocketResult) - assert len(columns) == 4 - names = [c.name for c in columns] - assert "pocket_id" in names - assert "score" in names - assert "volume" in names - assert "n_spheres" in names - - def test_maps_str_to_string(self) -> None: - from osa.manifest import generate_columns - - columns = generate_columns(SimpleResult) - name_col = next(c for c in columns if c.name == "name") - assert name_col.json_type == "string" - - def test_maps_float_to_number(self) -> None: - from osa.manifest import generate_columns - - columns = generate_columns(PocketResult) - score_col = next(c for c in columns if c.name == "score") - assert score_col.json_type == "number" - - def test_maps_int_to_integer(self) -> None: - from osa.manifest import generate_columns - - columns = generate_columns(PocketResult) - n_col = next(c for c in columns if c.name == "n_spheres") - assert n_col.json_type == "integer" - - def test_maps_bool_to_boolean(self) -> None: - from osa.manifest import generate_columns - - columns = generate_columns(SimpleResult) - ok_col = next(c for c in columns if c.name == "ok") - assert ok_col.json_type == "boolean" - - def test_optional_field_not_required(self) -> None: - from osa.manifest import generate_columns - - columns = generate_columns(AllTypesResult) - opt_col = next(c for c in columns if c.name == "optional_field") - assert opt_col.required is False - assert opt_col.json_type == "string" - - def test_required_field_is_required(self) -> None: - from osa.manifest import generate_columns - - columns = generate_columns(PocketResult) - for col in columns: - assert col.required is True - - def test_list_field_maps_to_array(self) -> None: - from osa.manifest import generate_columns - - columns = generate_columns(AllTypesResult) - list_col = next(c for c in columns if c.name == "list_field") - assert list_col.json_type == "array" - - def test_dict_field_maps_to_object(self) -> None: - from osa.manifest import generate_columns - - columns = generate_columns(AllTypesResult) - dict_col = next(c for c in columns if c.name == "dict_field") - assert dict_col.json_type == "object" - - def test_datetime_gets_date_time_format(self) -> None: - from osa.manifest import generate_columns - - columns = generate_columns(DateResult) - created_col = next(c for c in columns if c.name == "created") - assert created_col.json_type == "string" - assert created_col.format == "date-time" - - def test_uuid_gets_uuid_format(self) -> None: - from osa.manifest import generate_columns - - columns = generate_columns(DateResult) - id_col = next(c for c in columns if c.name == "id") - assert id_col.json_type == "string" - assert id_col.format == "uuid" - - -class TestNewManifestGeneration: - """Test the updated manifest generation with hooks + conventions.""" - - def setup_method(self) -> None: - from osa._registry import clear - - clear() - - def test_manifest_includes_hooks_with_columns(self) -> None: - from osa.authoring.hook import hook - from osa.manifest import generate_manifest - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - m = generate_manifest() - assert len(m.hooks) == 1 - h = m.hooks[0] - assert h.name == "detect" - assert h.cardinality == "many" - assert len(h.columns) == 4 - - def test_manifest_includes_schemas(self) -> None: - from osa.authoring.hook import hook - from osa.manifest import generate_manifest - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - m = generate_manifest() - assert "SampleSchema" in m.schemas - - def test_manifest_serialization_roundtrip(self) -> None: - from osa.authoring.hook import hook - from osa.manifest import Manifest, generate_manifest - - @hook - def detect(record: Record[SampleSchema]) -> list[PocketResult]: - return [] - - m = generate_manifest() - data = m.model_dump_json() - restored = Manifest.model_validate_json(data) - assert len(restored.hooks) == 1 - assert restored.hooks[0].name == "detect" diff --git a/sdk/py/tests/test_record.py b/sdk/py/tests/test_record.py deleted file mode 100644 index dc4a950..0000000 --- a/sdk/py/tests/test_record.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Tests for Record[T].""" - -from datetime import datetime -from pathlib import Path -from typing import Literal - -from osa import Field, MetadataSchema, Record -from osa.types.files import FileCollection - -FIXTURES_DIR = Path(__file__).parent / "fixtures" / "files" - - -class ProteinStructure(MetadataSchema): - """Test schema.""" - - organism: str - method: Literal["xray", "cryo-em", "nmr", "predicted"] - resolution: float | None = Field(default=None, ge=0, le=100, unit="Å") - uniprot_id: str = Field(pattern=r"^[A-Z0-9]{6,10}$") - - -class TestRecord: - def test_metadata_access(self) -> None: - meta = ProteinStructure( - organism="H. sapiens", method="xray", resolution=2.1, uniprot_id="P12345" - ) - files = FileCollection(FIXTURES_DIR) - rec = Record( - id="rec-001", - created_at=datetime(2025, 1, 1), - metadata=meta, - files=files, - ) - assert rec.metadata.organism == "H. sapiens" - assert rec.metadata.resolution == 2.1 - - def test_id_returns_string(self) -> None: - meta = ProteinStructure( - organism="H. sapiens", method="xray", uniprot_id="P12345" - ) - rec = Record( - id="rec-002", - created_at=datetime(2025, 1, 1), - metadata=meta, - files=FileCollection(FIXTURES_DIR), - ) - assert rec.id == "rec-002" - - def test_created_at_returns_datetime(self) -> None: - meta = ProteinStructure( - organism="H. sapiens", method="xray", uniprot_id="P12345" - ) - ts = datetime(2025, 6, 15, 12, 0, 0) - rec = Record( - id="rec-003", - created_at=ts, - metadata=meta, - files=FileCollection(FIXTURES_DIR), - ) - assert rec.created_at == ts - - def test_files_returns_file_collection(self) -> None: - meta = ProteinStructure( - organism="H. sapiens", method="xray", uniprot_id="P12345" - ) - files = FileCollection(FIXTURES_DIR) - rec = Record( - id="rec-004", created_at=datetime(2025, 1, 1), metadata=meta, files=files - ) - assert isinstance(rec.files, FileCollection) - assert len(rec.files) >= 2 - - def test_srn_defaults_to_empty(self) -> None: - meta = ProteinStructure( - organism="H. sapiens", method="xray", uniprot_id="P12345" - ) - rec = Record( - id="rec-006", - created_at=datetime(2025, 1, 1), - metadata=meta, - files=FileCollection(FIXTURES_DIR), - ) - assert rec.srn == "" - - def test_srn_returns_provided_value(self) -> None: - meta = ProteinStructure( - organism="H. sapiens", method="xray", uniprot_id="P12345" - ) - rec = Record( - id="rec-007", - created_at=datetime(2025, 1, 1), - metadata=meta, - files=FileCollection(FIXTURES_DIR), - srn="urn:osa:localhost:dep:abc123", - ) - assert rec.srn == "urn:osa:localhost:dep:abc123" - - def test_empty_file_collection(self, tmp_path: Path) -> None: - meta = ProteinStructure( - organism="H. sapiens", method="xray", uniprot_id="P12345" - ) - files = FileCollection(tmp_path) - rec = Record( - id="rec-005", created_at=datetime(2025, 1, 1), metadata=meta, files=files - ) - assert len(rec.files) == 0 diff --git a/sdk/py/tests/test_schema.py b/sdk/py/tests/test_schema.py deleted file mode 100644 index 3799844..0000000 --- a/sdk/py/tests/test_schema.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Tests for MetadataSchema and Field().""" - -from typing import Literal - -import pytest -from pydantic import ValidationError - -from osa import Field, MetadataSchema - - -class ProteinStructure(MetadataSchema): - """Schema for protein structure deposits.""" - - organism: str - method: Literal["xray", "cryo-em", "nmr", "predicted"] - resolution: float | None = Field(default=None, ge=0, le=100, unit="Å") - uniprot_id: str = Field(pattern=r"^[A-Z0-9]{6,10}$") - - -VALID_DATA = { - "organism": "H. sapiens", - "method": "xray", - "resolution": 2.1, - "uniprot_id": "P12345", -} - - -class TestMetadataSchema: - def test_valid_data_creates_typed_instance(self) -> None: - ps = ProteinStructure(**VALID_DATA) - assert ps.organism == "H. sapiens" - assert ps.method == "xray" - assert ps.resolution == 2.1 - assert ps.uniprot_id == "P12345" - - def test_missing_required_field_raises(self) -> None: - with pytest.raises(ValidationError): - ProteinStructure(organism="H. sapiens", method="xray") - - def test_value_outside_range_raises(self) -> None: - with pytest.raises(ValidationError): - ProteinStructure(**{**VALID_DATA, "resolution": -1.0}) - with pytest.raises(ValidationError): - ProteinStructure(**{**VALID_DATA, "resolution": 101.0}) - - def test_pattern_mismatch_raises(self) -> None: - with pytest.raises(ValidationError): - ProteinStructure(**{**VALID_DATA, "uniprot_id": "invalid!"}) - - def test_default_value_applied(self) -> None: - ps = ProteinStructure(organism="H. sapiens", method="xray", uniprot_id="P12345") - assert ps.resolution is None - - def test_extra_field_raises(self) -> None: - with pytest.raises(ValidationError): - ProteinStructure(**{**VALID_DATA, "extra_field": "nope"}) - - def test_json_schema_has_correct_types(self) -> None: - schema = ProteinStructure.model_json_schema() - props = schema["properties"] - assert props["organism"]["type"] == "string" - assert props["resolution"]["anyOf"] == [ - {"type": "number", "maximum": 100.0, "minimum": 0.0}, - {"type": "null"}, - ] - assert "enum" in props["method"] - - def test_unit_metadata_in_json_schema(self) -> None: - schema = ProteinStructure.model_json_schema() - res_schema = schema["properties"]["resolution"] - assert res_schema.get("unit") == "Å" diff --git a/sdk/py/tests/test_source_types.py b/sdk/py/tests/test_source_types.py deleted file mode 100644 index 00eb5e0..0000000 --- a/sdk/py/tests/test_source_types.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Tests for SDK Source types (SourceFileRef, SourceRecord).""" - -from __future__ import annotations - -from datetime import datetime - -from pydantic import ValidationError -import pytest - - -class TestSourceFileRef: - def test_creates_with_name_and_relative_path(self) -> None: - from osa.types.source import SourceFileRef - - sf = SourceFileRef(name="structure.cif", relative_path="4HHB/structure.cif") - assert sf.name == "structure.cif" - assert sf.relative_path == "4HHB/structure.cif" - - def test_is_frozen(self) -> None: - from osa.types.source import SourceFileRef - - sf = SourceFileRef(name="structure.cif", relative_path="4HHB/structure.cif") - with pytest.raises(ValidationError): - sf.name = "other.cif" # type: ignore[misc] - - -class TestSourceRecord: - def test_creates_with_required_fields(self) -> None: - from osa.types.source import SourceRecord - - sr = SourceRecord( - source_id="4HHB", - metadata={"pdb_id": "4HHB", "title": "Deoxy Human Hemoglobin"}, - ) - assert sr.source_id == "4HHB" - assert sr.metadata["pdb_id"] == "4HHB" - assert sr.files == [] - assert sr.fetched_at is None - - def test_creates_with_files(self) -> None: - from osa.types.source import SourceFileRef, SourceRecord - - sr = SourceRecord( - source_id="4HHB", - metadata={"pdb_id": "4HHB"}, - files=[ - SourceFileRef(name="structure.cif", relative_path="4HHB/structure.cif"), - SourceFileRef(name="structure.pdb", relative_path="4HHB/structure.pdb"), - ], - ) - assert len(sr.files) == 2 - assert sr.files[0].name == "structure.cif" - - def test_creates_with_fetched_at(self) -> None: - from osa.types.source import SourceRecord - - now = datetime(2025, 6, 15, 12, 0, 0) - sr = SourceRecord(source_id="4HHB", metadata={}, fetched_at=now) - assert sr.fetched_at == now - - def test_is_frozen(self) -> None: - from osa.types.source import SourceRecord - - sr = SourceRecord(source_id="4HHB", metadata={}) - with pytest.raises(ValidationError): - sr.source_id = "other" # type: ignore[misc] diff --git a/sdk/py/tests/test_to_field_definitions.py b/sdk/py/tests/test_to_field_definitions.py deleted file mode 100644 index b670e07..0000000 --- a/sdk/py/tests/test_to_field_definitions.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Tests for MetadataSchema.to_field_definitions() — maps Python types to server FieldDefinitions.""" - -from __future__ import annotations - -from datetime import date, datetime - -from osa import Field, MetadataSchema - - -class SimpleSchema(MetadataSchema): - title: str - count: int - score: float - active: bool - - -class DateSchema(MetadataSchema): - created: date - updated: datetime - - -class OptionalSchema(MetadataSchema): - name: str - description: str | None = None - score: float | None = Field(default=None, unit="Å") - - -class UnitSchema(MetadataSchema): - resolution: float | None = Field(default=None, unit="Å") - weight: float = Field(unit="kDa") - - -class TestToFieldDefinitions: - def test_str_maps_to_text(self) -> None: - fields = SimpleSchema.to_field_definitions() - title_field = next(f for f in fields if f["name"] == "title") - assert title_field["type"] == "text" - assert title_field["required"] is True - - def test_int_maps_to_number_integer_only(self) -> None: - fields = SimpleSchema.to_field_definitions() - count_field = next(f for f in fields if f["name"] == "count") - assert count_field["type"] == "number" - assert count_field["constraints"]["type"] == "number" - assert count_field["constraints"]["integer_only"] is True - - def test_float_maps_to_number(self) -> None: - fields = SimpleSchema.to_field_definitions() - score_field = next(f for f in fields if f["name"] == "score") - assert score_field["type"] == "number" - # float has number constraints but not integer_only - assert score_field["constraints"]["type"] == "number" - assert score_field["constraints"].get("integer_only") is not True - - def test_bool_maps_to_boolean(self) -> None: - fields = SimpleSchema.to_field_definitions() - active_field = next(f for f in fields if f["name"] == "active") - assert active_field["type"] == "boolean" - assert active_field["required"] is True - - def test_date_maps_to_date(self) -> None: - fields = DateSchema.to_field_definitions() - created_field = next(f for f in fields if f["name"] == "created") - assert created_field["type"] == "date" - - def test_datetime_maps_to_date(self) -> None: - fields = DateSchema.to_field_definitions() - updated_field = next(f for f in fields if f["name"] == "updated") - assert updated_field["type"] == "date" - - def test_optional_field_not_required(self) -> None: - fields = OptionalSchema.to_field_definitions() - desc_field = next(f for f in fields if f["name"] == "description") - assert desc_field["required"] is False - - def test_required_field_is_required(self) -> None: - fields = OptionalSchema.to_field_definitions() - name_field = next(f for f in fields if f["name"] == "name") - assert name_field["required"] is True - - def test_unit_in_constraints(self) -> None: - fields = UnitSchema.to_field_definitions() - res_field = next(f for f in fields if f["name"] == "resolution") - assert res_field["constraints"]["unit"] == "Å" - - def test_unit_on_required_field(self) -> None: - fields = UnitSchema.to_field_definitions() - weight_field = next(f for f in fields if f["name"] == "weight") - assert weight_field["constraints"]["unit"] == "kDa" - assert weight_field["required"] is True - - def test_cardinality_defaults_to_exactly_one(self) -> None: - fields = SimpleSchema.to_field_definitions() - for f in fields: - assert f["cardinality"] == "exactly_one" - - def test_returns_list_of_dicts(self) -> None: - fields = SimpleSchema.to_field_definitions() - assert isinstance(fields, list) - assert all(isinstance(f, dict) for f in fields) - assert len(fields) == 4 diff --git a/sdk/py/uv.lock b/sdk/py/uv.lock deleted file mode 100644 index eca956c..0000000 --- a/sdk/py/uv.lock +++ /dev/null @@ -1,656 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.13" - -[[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" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "anyio" -version = "4.12.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, -] - -[[package]] -name = "biotite" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "biotraj" }, - { name = "msgpack" }, - { name = "networkx" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/59/7b/99153f7bceef01034b5f19a6b123219533132d446ffcf141dfef3e386d33/biotite-1.6.0.tar.gz", hash = "sha256:4c172f6e57521220fa0fc4899142211f6f21ba83d8f6f135d4edc68981f70e7e", size = 38514388, upload-time = "2026-01-23T12:47:38.331Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/c0/2f911876c91468e9794003724cc641f0bfe7563229f537ee6a2040b6bc6f/biotite-1.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:69d16be1721bb65769e1daa9316580e8e7c832d487cb9e5f1ce1f3100492f966", size = 41648417, upload-time = "2026-01-23T12:47:07.791Z" }, - { url = "https://files.pythonhosted.org/packages/d2/5b/ca8476009f5dab4efbd5e889e60b14eac4650384bf39bada08e551d97da9/biotite-1.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cbac31dfc484a953a0b50ad692ab558e5537a39894b126791506da2ba8e759f", size = 41531163, upload-time = "2026-01-23T12:47:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/db/c6/b72f7ce4438401d9d7c6b2c46ef26e7cf1187f0ff9faf15b8444cacdc0da/biotite-1.6.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4026e034e90696780598f46fccd9e020a562983620f77c4e6f51b0805f929f38", size = 56584437, upload-time = "2026-01-23T12:47:14.53Z" }, - { url = "https://files.pythonhosted.org/packages/25/44/c8cfb570d8a131e136e3f06b9c47d4dea0b6f1ce0b34d1ce1092331e7505/biotite-1.6.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5087a40853a451abf4aedae1293bb413ef1aa4dd8287fc2b363b4a5c70c3ae55", size = 57184611, upload-time = "2026-01-23T12:47:17.642Z" }, - { url = "https://files.pythonhosted.org/packages/1c/5a/4594df3af07446506305806f253f1432374f9f318d0225725631dacfde83/biotite-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:fe64a929d7ddede42c4c757a81ef505d5f389e788bc8b9c61c8fe605899a20a4", size = 41201315, upload-time = "2026-01-23T12:47:20.481Z" }, - { url = "https://files.pythonhosted.org/packages/e8/a3/3b54a4fa882ff29408626c20f02c826c8b9cfcb4faa5d5aad47d1063ed7f/biotite-1.6.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:588798183172dde36de91f32d6312aae7dbb8e655e3a395c08ee1023919e43cf", size = 41646883, upload-time = "2026-01-23T12:47:23.451Z" }, - { url = "https://files.pythonhosted.org/packages/05/11/8dbd811ac907015f7e12383c0b1bcd8872e0fe9085a822cc94e6ddd882de/biotite-1.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:16ec5b541caa68cf79c17a64b26526b1884b96884a016e443da1b21d9bd849ab", size = 41531108, upload-time = "2026-01-23T12:47:26.135Z" }, - { url = "https://files.pythonhosted.org/packages/dd/65/b5358c885a9c04c3988678cb352e53edfb5cf234f8c653159d0830a58a42/biotite-1.6.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24cd941932987569be48b85219d7a97bacad865532a3e5b31998cc5f192f9fd7", size = 56511519, upload-time = "2026-01-23T12:47:29.721Z" }, - { url = "https://files.pythonhosted.org/packages/62/b7/fb3b2fa155a3a5cf00f8e970e82a7554070c59a127b326375aa36d5452db/biotite-1.6.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef558c9bb1715753acf5819593fd9f3f80bc2feaa02a7d9f0d7d8d15a284d182", size = 56926154, upload-time = "2026-01-23T12:47:32.824Z" }, - { url = "https://files.pythonhosted.org/packages/ae/0e/f2a1f98ec994e5ee4568bad6b56e6828a5e74bd95c8a6b996156bd18d0a5/biotite-1.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:7a972c434484cab4cff2c1a6263b146c57341fdc0db12a4c387ba4f12e7b29a7", size = 41186067, upload-time = "2026-01-23T12:47:35.779Z" }, -] - -[[package]] -name = "biotraj" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "scipy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/21/2287edfd0d2569639eea706e25c39e63b46a384cf1712db8ea05768317b0/biotraj-1.2.2.tar.gz", hash = "sha256:4bcba92101ed50f369cc1487fb5dfcfe1d8402ad47adaa9232b080553271663a", size = 3909030, upload-time = "2024-11-02T11:30:54.974Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/12/8f6a2b21a8b43a2bf85352c993afecb21d40eb3d7373b6242163a93f57e0/biotraj-1.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:31ed2a9e8a6436f5432f22883e68a36c223b98de0ab80225efbaf67da339a2b2", size = 856347, upload-time = "2024-11-02T11:30:47.431Z" }, - { url = "https://files.pythonhosted.org/packages/f6/4d/b1e792c01e61bbfe03b290a0805f0c7bc3949bf9cd4031bcdd183d2f2524/biotraj-1.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6a8c84ac95bf65b73928774ec46b72d62b0f30fa12eb8c56c78ed25235f0acf8", size = 832499, upload-time = "2024-11-02T11:30:49.363Z" }, - { url = "https://files.pythonhosted.org/packages/f0/6f/ab71525583a7824c70f71de387a1c5ceb27ddcb3fda2dacb734e5b875f14/biotraj-1.2.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dcfa4a4c755ddc206f81999fd47664747cd2e546e16a51d885332cd4c955f41", size = 2246545, upload-time = "2024-11-02T11:30:50.829Z" }, - { url = "https://files.pythonhosted.org/packages/e0/9a/46d2d67b5b672d5d2ffbfb3551fc4b499b45d5edce2558e259f69b72a0d9/biotraj-1.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:e0ed1b586e23ad53e53fb42be8541fb964ec6c4183e6d500fef0bcc1bdcd7487", size = 359596, upload-time = "2024-11-02T11:30:52.876Z" }, -] - -[[package]] -name = "certifi" -version = "2026.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, -] - -[[package]] -name = "click" -version = "8.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, -] - -[[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 = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "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 = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, -] - -[[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 = "markdown-it-py" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, -] - -[[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 = "msgpack" -version = "1.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, - { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, - { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, - { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, - { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, - { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, - { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, - { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, - { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, - { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, - { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, - { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, - { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, - { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, - { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, - { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, - { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, - { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, -] - -[[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.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, - { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, - { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, - { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, - { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, - { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, - { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, - { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, - { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, - { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, - { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, - { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, - { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, - { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, - { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, - { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, - { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, - { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, - { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, - { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, - { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, - { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, - { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, - { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, - { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, - { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, - { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, - { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, - { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, - { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, - { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, - { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, - { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, - { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, - { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, -] - -[[package]] -name = "osa" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "httpx" }, - { name = "pydantic" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pocketeer" }, - { name = "pytest" }, - { name = "ruff" }, - { name = "ty" }, -] - -[package.metadata] -requires-dist = [ - { name = "httpx", specifier = ">=0.27" }, - { name = "pydantic", specifier = ">=2" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pocketeer", specifier = ">=0.3.0" }, - { name = "pytest", specifier = ">=9.0.2" }, - { name = "ruff", specifier = ">=0.15.1" }, - { name = "ty", specifier = ">=0.0.17" }, -] - -[[package]] -name = "packaging" -version = "26.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, -] - -[[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 = "pocketeer" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "biotite" }, - { name = "numpy" }, - { name = "rich" }, - { name = "scipy" }, - { name = "typer" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dc/fc/1c2d7390c1b3ff55cc5f7e2fe2e34993a1aa1ca0c80b6e429cda9433c82e/pocketeer-0.3.0.tar.gz", hash = "sha256:ca746fb910126e1db8261241e0210b258119bc90217479437db5d738a8b5e10a", size = 4438785, upload-time = "2026-01-12T20:12:07.977Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/5a/e777f6af7d786d1132255f48e450d0c34b5339d504758f6e0b2145edc136/pocketeer-0.3.0-py3-none-any.whl", hash = "sha256:a9559d0dadaf1d52bde57957c3841e426949262dfc7ea00b8a8166a272018e7d", size = 25307, upload-time = "2026-01-12T20:12:06.831Z" }, -] - -[[package]] -name = "pydantic" -version = "2.12.5" -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/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.41.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, -] - -[[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, -] - -[[package]] -name = "pytest" -version = "9.0.2" -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/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, -] - -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, -] - -[[package]] -name = "rich" -version = "14.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, -] - -[[package]] -name = "ruff" -version = "0.15.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" }, - { url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" }, - { url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" }, - { url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" }, - { url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" }, - { url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" }, - { url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" }, - { url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" }, - { url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" }, - { url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" }, - { url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" }, - { url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" }, - { url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" }, - { url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" }, - { url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" }, -] - -[[package]] -name = "scipy" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/51/3468fdfd49387ddefee1636f5cf6d03ce603b75205bf439bbf0e62069bfd/scipy-1.17.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:65ec32f3d32dfc48c72df4291345dae4f048749bc8d5203ee0a3f347f96c5ce6", size = 31344101, upload-time = "2026-01-10T21:26:30.25Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/9406aec58268d437636069419e6977af953d1e246df941d42d3720b7277b/scipy-1.17.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1f9586a58039d7229ce77b52f8472c972448cded5736eaf102d5658bbac4c269", size = 27950385, upload-time = "2026-01-10T21:26:36.801Z" }, - { url = "https://files.pythonhosted.org/packages/4f/98/e7342709e17afdfd1b26b56ae499ef4939b45a23a00e471dfb5375eea205/scipy-1.17.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9fad7d3578c877d606b1150135c2639e9de9cecd3705caa37b66862977cc3e72", size = 20122115, upload-time = "2026-01-10T21:26:42.107Z" }, - { url = "https://files.pythonhosted.org/packages/fd/0e/9eeeb5357a64fd157cbe0302c213517c541cc16b8486d82de251f3c68ede/scipy-1.17.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:423ca1f6584fc03936972b5f7c06961670dbba9f234e71676a7c7ccf938a0d61", size = 22442402, upload-time = "2026-01-10T21:26:48.029Z" }, - { url = "https://files.pythonhosted.org/packages/c9/10/be13397a0e434f98e0c79552b2b584ae5bb1c8b2be95db421533bbca5369/scipy-1.17.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe508b5690e9eaaa9467fc047f833af58f1152ae51a0d0aed67aa5801f4dd7d6", size = 32696338, upload-time = "2026-01-10T21:26:55.521Z" }, - { url = "https://files.pythonhosted.org/packages/63/1e/12fbf2a3bb240161651c94bb5cdd0eae5d4e8cc6eaeceb74ab07b12a753d/scipy-1.17.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6680f2dfd4f6182e7d6db161344537da644d1cf85cf293f015c60a17ecf08752", size = 34977201, upload-time = "2026-01-10T21:27:03.501Z" }, - { url = "https://files.pythonhosted.org/packages/19/5b/1a63923e23ccd20bd32156d7dd708af5bbde410daa993aa2500c847ab2d2/scipy-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eec3842ec9ac9de5917899b277428886042a93db0b227ebbe3a333b64ec7643d", size = 34777384, upload-time = "2026-01-10T21:27:11.423Z" }, - { url = "https://files.pythonhosted.org/packages/39/22/b5da95d74edcf81e540e467202a988c50fef41bd2011f46e05f72ba07df6/scipy-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d7425fcafbc09a03731e1bc05581f5fad988e48c6a861f441b7ab729a49a55ea", size = 37379586, upload-time = "2026-01-10T21:27:20.171Z" }, - { url = "https://files.pythonhosted.org/packages/b9/b6/8ac583d6da79e7b9e520579f03007cb006f063642afd6b2eeb16b890bf93/scipy-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:87b411e42b425b84777718cc41516b8a7e0795abfa8e8e1d573bf0ef014f0812", size = 36287211, upload-time = "2026-01-10T21:28:43.122Z" }, - { url = "https://files.pythonhosted.org/packages/55/fb/7db19e0b3e52f882b420417644ec81dd57eeef1bd1705b6f689d8ff93541/scipy-1.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:357ca001c6e37601066092e7c89cca2f1ce74e2a520ca78d063a6d2201101df2", size = 24312646, upload-time = "2026-01-10T21:28:49.893Z" }, - { url = "https://files.pythonhosted.org/packages/20/b6/7feaa252c21cc7aff335c6c55e1b90ab3e3306da3f048109b8b639b94648/scipy-1.17.0-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:ec0827aa4d36cb79ff1b81de898e948a51ac0b9b1c43e4a372c0508c38c0f9a3", size = 31693194, upload-time = "2026-01-10T21:27:27.454Z" }, - { url = "https://files.pythonhosted.org/packages/76/bb/bbb392005abce039fb7e672cb78ac7d158700e826b0515cab6b5b60c26fb/scipy-1.17.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:819fc26862b4b3c73a60d486dbb919202f3d6d98c87cf20c223511429f2d1a97", size = 28365415, upload-time = "2026-01-10T21:27:34.26Z" }, - { url = "https://files.pythonhosted.org/packages/37/da/9d33196ecc99fba16a409c691ed464a3a283ac454a34a13a3a57c0d66f3a/scipy-1.17.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:363ad4ae2853d88ebcde3ae6ec46ccca903ea9835ee8ba543f12f575e7b07e4e", size = 20537232, upload-time = "2026-01-10T21:27:40.306Z" }, - { url = "https://files.pythonhosted.org/packages/56/9d/f4b184f6ddb28e9a5caea36a6f98e8ecd2a524f9127354087ce780885d83/scipy-1.17.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:979c3a0ff8e5ba254d45d59ebd38cde48fce4f10b5125c680c7a4bfe177aab07", size = 22791051, upload-time = "2026-01-10T21:27:46.539Z" }, - { url = "https://files.pythonhosted.org/packages/9b/9d/025cccdd738a72140efc582b1641d0dd4caf2e86c3fb127568dc80444e6e/scipy-1.17.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:130d12926ae34399d157de777472bf82e9061c60cc081372b3118edacafe1d00", size = 32815098, upload-time = "2026-01-10T21:27:54.389Z" }, - { url = "https://files.pythonhosted.org/packages/48/5f/09b879619f8bca15ce392bfc1894bd9c54377e01d1b3f2f3b595a1b4d945/scipy-1.17.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e886000eb4919eae3a44f035e63f0fd8b651234117e8f6f29bad1cd26e7bc45", size = 35031342, upload-time = "2026-01-10T21:28:03.012Z" }, - { url = "https://files.pythonhosted.org/packages/f2/9a/f0f0a9f0aa079d2f106555b984ff0fbb11a837df280f04f71f056ea9c6e4/scipy-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13c4096ac6bc31d706018f06a49abe0485f96499deb82066b94d19b02f664209", size = 34893199, upload-time = "2026-01-10T21:28:10.832Z" }, - { url = "https://files.pythonhosted.org/packages/90/b8/4f0f5cf0c5ea4d7548424e6533e6b17d164f34a6e2fb2e43ffebb6697b06/scipy-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cacbaddd91fcffde703934897c5cd2c7cb0371fac195d383f4e1f1c5d3f3bd04", size = 37438061, upload-time = "2026-01-10T21:28:19.684Z" }, - { url = "https://files.pythonhosted.org/packages/f9/cc/2bd59140ed3b2fa2882fb15da0a9cb1b5a6443d67cfd0d98d4cec83a57ec/scipy-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:edce1a1cf66298cccdc48a1bdf8fb10a3bf58e8b58d6c3883dd1530e103f87c0", size = 36328593, upload-time = "2026-01-10T21:28:28.007Z" }, - { url = "https://files.pythonhosted.org/packages/13/1b/c87cc44a0d2c7aaf0f003aef2904c3d097b422a96c7e7c07f5efd9073c1b/scipy-1.17.0-cp313-cp313t-win_arm64.whl", hash = "sha256:30509da9dbec1c2ed8f168b8d8aa853bc6723fede1dbc23c7d43a56f5ab72a67", size = 24625083, upload-time = "2026-01-10T21:28:35.188Z" }, - { url = "https://files.pythonhosted.org/packages/1a/2d/51006cd369b8e7879e1c630999a19d1fbf6f8b5ed3e33374f29dc87e53b3/scipy-1.17.0-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:c17514d11b78be8f7e6331b983a65a7f5ca1fd037b95e27b280921fe5606286a", size = 31346803, upload-time = "2026-01-10T21:28:57.24Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2e/2349458c3ce445f53a6c93d4386b1c4c5c0c540917304c01222ff95ff317/scipy-1.17.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:4e00562e519c09da34c31685f6acc3aa384d4d50604db0f245c14e1b4488bfa2", size = 27967182, upload-time = "2026-01-10T21:29:04.107Z" }, - { url = "https://files.pythonhosted.org/packages/5e/7c/df525fbfa77b878d1cfe625249529514dc02f4fd5f45f0f6295676a76528/scipy-1.17.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7df7941d71314e60a481e02d5ebcb3f0185b8d799c70d03d8258f6c80f3d467", size = 20139125, upload-time = "2026-01-10T21:29:10.179Z" }, - { url = "https://files.pythonhosted.org/packages/33/11/fcf9d43a7ed1234d31765ec643b0515a85a30b58eddccc5d5a4d12b5f194/scipy-1.17.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:aabf057c632798832f071a8dde013c2e26284043934f53b00489f1773b33527e", size = 22443554, upload-time = "2026-01-10T21:29:15.888Z" }, - { url = "https://files.pythonhosted.org/packages/80/5c/ea5d239cda2dd3d31399424967a24d556cf409fbea7b5b21412b0fd0a44f/scipy-1.17.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a38c3337e00be6fd8a95b4ed66b5d988bac4ec888fd922c2ea9fe5fb1603dd67", size = 32757834, upload-time = "2026-01-10T21:29:23.406Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7e/8c917cc573310e5dc91cbeead76f1b600d3fb17cf0969db02c9cf92e3cfa/scipy-1.17.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00fb5f8ec8398ad90215008d8b6009c9db9fa924fd4c7d6be307c6f945f9cd73", size = 34995775, upload-time = "2026-01-10T21:29:31.915Z" }, - { url = "https://files.pythonhosted.org/packages/c5/43/176c0c3c07b3f7df324e7cdd933d3e2c4898ca202b090bd5ba122f9fe270/scipy-1.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f2a4942b0f5f7c23c7cd641a0ca1955e2ae83dedcff537e3a0259096635e186b", size = 34841240, upload-time = "2026-01-10T21:29:39.995Z" }, - { url = "https://files.pythonhosted.org/packages/44/8c/d1f5f4b491160592e7f084d997de53a8e896a3ac01cd07e59f43ca222744/scipy-1.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf133ced83889583156566d2bdf7a07ff89228fe0c0cb727f777de92092ec6b", size = 37394463, upload-time = "2026-01-10T21:29:48.723Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ec/42a6657f8d2d087e750e9a5dde0b481fd135657f09eaf1cf5688bb23c338/scipy-1.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3625c631a7acd7cfd929e4e31d2582cf00f42fcf06011f59281271746d77e061", size = 37053015, upload-time = "2026-01-10T21:30:51.418Z" }, - { url = "https://files.pythonhosted.org/packages/27/58/6b89a6afd132787d89a362d443a7bddd511b8f41336a1ae47f9e4f000dc4/scipy-1.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:9244608d27eafe02b20558523ba57f15c689357c85bdcfe920b1828750aa26eb", size = 24951312, upload-time = "2026-01-10T21:30:56.771Z" }, - { url = "https://files.pythonhosted.org/packages/e9/01/f58916b9d9ae0112b86d7c3b10b9e685625ce6e8248df139d0fcb17f7397/scipy-1.17.0-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:2b531f57e09c946f56ad0b4a3b2abee778789097871fc541e267d2eca081cff1", size = 31706502, upload-time = "2026-01-10T21:29:56.326Z" }, - { url = "https://files.pythonhosted.org/packages/59/8e/2912a87f94a7d1f8b38aabc0faf74b82d3b6c9e22be991c49979f0eceed8/scipy-1.17.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:13e861634a2c480bd237deb69333ac79ea1941b94568d4b0efa5db5e263d4fd1", size = 28380854, upload-time = "2026-01-10T21:30:01.554Z" }, - { url = "https://files.pythonhosted.org/packages/bd/1c/874137a52dddab7d5d595c1887089a2125d27d0601fce8c0026a24a92a0b/scipy-1.17.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:eb2651271135154aa24f6481cbae5cc8af1f0dd46e6533fb7b56aa9727b6a232", size = 20552752, upload-time = "2026-01-10T21:30:05.93Z" }, - { url = "https://files.pythonhosted.org/packages/3f/f0/7518d171cb735f6400f4576cf70f756d5b419a07fe1867da34e2c2c9c11b/scipy-1.17.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:c5e8647f60679790c2f5c76be17e2e9247dc6b98ad0d3b065861e082c56e078d", size = 22803972, upload-time = "2026-01-10T21:30:10.651Z" }, - { url = "https://files.pythonhosted.org/packages/7c/74/3498563a2c619e8a3ebb4d75457486c249b19b5b04a30600dfd9af06bea5/scipy-1.17.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fb10d17e649e1446410895639f3385fd2bf4c3c7dfc9bea937bddcbc3d7b9ba", size = 32829770, upload-time = "2026-01-10T21:30:16.359Z" }, - { url = "https://files.pythonhosted.org/packages/48/d1/7b50cedd8c6c9d6f706b4b36fa8544d829c712a75e370f763b318e9638c1/scipy-1.17.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8547e7c57f932e7354a2319fab613981cde910631979f74c9b542bb167a8b9db", size = 35051093, upload-time = "2026-01-10T21:30:22.987Z" }, - { url = "https://files.pythonhosted.org/packages/e2/82/a2d684dfddb87ba1b3ea325df7c3293496ee9accb3a19abe9429bce94755/scipy-1.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33af70d040e8af9d5e7a38b5ed3b772adddd281e3062ff23fec49e49681c38cf", size = 34909905, upload-time = "2026-01-10T21:30:28.704Z" }, - { url = "https://files.pythonhosted.org/packages/ef/5e/e565bd73991d42023eb82bb99e51c5b3d9e2c588ca9d4b3e2cc1d3ca62a6/scipy-1.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb55bb97d00f8b7ab95cb64f873eb0bf54d9446264d9f3609130381233483f", size = 37457743, upload-time = "2026-01-10T21:30:34.819Z" }, - { url = "https://files.pythonhosted.org/packages/58/a8/a66a75c3d8f1fb2b83f66007d6455a06a6f6cf5618c3dc35bc9b69dd096e/scipy-1.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1ff269abf702f6c7e67a4b7aad981d42871a11b9dd83c58d2d2ea624efbd1088", size = 37098574, upload-time = "2026-01-10T21:30:40.782Z" }, - { url = "https://files.pythonhosted.org/packages/56/a5/df8f46ef7da168f1bc52cd86e09a9de5c6f19cc1da04454d51b7d4f43408/scipy-1.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:031121914e295d9791319a1875444d55079885bbae5bdc9c5e0f2ee5f09d34ff", size = 25246266, upload-time = "2026-01-10T21:30:45.923Z" }, -] - -[[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 = "ty" -version = "0.0.17" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/c3/41ae6346443eedb65b96761abfab890a48ce2aa5a8a27af69c5c5d99064d/ty-0.0.17.tar.gz", hash = "sha256:847ed6c120913e280bf9b54d8eaa7a1049708acb8824ad234e71498e8ad09f97", size = 5167209, upload-time = "2026-02-13T13:26:36.835Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/01/0ef15c22a1c54b0f728ceff3f62d478dbf8b0dcf8ff7b80b954f79584f3e/ty-0.0.17-py3-none-linux_armv6l.whl", hash = "sha256:64a9a16555cc8867d35c2647c2f1afbd3cae55f68fd95283a574d1bb04fe93e0", size = 10192793, upload-time = "2026-02-13T13:27:13.943Z" }, - { url = "https://files.pythonhosted.org/packages/0f/2c/f4c322d9cded56edc016b1092c14b95cf58c8a33b4787316ea752bb9418e/ty-0.0.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eb2dbd8acd5c5a55f4af0d479523e7c7265a88542efe73ed3d696eb1ba7b6454", size = 10051977, upload-time = "2026-02-13T13:26:57.741Z" }, - { url = "https://files.pythonhosted.org/packages/4c/a5/43746c1ff81e784f5fc303afc61fe5bcd85d0fcf3ef65cb2cef78c7486c7/ty-0.0.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f18f5fd927bc628deb9ea2df40f06b5f79c5ccf355db732025a3e8e7152801f6", size = 9564639, upload-time = "2026-02-13T13:26:42.781Z" }, - { url = "https://files.pythonhosted.org/packages/d6/b8/280b04e14a9c0474af574f929fba2398b5e1c123c1e7735893b4cd73d13c/ty-0.0.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5383814d1d7a5cc53b3b07661856bab04bb2aac7a677c8d33c55169acdaa83df", size = 10061204, upload-time = "2026-02-13T13:27:00.152Z" }, - { url = "https://files.pythonhosted.org/packages/2a/d7/493e1607d8dfe48288d8a768a2adc38ee27ef50e57f0af41ff273987cda0/ty-0.0.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c20423b8744b484f93e7bf2ef8a9724bca2657873593f9f41d08bd9f83444c9", size = 10013116, upload-time = "2026-02-13T13:26:34.543Z" }, - { url = "https://files.pythonhosted.org/packages/80/ef/22f3ed401520afac90dbdf1f9b8b7755d85b0d5c35c1cb35cf5bd11b59c2/ty-0.0.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6f5b1aba97db9af86517b911674b02f5bc310750485dc47603a105bd0e83ddd", size = 10533623, upload-time = "2026-02-13T13:26:31.449Z" }, - { url = "https://files.pythonhosted.org/packages/75/ce/744b15279a11ac7138832e3a55595706b4a8a209c9f878e3ab8e571d9032/ty-0.0.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:488bce1a9bea80b851a97cd34c4d2ffcd69593d6c3f54a72ae02e5c6e47f3d0c", size = 11069750, upload-time = "2026-02-13T13:26:48.638Z" }, - { url = "https://files.pythonhosted.org/packages/f2/be/1133c91f15a0e00d466c24f80df486d630d95d1b2af63296941f7473812f/ty-0.0.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8df66b91ec84239420985ec215e7f7549bfda2ac036a3b3c065f119d1c06825a", size = 10870862, upload-time = "2026-02-13T13:26:54.715Z" }, - { url = "https://files.pythonhosted.org/packages/3e/4a/a2ed209ef215b62b2d3246e07e833081e07d913adf7e0448fc204be443d6/ty-0.0.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:002139e807c53002790dfefe6e2f45ab0e04012e76db3d7c8286f96ec121af8f", size = 10628118, upload-time = "2026-02-13T13:26:45.439Z" }, - { url = "https://files.pythonhosted.org/packages/b3/0c/87476004cb5228e9719b98afffad82c3ef1f84334bde8527bcacba7b18cb/ty-0.0.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6c4e01f05ce82e5d489ab3900ca0899a56c4ccb52659453780c83e5b19e2b64c", size = 10038185, upload-time = "2026-02-13T13:27:02.693Z" }, - { url = "https://files.pythonhosted.org/packages/46/4b/98f0b3ba9aef53c1f0305519536967a4aa793a69ed72677b0a625c5313ac/ty-0.0.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2b226dd1e99c0d2152d218c7e440150d1a47ce3c431871f0efa073bbf899e881", size = 10047644, upload-time = "2026-02-13T13:27:05.474Z" }, - { url = "https://files.pythonhosted.org/packages/93/e0/06737bb80aa1a9103b8651d2eb691a7e53f1ed54111152be25f4a02745db/ty-0.0.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8b11f1da7859e0ad69e84b3c5ef9a7b055ceed376a432fad44231bdfc48061c2", size = 10231140, upload-time = "2026-02-13T13:27:10.844Z" }, - { url = "https://files.pythonhosted.org/packages/7c/79/e2a606bd8852383ba9abfdd578f4a227bd18504145381a10a5f886b4e751/ty-0.0.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c04e196809ff570559054d3e011425fd7c04161529eb551b3625654e5f2434cb", size = 10718344, upload-time = "2026-02-13T13:26:51.66Z" }, - { url = "https://files.pythonhosted.org/packages/c5/2d/2663984ac11de6d78f74432b8b14ba64d170b45194312852b7543cf7fd56/ty-0.0.17-py3-none-win32.whl", hash = "sha256:305b6ed150b2740d00a817b193373d21f0767e10f94ac47abfc3b2e5a5aec809", size = 9672932, upload-time = "2026-02-13T13:27:08.522Z" }, - { url = "https://files.pythonhosted.org/packages/de/b5/39be78f30b31ee9f5a585969930c7248354db90494ff5e3d0756560fb731/ty-0.0.17-py3-none-win_amd64.whl", hash = "sha256:531828267527aee7a63e972f54e5eee21d9281b72baf18e5c2850c6b862add83", size = 10542138, upload-time = "2026-02-13T13:27:17.084Z" }, - { url = "https://files.pythonhosted.org/packages/40/b7/f875c729c5d0079640c75bad2c7e5d43edc90f16ba242f28a11966df8f65/ty-0.0.17-py3-none-win_arm64.whl", hash = "sha256:de9810234c0c8d75073457e10a84825b9cd72e6629826b7f01c7a0b266ae25b1", size = 10023068, upload-time = "2026-02-13T13:26:39.637Z" }, -] - -[[package]] -name = "typer" -version = "0.23.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047, upload-time = "2026-02-13T10:04:30.984Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813, upload-time = "2026-02-13T10:04:32.008Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[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 = "urllib3" -version = "2.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } -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" }, -] diff --git a/server/migrations/env.py b/server/migrations/env.py index 0a92b45..0defa06 100644 --- a/server/migrations/env.py +++ b/server/migrations/env.py @@ -31,7 +31,7 @@ config.set_main_option("sqlalchemy.url", sync_url) else: # Derive SQLite URL from OSA_DATA_DIR (same logic as Config class) - from osa.cli.util.paths import OSAPaths + from osa.util.paths import OSAPaths paths = OSAPaths() paths.ensure_directories() # Create directories if they don't exist diff --git a/server/osa/__init__.py b/server/osa/__init__.py index ffa73f4..2ec12b5 100644 --- a/server/osa/__init__.py +++ b/server/osa/__init__.py @@ -1,8 +1 @@ """Open Scientific Archive.""" - -from osa.cli import app - - -def main() -> None: - """CLI entry point.""" - app() diff --git a/server/osa/application/api/rest/app.py b/server/osa/application/api/rest/app.py index 9924dd7..2dc477c 100644 --- a/server/osa/application/api/rest/app.py +++ b/server/osa/application/api/rest/app.py @@ -57,15 +57,15 @@ def create_app() -> FastAPI: # Configure logging early configure_logging(config.logging) - logger.info("Starting OSA server: %s v%s", config.server.name, config.server.version) + logger.info("Starting OSA server: %s v%s", config.name, config.version) # Validate all handlers have authorization declarations (fail fast) validate_all_handlers() app_instance = FastAPI( - title=config.server.name, - description=config.server.description, - version=config.server.version, + title=config.name, + description=config.description, + version=config.version, lifespan=lifespan, ) diff --git a/server/osa/application/di.py b/server/osa/application/di.py index a6d8759..6384767 100644 --- a/server/osa/application/di.py +++ b/server/osa/application/di.py @@ -1,6 +1,6 @@ from dishka import AsyncContainer, make_async_container -from osa.cli.util.paths import OSAPaths +from osa.util.paths import OSAPaths from osa.config import Config from osa.domain.auth.util.di import AuthProvider from osa.domain.discovery.util.di import DiscoveryProvider diff --git a/server/osa/cli/__init__.py b/server/osa/cli/__init__.py deleted file mode 100644 index d37aac1..0000000 --- a/server/osa/cli/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""CLI module for OSA.""" - -from osa.cli.main import app - -__all__ = ["app"] diff --git a/server/osa/cli/commands/__init__.py b/server/osa/cli/commands/__init__.py deleted file mode 100644 index 9b36c24..0000000 --- a/server/osa/cli/commands/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""CLI commands.""" diff --git a/server/osa/cli/commands/admin.py b/server/osa/cli/commands/admin.py deleted file mode 100644 index 332f48d..0000000 --- a/server/osa/cli/commands/admin.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Administrative commands for OSA management.""" - -from pathlib import Path - -import cyclopts - -from osa.cli.console import get_console -from osa.cli.util import OSAPaths, read_server_state - -app = cyclopts.App(name="admin", help="Administrative commands") - - -@app.command -def info() -> None: - """Show information about OSA directories and status.""" - console = get_console() - paths = OSAPaths() - - def dir_size(path: Path) -> str: - """Get human-readable size of a file or directory.""" - if not path.exists(): - return "—" - if path.is_file(): - size = path.stat().st_size - else: - size = sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) - - for unit in ["B", "KB", "MB", "GB"]: - if size < 1024: - return f"{size:.1f} {unit}" - size /= 1024 - return f"{size:.1f} TB" - - def exists_marker(path: Path) -> str: - return "[green]✓[/green]" if path.exists() else "[dim]✗[/dim]" - - console.print("[bold]OSA Directories[/bold]\n") - - # Config - console.print(f"{exists_marker(paths.config_dir)} [cyan]Config:[/cyan] {paths.config_dir}") - if paths.config_file.exists(): - console.print(f" config.yaml [dim]({dir_size(paths.config_file)})[/dim]") - - # Data - console.print(f"{exists_marker(paths.data_dir)} [cyan]Data:[/cyan] {paths.data_dir}") - if paths.data_dir.exists(): - console.print(f" osa.db [dim]({dir_size(paths.database_file)})[/dim]") - console.print(f" vectors/ [dim]({dir_size(paths.vectors_dir)})[/dim]") - - # State - console.print(f"{exists_marker(paths.state_dir)} [cyan]State:[/cyan] {paths.state_dir}") - if paths.state_dir.exists(): - console.print(f" logs/ [dim]({dir_size(paths.logs_dir)})[/dim]") - - # Cache - console.print(f"{exists_marker(paths.cache_dir)} [cyan]Cache:[/cyan] {paths.cache_dir}") - if paths.cache_dir.exists(): - console.print(f" [dim]({dir_size(paths.cache_dir)})[/dim]") - - # Server status - console.print() - state = read_server_state(paths.server_state_file) - if state: - from osa.cli.console import relative_time - - console.print("[bold]Server[/bold]") - console.print(f" PID: {state.pid}") - console.print(f" Address: http://{state.host}:{state.port}") - console.print(f" Started: {relative_time(state.started_at)}") - else: - console.print("[dim]Server not running[/dim]") diff --git a/server/osa/cli/commands/events.py b/server/osa/cli/commands/events.py deleted file mode 100644 index 2bcd724..0000000 --- a/server/osa/cli/commands/events.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Events command - view the event log.""" - -import sys - -import cyclopts -import httpx - -from osa.cli.commands.search import get_server_url, with_retry -from osa.cli.console import get_console, relative_time - -app = cyclopts.App(name="events", help="View the event log") - - -@app.default -def events( - limit: int = 20, - types: list[str] | None = None, -) -> None: - """Show recent events from the event log (newest first). - - Args: - limit: Number of events to show. - types: Filter by event types (e.g., RecordPublished). - """ - console = get_console() - server_url = get_server_url() - url = f"{server_url}/api/v1/events" - - params: dict[str, str | int | list[str]] = {"limit": limit, "order": "desc"} - if types: - params["types"] = types - - try: - response = with_retry( - lambda: httpx.get(url, params=params), - exceptions=(httpx.ReadError, httpx.ConnectError), - ) - response.raise_for_status() - data = response.json() - - events_list = data.get("events", []) - has_more = data.get("has_more", False) - - if not events_list: - console.info("No events found") - return - - more_indicator = " [dim](more available)[/dim]" if has_more else "" - console.print(f"[bold]Events[/bold] [dim]({len(events_list)})[/dim]{more_indicator}\n") - - for event in events_list: - event_type = event.get("type", "Unknown") - created_at = event.get("created_at", "") - - # Format time - time_str = relative_time(created_at) if created_at else "" - - console.print(f"[cyan]{event_type}[/cyan] [dim]{time_str}[/dim]") - - except httpx.ConnectError: - console.error( - f"Could not connect to server at {server_url}", - hint="Is the server running? Start it with: osa server start", - ) - sys.exit(1) - except httpx.HTTPStatusError as e: - console.error(f"Server error: {e.response.status_code} - {e.response.text}") - sys.exit(1) - except httpx.ReadError: - console.error( - "Connection lost while reading response", - hint="The server may have crashed. Check: osa server logs", - ) - sys.exit(1) diff --git a/server/osa/cli/commands/local.py b/server/osa/cli/commands/local.py deleted file mode 100644 index 4b14f37..0000000 --- a/server/osa/cli/commands/local.py +++ /dev/null @@ -1,436 +0,0 @@ -"""Local development server commands (start/stop/logs/status/clean).""" - -import shutil -import sys -import time -from pathlib import Path -from typing import TYPE_CHECKING, Literal - -import cyclopts - -from osa.cli.console import get_console, relative_time - -if TYPE_CHECKING: - from osa.cli.console import Console -from osa.cli.util import ConfigError, DaemonManager, OSAPaths, ServerStatus - -app = cyclopts.App(name="local", help="Local development server") - -# Local config override (for development) -LOCAL_CONFIG = Path("osa.yaml") - -# Available templates -Template = Literal["geo", "minimal"] - -GEO_TEMPLATE = """\ -# OSA Configuration - GEO Template - -server: - name: "My OSA Node" - domain: "localhost" - -# Database (SQLite by default, stored in $OSA_DATA_DIR or ~/.local/share/osa/) -# database: -# auto_migrate: true - -# Logging -# logging: -# level: "INFO" - -# GEO Source - pulls from NCBI Gene Expression Omnibus via Entrez API -sources: - - source: geo-entrez - config: - record_type: gse # gse (~250k all) or gds (~5k curated) - email: your@email.com # Required by NCBI - please update this - # api_key: null # Optional: NCBI API key for higher rate limits (https://account.ncbi.nlm.nih.gov/settings/) - initial_run: - enabled: true - limit: 50 - # schedule: - # cron: "0 * * * *" # Hourly - # limit: 100 - -# Vector search index -indexes: - - name: vector - backend: vector - config: - persist_dir: {vectors_dir} - embedding: - model: all-MiniLM-L6-v2 - fields: [title, summary, organism, platform, entry_type] -""" - -MINIMAL_TEMPLATE = """\ -# OSA Configuration - -server: - name: "My OSA Node" - domain: "localhost" - -# Database (SQLite by default, stored in $OSA_DATA_DIR or ~/.local/share/osa/) -# database: -# auto_migrate: true - -# Logging -# logging: -# level: "INFO" - -# Add your sources here: -# sources: -# - source: geo-entrez -# config: -# email: your@email.com - -# Add your indexes here: -# indexes: -# - name: my-index -# backend: vector -# config: -# persist_dir: {vectors_dir} -""" - - -def _get_template_content(template: Template, paths: OSAPaths) -> str: - """Get template content with paths substituted.""" - if template == "geo": - return GEO_TEMPLATE.format(vectors_dir=paths.vectors_dir) - else: - return MINIMAL_TEMPLATE.format(vectors_dir=paths.vectors_dir) - - -def _resolve_config(paths: OSAPaths, console: "Console") -> Path: - """Resolve config file path. - - Resolution order: - 1. ./osa.yaml (local development override) - 2. $OSA_DATA_DIR/config/config.yaml (unified mode) - 3. ~/.config/osa/config.yaml (XDG standard location) - - Returns: - Path to config file. - - Exits: - If no config found, prints helpful message and exits. - """ - if LOCAL_CONFIG.exists(): - return LOCAL_CONFIG - elif paths.config_file.exists(): - return paths.config_file - else: - console.error("No configuration found") - console.print() - console.print("Create a config file first:") - console.print(" [bold]osa local init geo[/bold] # GEO template with vector search") - console.print(" [bold]osa local init minimal[/bold] # Blank template") - console.print() - console.print("Then run: [bold]osa local start[/bold]") - sys.exit(1) - - -@app.command -def start( - host: str = "0.0.0.0", - port: int = 8000, -) -> None: - """Start the OSA server in the background. - - Args: - host: Host to bind to. - port: Port to listen on. - """ - console = get_console() - paths = OSAPaths() - - # Ensure directories exist - paths.ensure_directories() - - # Resolve config (exits if not found) - config = _resolve_config(paths, console) - - daemon = DaemonManager(paths) - - try: - config_path = str(config.resolve()) - info = daemon.start(host=host, port=port, config_file=config_path) - console.success(f"Server started on http://{host}:{port}") - console.print(f" [dim]PID:[/dim] {info.pid}") - console.print(f" [dim]Config:[/dim] {config}") - console.print(f" [dim]Logs:[/dim] {daemon.paths.server_log}") - except ConfigError as e: - console.error(e.message) - for detail in e.details: - console.print(f" [dim]•[/dim] {detail}") - console.print() - if config: - console.info(f"Config file: {config}") - sys.exit(1) - except RuntimeError as e: - console.error(str(e)) - sys.exit(1) - - -@app.command -def stop() -> None: - """Stop the OSA server.""" - console = get_console() - daemon = DaemonManager() - - try: - daemon.stop() - console.success("Server stopped") - except RuntimeError as e: - console.error(str(e)) - sys.exit(1) - - -@app.command -def restart() -> None: - """Restart the OSA server (stop + start).""" - console = get_console() - paths = OSAPaths() - daemon = DaemonManager(paths) - - # Get current server info before stopping - info = daemon.status() - if info.status == ServerStatus.RUNNING: - host = info.host or "0.0.0.0" - port = info.port or 8000 - console.print(f"Stopping server (PID {info.pid})...") - try: - daemon.stop() - except RuntimeError: - pass # Continue to start even if stop fails - else: - host = "0.0.0.0" - port = 8000 - if info.status == ServerStatus.STALE: - try: - daemon.stop() - except RuntimeError: - pass - - # Start with resolved config - start(host=host, port=port) - - -@app.command -def status() -> None: - """Check server status.""" - console = get_console() - daemon = DaemonManager() - info = daemon.status() - - if info.status == ServerStatus.RUNNING: - console.success(f"Server running on http://{info.host}:{info.port}") - console.print(f" [dim]PID:[/dim] {info.pid}") - if info.started_at: - console.print(f" [dim]Started:[/dim] {relative_time(info.started_at)}") - elif info.status == ServerStatus.STOPPED: - console.print("[dim]Server is not running[/dim]") - elif info.status == ServerStatus.STALE: - console.warning(f"Stale server state (PID {info.pid} is dead)") - console.info("Run 'osa local stop' to clean up, or 'osa local start' to start fresh") - - -@app.command -def logs( - follow: bool = False, - lines: int = 50, -) -> None: - """View server logs. - - Args: - follow: Follow log output (like tail -f). - lines: Number of lines to show (default 50). Use 0 for all. - """ - console = get_console() - paths = OSAPaths() - log_file = paths.server_log - - if not log_file.exists(): - console.error( - f"No log file found at {log_file}", - hint="Has the server been started?", - ) - sys.exit(1) - - if follow: - _follow_logs(log_file, lines) - else: - _show_logs(log_file, lines) - - -def _show_logs(log_file: Path, lines: int) -> None: - """Show the last N lines of the log file.""" - with open(log_file) as f: - all_lines = f.readlines() - - if lines == 0: - for line in all_lines: - print(line, end="") - else: - for line in all_lines[-lines:]: - print(line, end="") - - -def _follow_logs(log_file: Path, initial_lines: int) -> None: - """Follow log output, similar to tail -f.""" - _show_logs(log_file, initial_lines) - - try: - with open(log_file) as f: - f.seek(0, 2) # Seek to end - while True: - line = f.readline() - if line: - print(line, end="", flush=True) - else: - time.sleep(0.1) - except KeyboardInterrupt: - print() - - -@app.command -def clean( - force: bool = False, - keep_config: bool = False, - keep_logs: bool = False, -) -> None: - """Wipe OSA data directories to start fresh. - - Stops the server if running, then removes: - - Data directory (database, vectors) - - State directory (server state, logs) - - Cache directory (search cache) - - Config directory (unless --keep-config) - - Args: - force: Skip confirmation prompt. - keep_config: Keep the config directory. - keep_logs: Keep the logs directory. - """ - console = get_console() - paths = OSAPaths() - - # Check which directories exist - dirs_to_clean: list[tuple[str, Path]] = [] - if paths.data_dir.exists(): - dirs_to_clean.append(("Data", paths.data_dir)) - if paths.state_dir.exists(): - dirs_to_clean.append(("State", paths.state_dir)) - if paths.cache_dir.exists(): - dirs_to_clean.append(("Cache", paths.cache_dir)) - if paths.config_dir.exists() and not keep_config: - dirs_to_clean.append(("Config", paths.config_dir)) - - if not dirs_to_clean: - console.info("Nothing to clean - no OSA directories exist") - return - - # Check if server is running - daemon = DaemonManager() - status_info = daemon.status() - - if status_info.status == ServerStatus.RUNNING: - if not force: - console.warning(f"Server is running (PID {status_info.pid})") - response = input("Stop server and clean? [y/N] ").strip().lower() - if response != "y": - console.info("Aborted") - sys.exit(1) - console.info("Stopping server...") - daemon.stop() - - # Confirm before wiping - if not force: - console.print("[bold]This will delete:[/bold]") - for name, path in dirs_to_clean: - console.print(f" [cyan]{path}[/cyan] [dim]({name.lower()})[/dim]") - if keep_logs: - console.print(f" [dim](keeping logs at {paths.logs_dir})[/dim]") - if keep_config and paths.config_dir.exists(): - console.print(f" [dim](keeping config at {paths.config_dir})[/dim]") - console.print() - response = input("Are you sure? [y/N] ").strip().lower() - if response != "y": - console.info("Aborted") - sys.exit(1) - - # Perform cleanup - for name, path in dirs_to_clean: - if name == "State" and keep_logs: - # Delete everything in state except logs - for item in path.iterdir(): - if item == paths.logs_dir: - continue - if item.is_dir(): - shutil.rmtree(item) - else: - item.unlink() - console.success(f"Cleaned {path} (logs preserved)") - else: - shutil.rmtree(path) - console.success(f"Removed {path}") - - console.print() - console.info("Run 'osa local init' to set up OSA again") - - -@app.command -def init( - template: Template | None = None, - /, - force: bool = False, -) -> None: - """Initialize OSA for local development. - - Creates directories and config file in XDG locations (or $OSA_DATA_DIR). - Edit the config to customize your instance, then run 'osa local start'. - - Args: - template: Template to use (geo, minimal). - force: Overwrite existing configuration. - """ - console = get_console() - paths = OSAPaths() - - # If no template specified, show available options - if template is None: - console.print("[bold]Available templates:[/bold]\n") - console.print(" [cyan]geo[/cyan] NCBI GEO integration with vector search") - console.print(" [cyan]minimal[/cyan] Blank configuration to customize") - console.print() - console.print("Usage: [bold]osa local init