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