From 6c844e56e92c5f2f0698528742e5c07f73a5f83b Mon Sep 17 00:00:00 2001 From: Petr Simecek Date: Thu, 26 Feb 2026 12:35:18 +0100 Subject: [PATCH] Phase 1: Project skeleton with CLI, models, output formatter, and error handling Initialize the Python project with uv, create directory structure, Typer CLI skeleton with all command groups (project, config, context, doctor), shared Pydantic models, OutputFormatter with JSON/Rich dual mode, and error handling with token masking. - pyproject.toml with entry point, deps, and dev deps - Typer root app with --json, --verbose, --no-color global flags - Project commands: add, list, remove, edit, status (stubs) - Config commands: list, detail (stubs) - Context and doctor top-level commands (stubs) - Pydantic models: AppConfig, ProjectConfig, ErrorResponse, SuccessResponse - OutputFormatter: JSON mode for agents, Rich mode for humans - mask_token() helper and KeboolaApiError/ConfigError exceptions - ConfigStore skeleton with load/save/add/remove/edit/get methods - Tests: test_errors.py, test_models.py, test_output.py (40 tests, all pass) Co-Authored-By: Claude Opus 4.6 --- .gitignore | 42 +++ .python-version | 1 + README.md | 3 + pyproject.toml | 37 ++ src/keboola_agent_cli/__init__.py | 3 + src/keboola_agent_cli/__main__.py | 5 + src/keboola_agent_cli/cli.py | 61 ++++ src/keboola_agent_cli/commands/__init__.py | 0 src/keboola_agent_cli/commands/config.py | 42 +++ src/keboola_agent_cli/commands/context.py | 16 + src/keboola_agent_cli/commands/doctor.py | 16 + src/keboola_agent_cli/commands/project.py | 68 ++++ src/keboola_agent_cli/config_store.py | 82 +++++ src/keboola_agent_cli/errors.py | 51 +++ src/keboola_agent_cli/models.py | 41 +++ src/keboola_agent_cli/output.py | 88 +++++ src/keboola_agent_cli/services/__init__.py | 0 tests/__init__.py | 0 tests/conftest.py | 34 ++ tests/test_errors.py | 93 +++++ tests/test_models.py | 192 +++++++++++ tests/test_output.py | 147 ++++++++ uv.lock | 382 +++++++++++++++++++++ 23 files changed, 1404 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/keboola_agent_cli/__init__.py create mode 100644 src/keboola_agent_cli/__main__.py create mode 100644 src/keboola_agent_cli/cli.py create mode 100644 src/keboola_agent_cli/commands/__init__.py create mode 100644 src/keboola_agent_cli/commands/config.py create mode 100644 src/keboola_agent_cli/commands/context.py create mode 100644 src/keboola_agent_cli/commands/doctor.py create mode 100644 src/keboola_agent_cli/commands/project.py create mode 100644 src/keboola_agent_cli/config_store.py create mode 100644 src/keboola_agent_cli/errors.py create mode 100644 src/keboola_agent_cli/models.py create mode 100644 src/keboola_agent_cli/output.py create mode 100644 src/keboola_agent_cli/services/__init__.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_errors.py create mode 100644 tests/test_models.py create mode 100644 tests/test_output.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..873235ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so + +# Distribution / packaging +dist/ +build/ +*.egg-info/ +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ + +# Environment variables +.env +.env.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# mypy +.mypy_cache/ + +# ruff +.ruff_cache/ + +# OS +.DS_Store +Thumbs.db diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..e4fba218 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/README.md b/README.md new file mode 100644 index 00000000..a967cdb6 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Keboola Agent CLI + +AI-friendly CLI for managing Keboola projects. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..fde412b1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[project] +name = "keboola-agent-cli" +version = "0.1.0" +description = "AI-friendly CLI for managing Keboola projects" +readme = "README.md" +requires-python = ">=3.12" +license = "MIT" +authors = [ + { name = "Keboola", email = "dev@keboola.com" }, +] +dependencies = [ + "typer[all]>=0.12", + "rich>=13", + "httpx>=0.27", + "pydantic>=2.5", + "platformdirs>=4", +] + +[project.scripts] +kbagent = "keboola_agent_cli.cli:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/keboola_agent_cli"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] + +[dependency-groups] +dev = [ + "pytest>=8", + "pytest-httpx>=0.30", +] diff --git a/src/keboola_agent_cli/__init__.py b/src/keboola_agent_cli/__init__.py new file mode 100644 index 00000000..6b058ef4 --- /dev/null +++ b/src/keboola_agent_cli/__init__.py @@ -0,0 +1,3 @@ +"""Keboola Agent CLI - AI-friendly interface to Keboola projects.""" + +__version__ = "0.1.0" diff --git a/src/keboola_agent_cli/__main__.py b/src/keboola_agent_cli/__main__.py new file mode 100644 index 00000000..1f0ae64a --- /dev/null +++ b/src/keboola_agent_cli/__main__.py @@ -0,0 +1,5 @@ +"""Allow running as `python -m keboola_agent_cli`.""" + +from .cli import app + +app() diff --git a/src/keboola_agent_cli/cli.py b/src/keboola_agent_cli/cli.py new file mode 100644 index 00000000..0e29d86f --- /dev/null +++ b/src/keboola_agent_cli/cli.py @@ -0,0 +1,61 @@ +"""Typer root application with global options and subcommand registration.""" + +import sys +from typing import Optional + +import typer + +from .commands.config import config_app +from .commands.context import context_command +from .commands.doctor import doctor_command +from .commands.project import project_app +from .output import OutputFormatter + +app = typer.Typer( + name="kbagent", + help="Keboola Agent CLI -- AI-friendly interface to Keboola projects", + no_args_is_help=True, +) + +app.add_typer(project_app, name="project") +app.add_typer(config_app, name="config") +app.command("context")(context_command) +app.command("doctor")(doctor_command) + + +@app.callback() +def main( + ctx: typer.Context, + json_output: bool = typer.Option( + False, + "--json", + "-j", + help="Output in JSON format (for machine consumption)", + ), + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Enable verbose output", + ), + no_color: bool = typer.Option( + False, + "--no-color", + help="Disable colored output", + ), +) -> None: + """Global options applied to all commands.""" + is_tty = hasattr(sys.stdout, "isatty") and sys.stdout.isatty() + effective_no_color = no_color or not is_tty + + formatter = OutputFormatter( + json_mode=json_output, + no_color=effective_no_color, + verbose=verbose, + ) + + ctx.ensure_object(dict) + ctx.obj["formatter"] = formatter + ctx.obj["json_output"] = json_output + ctx.obj["verbose"] = verbose + ctx.obj["no_color"] = effective_no_color diff --git a/src/keboola_agent_cli/commands/__init__.py b/src/keboola_agent_cli/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/keboola_agent_cli/commands/config.py b/src/keboola_agent_cli/commands/config.py new file mode 100644 index 00000000..dc9c5f2e --- /dev/null +++ b/src/keboola_agent_cli/commands/config.py @@ -0,0 +1,42 @@ +"""Configuration browsing commands - list and detail.""" + +from typing import Optional + +import typer + +from ..output import OutputFormatter + +config_app = typer.Typer(help="Browse and inspect configurations") + + +def _get_formatter(ctx: typer.Context) -> OutputFormatter: + """Retrieve the OutputFormatter from the Typer context.""" + return ctx.obj["formatter"] + + +@config_app.command("list") +def config_list( + ctx: typer.Context, + project: Optional[list[str]] = typer.Option(None, "--project", help="Project alias (can be repeated)"), + component_type: Optional[str] = typer.Option( + None, + "--component-type", + help="Filter by component type: extractor, writer, transformation, application", + ), + component_id: Optional[str] = typer.Option(None, "--component-id", help="Filter by specific component ID"), +) -> None: + """List configurations from connected projects.""" + formatter = _get_formatter(ctx) + formatter.output("Not yet implemented", lambda c, d: c.print(d)) + + +@config_app.command("detail") +def config_detail( + ctx: typer.Context, + project: str = typer.Option(..., "--project", help="Project alias"), + component_id: str = typer.Option(..., "--component-id", help="Component ID"), + config_id: str = typer.Option(..., "--config-id", help="Configuration ID"), +) -> None: + """Show detailed information about a specific configuration.""" + formatter = _get_formatter(ctx) + formatter.output("Not yet implemented", lambda c, d: c.print(d)) diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py new file mode 100644 index 00000000..0fd80fa5 --- /dev/null +++ b/src/keboola_agent_cli/commands/context.py @@ -0,0 +1,16 @@ +"""Context command - provides usage instructions for AI agents.""" + +import typer + +from ..output import OutputFormatter + + +def _get_formatter(ctx: typer.Context) -> OutputFormatter: + """Retrieve the OutputFormatter from the Typer context.""" + return ctx.obj["formatter"] + + +def context_command(ctx: typer.Context) -> None: + """Show usage instructions for AI agents interacting with Keboola.""" + formatter = _get_formatter(ctx) + formatter.output("Not yet implemented", lambda c, d: c.print(d)) diff --git a/src/keboola_agent_cli/commands/doctor.py b/src/keboola_agent_cli/commands/doctor.py new file mode 100644 index 00000000..0fab7769 --- /dev/null +++ b/src/keboola_agent_cli/commands/doctor.py @@ -0,0 +1,16 @@ +"""Doctor command - health check for CLI configuration and connectivity.""" + +import typer + +from ..output import OutputFormatter + + +def _get_formatter(ctx: typer.Context) -> OutputFormatter: + """Retrieve the OutputFormatter from the Typer context.""" + return ctx.obj["formatter"] + + +def doctor_command(ctx: typer.Context) -> None: + """Run health checks on CLI configuration and project connectivity.""" + formatter = _get_formatter(ctx) + formatter.output("Not yet implemented", lambda c, d: c.print(d)) diff --git a/src/keboola_agent_cli/commands/project.py b/src/keboola_agent_cli/commands/project.py new file mode 100644 index 00000000..91640a5d --- /dev/null +++ b/src/keboola_agent_cli/commands/project.py @@ -0,0 +1,68 @@ +"""Project management commands - add, list, remove, edit, status.""" + +from typing import Optional + +import typer + +from ..output import OutputFormatter + +project_app = typer.Typer(help="Manage connected Keboola projects") + + +def _get_formatter(ctx: typer.Context) -> OutputFormatter: + """Retrieve the OutputFormatter from the Typer context.""" + return ctx.obj["formatter"] + + +@project_app.command("add") +def project_add( + ctx: typer.Context, + alias: str = typer.Option(..., help="Human-friendly name for this project"), + url: str = typer.Option( + "https://connection.keboola.com", + help="Keboola stack URL", + ), + token: str = typer.Option(..., help="Storage API token"), +) -> None: + """Add a new Keboola project connection.""" + formatter = _get_formatter(ctx) + formatter.output("Not yet implemented", lambda c, d: c.print(d)) + + +@project_app.command("list") +def project_list(ctx: typer.Context) -> None: + """List all connected Keboola projects.""" + formatter = _get_formatter(ctx) + formatter.output([], lambda c, d: c.print("Not yet implemented")) + + +@project_app.command("remove") +def project_remove( + ctx: typer.Context, + alias: str = typer.Option(..., help="Alias of the project to remove"), +) -> None: + """Remove a Keboola project connection.""" + formatter = _get_formatter(ctx) + formatter.output("Not yet implemented", lambda c, d: c.print(d)) + + +@project_app.command("edit") +def project_edit( + ctx: typer.Context, + alias: str = typer.Option(..., help="Alias of the project to edit"), + url: Optional[str] = typer.Option(None, help="New Keboola stack URL"), + token: Optional[str] = typer.Option(None, help="New Storage API token"), +) -> None: + """Edit an existing Keboola project connection.""" + formatter = _get_formatter(ctx) + formatter.output("Not yet implemented", lambda c, d: c.print(d)) + + +@project_app.command("status") +def project_status( + ctx: typer.Context, + project: Optional[str] = typer.Option(None, "--project", help="Check only this project (default: all)"), +) -> None: + """Test connectivity to connected Keboola projects.""" + formatter = _get_formatter(ctx) + formatter.output("Not yet implemented", lambda c, d: c.print(d)) diff --git a/src/keboola_agent_cli/config_store.py b/src/keboola_agent_cli/config_store.py new file mode 100644 index 00000000..4890dc3a --- /dev/null +++ b/src/keboola_agent_cli/config_store.py @@ -0,0 +1,82 @@ +"""Persistent configuration store for Keboola Agent CLI. + +Manages reading and writing of config.json with project connections. +""" + +from pathlib import Path + +import platformdirs + +from .models import AppConfig, ProjectConfig + + +class ConfigStore: + """Handles persistence of application configuration to disk. + + Configuration is stored as JSON at the platform-appropriate config directory, + defaulting to ~/.config/keboola-agent-cli/config.json on Linux/macOS. + """ + + CONFIG_FILENAME = "config.json" + + def __init__(self, config_dir: Path | None = None) -> None: + if config_dir is None: + self._config_dir = Path(platformdirs.user_config_dir("keboola-agent-cli")) + else: + self._config_dir = config_dir + self._config_path = self._config_dir / self.CONFIG_FILENAME + + @property + def config_path(self) -> Path: + """Return the path to the config file.""" + return self._config_path + + def load(self) -> AppConfig: + """Load configuration from disk. + + Returns an empty AppConfig if the file does not exist. + """ + if not self._config_path.exists(): + return AppConfig() + raw = self._config_path.read_text(encoding="utf-8") + return AppConfig.model_validate_json(raw) + + def save(self, config: AppConfig) -> None: + """Save configuration to disk with secure file permissions (0600).""" + self._config_dir.mkdir(parents=True, exist_ok=True) + json_str = config.model_dump_json(indent=2) + self._config_path.write_text(json_str + "\n", encoding="utf-8") + self._config_path.chmod(0o600) + + def add_project(self, alias: str, project: ProjectConfig) -> None: + """Add a project to the configuration.""" + config = self.load() + config.projects[alias] = project + if not config.default_project: + config.default_project = alias + self.save(config) + + def remove_project(self, alias: str) -> None: + """Remove a project from the configuration.""" + config = self.load() + config.projects.pop(alias, None) + if config.default_project == alias: + config.default_project = next(iter(config.projects), "") + self.save(config) + + def get_project(self, alias: str) -> ProjectConfig | None: + """Get a project by alias, or None if not found.""" + config = self.load() + return config.projects.get(alias) + + def edit_project(self, alias: str, **kwargs: str | int) -> None: + """Update fields on an existing project.""" + config = self.load() + if alias not in config.projects: + return + project = config.projects[alias] + for key, value in kwargs.items(): + if hasattr(project, key) and value is not None: + setattr(project, key, value) + config.projects[alias] = project + self.save(config) diff --git a/src/keboola_agent_cli/errors.py b/src/keboola_agent_cli/errors.py new file mode 100644 index 00000000..f0595672 --- /dev/null +++ b/src/keboola_agent_cli/errors.py @@ -0,0 +1,51 @@ +"""Error types and helpers for Keboola Agent CLI.""" + + +def mask_token(token: str) -> str: + """Mask a Keboola Storage API token for safe display. + + Preserves the prefix (part before the first dash) and the last 4 characters, + replacing the middle with '...'. + + Examples: + mask_token("901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k") + -> "901-...pt0k" + + mask_token("abc") -> "***" + mask_token("") -> "***" + """ + if len(token) < 8: + return "***" + + dash_index = token.find("-") + if dash_index == -1 or dash_index >= len(token) - 4: + return "***" + + prefix = token[: dash_index] + last4 = token[-4:] + return f"{prefix}-...{last4}" + + +class KeboolaApiError(Exception): + """Raised when a Keboola API call fails.""" + + def __init__( + self, + message: str, + status_code: int = 0, + error_code: str = "UNKNOWN_ERROR", + retryable: bool = False, + ) -> None: + super().__init__(message) + self.message = message + self.status_code = status_code + self.error_code = error_code + self.retryable = retryable + + +class ConfigError(Exception): + """Raised when there is a configuration problem.""" + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message diff --git a/src/keboola_agent_cli/models.py b/src/keboola_agent_cli/models.py new file mode 100644 index 00000000..81a7b59f --- /dev/null +++ b/src/keboola_agent_cli/models.py @@ -0,0 +1,41 @@ +"""Pydantic models shared across all layers of the application.""" + +from typing import Any + +from pydantic import BaseModel, Field + + +class ProjectConfig(BaseModel): + """Configuration for a single Keboola project connection.""" + + stack_url: str = Field(description="Keboola stack URL, e.g. https://connection.keboola.com") + token: str = Field(description="Storage API token") + project_name: str = Field(default="", description="Human-readable project name (populated on add)") + project_id: int = Field(default=0, description="Keboola project ID (populated on add)") + + +class AppConfig(BaseModel): + """Top-level application configuration persisted to config.json.""" + + version: int = Field(default=1, description="Config schema version for future migrations") + default_project: str = Field(default="", description="Alias of the default project") + projects: dict[str, ProjectConfig] = Field( + default_factory=dict, + description="Map of alias -> ProjectConfig", + ) + + +class ErrorResponse(BaseModel): + """Structured error response for JSON output mode.""" + + code: str = Field(description="Machine-readable error code, e.g. INVALID_TOKEN") + message: str = Field(description="Human-readable error description") + project: str = Field(default="", description="Project alias related to the error, if any") + retryable: bool = Field(default=False, description="Whether the operation can be retried") + + +class SuccessResponse(BaseModel): + """Structured success response for JSON output mode.""" + + status: str = Field(default="ok", description="Always 'ok' for success responses") + data: Any = Field(default=None, description="Response payload") diff --git a/src/keboola_agent_cli/output.py b/src/keboola_agent_cli/output.py new file mode 100644 index 00000000..0784af48 --- /dev/null +++ b/src/keboola_agent_cli/output.py @@ -0,0 +1,88 @@ +"""Output formatting with JSON and Rich dual mode support.""" + +import json +import sys +from typing import Any, Callable + +from rich.console import Console + +from .models import ErrorResponse, SuccessResponse + + +class OutputFormatter: + """Formats CLI output as either JSON (for machines/agents) or Rich (for humans). + + In JSON mode, all output goes to stdout as valid JSON. + In human mode, Rich console is used for formatted tables and panels. + """ + + def __init__( + self, + json_mode: bool = False, + no_color: bool = False, + verbose: bool = False, + ) -> None: + self.json_mode = json_mode + self.verbose = verbose + is_tty = hasattr(sys.stdout, "isatty") and sys.stdout.isatty() + force_terminal = None if is_tty and not no_color else False + self.console = Console( + no_color=no_color, + force_terminal=force_terminal, + ) + self.err_console = Console( + stderr=True, + no_color=no_color, + force_terminal=force_terminal, + ) + + def output(self, data: Any, human_formatter: Callable[[Console, Any], None] | None = None) -> None: + """Output data in the appropriate format. + + Args: + data: The data to output. In JSON mode, this is serialized directly. + In human mode, it's passed to human_formatter. + human_formatter: A callable that takes (Console, data) and prints + human-friendly output. If None in human mode, prints repr. + """ + if self.json_mode: + response = SuccessResponse(status="ok", data=data) + sys.stdout.write(response.model_dump_json(indent=2) + "\n") + else: + if human_formatter is not None: + human_formatter(self.console, data) + else: + self.console.print(data) + + def error(self, message: str, error_code: str = "ERROR", project: str = "", retryable: bool = False) -> None: + """Output an error message. + + Args: + message: Human-readable error description. + error_code: Machine-readable error code. + project: Project alias related to the error. + retryable: Whether the operation can be retried. + """ + if self.json_mode: + err = ErrorResponse( + code=error_code, + message=message, + project=project, + retryable=retryable, + ) + error_envelope = {"status": "error", "error": err.model_dump()} + sys.stdout.write(json.dumps(error_envelope, indent=2) + "\n") + else: + self.err_console.print(f"[bold red]Error:[/bold red] {message}") + + def success(self, message: str) -> None: + """Output a success message. + + Args: + message: The success message to display. + """ + if self.json_mode: + response = SuccessResponse(status="ok", data={"message": message}) + sys.stdout.write(response.model_dump_json(indent=2) + "\n") + else: + self.console.print(f"[bold green]Success:[/bold green] {message}") diff --git a/src/keboola_agent_cli/services/__init__.py b/src/keboola_agent_cli/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..b1382a4c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,34 @@ +"""Shared test fixtures for Keboola Agent CLI tests.""" + +from pathlib import Path + +import pytest + +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.output import OutputFormatter + + +@pytest.fixture +def tmp_config_dir(tmp_path: Path) -> Path: + """Provide a temporary directory for configuration files.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + return config_dir + + +@pytest.fixture +def config_store(tmp_config_dir: Path) -> ConfigStore: + """Provide a ConfigStore backed by a temporary directory.""" + return ConfigStore(config_dir=tmp_config_dir) + + +@pytest.fixture +def json_formatter() -> OutputFormatter: + """Provide an OutputFormatter in JSON mode.""" + return OutputFormatter(json_mode=True, no_color=True) + + +@pytest.fixture +def human_formatter() -> OutputFormatter: + """Provide an OutputFormatter in human (Rich) mode.""" + return OutputFormatter(json_mode=False, no_color=True) diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 00000000..50dc257e --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,93 @@ +"""Tests for error types and helpers.""" + +from keboola_agent_cli.errors import ConfigError, KeboolaApiError, mask_token + + +class TestMaskToken: + """Tests for the mask_token() function.""" + + def test_normal_token(self) -> None: + """A standard Keboola token is masked to show prefix and last 4 chars.""" + result = mask_token("901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k") + assert result == "901-...pt0k" + + def test_short_prefix_token(self) -> None: + """Token with a short numeric prefix still masks correctly.""" + result = mask_token("5-abcdefghijklmnop") + assert result == "5-...mnop" + + def test_empty_string(self) -> None: + """Empty string returns the safe mask placeholder.""" + result = mask_token("") + assert result == "***" + + def test_short_token(self) -> None: + """Very short tokens (< 8 chars) return the safe mask placeholder.""" + assert mask_token("abc") == "***" + assert mask_token("ab-cde") == "***" + assert mask_token("1234567") == "***" + + def test_token_exactly_8_chars_with_dash(self) -> None: + """Token with exactly 8 chars and a dash in a valid position.""" + result = mask_token("a-123456") + assert result == "a-...3456" + + def test_no_dash_in_token(self) -> None: + """Token without any dash returns the safe mask placeholder.""" + result = mask_token("abcdefghijklmnop") + assert result == "***" + + def test_dash_at_end(self) -> None: + """Token with dash near the end where prefix would consume too much.""" + result = mask_token("abcdefghijklmno-") + assert result == "***" + + def test_multiple_dashes(self) -> None: + """Token with multiple dashes uses only the first dash for prefix.""" + result = mask_token("901-123-abcdefghijk") + assert result == "901-...hijk" + + +class TestKeboolaApiError: + """Tests for KeboolaApiError exception.""" + + def test_basic_creation(self) -> None: + """Error can be created with all attributes.""" + err = KeboolaApiError( + message="Token is invalid", + status_code=401, + error_code="INVALID_TOKEN", + retryable=False, + ) + assert str(err) == "Token is invalid" + assert err.message == "Token is invalid" + assert err.status_code == 401 + assert err.error_code == "INVALID_TOKEN" + assert err.retryable is False + + def test_default_values(self) -> None: + """Error has sensible defaults for optional fields.""" + err = KeboolaApiError(message="Something failed") + assert err.status_code == 0 + assert err.error_code == "UNKNOWN_ERROR" + assert err.retryable is False + + def test_is_exception(self) -> None: + """KeboolaApiError is a proper Exception subclass.""" + err = KeboolaApiError(message="test") + assert isinstance(err, Exception) + + +class TestConfigError: + """Tests for ConfigError exception.""" + + def test_basic_creation(self) -> None: + """ConfigError stores the message.""" + err = ConfigError(message="Config file is corrupted") + assert str(err) == "Config file is corrupted" + assert err.message == "Config file is corrupted" + + def test_is_exception(self) -> None: + """ConfigError is a proper Exception subclass.""" + err = ConfigError(message="test") + assert isinstance(err, Exception) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 00000000..4adc416a --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,192 @@ +"""Tests for Pydantic models serialization and deserialization.""" + +import json + +from keboola_agent_cli.models import AppConfig, ErrorResponse, ProjectConfig, SuccessResponse + + +class TestProjectConfig: + """Tests for ProjectConfig model.""" + + def test_create_with_all_fields(self) -> None: + """ProjectConfig can be created with all fields specified.""" + config = ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-secret-token", + project_name="My Project", + project_id=1234, + ) + assert config.stack_url == "https://connection.keboola.com" + assert config.token == "901-secret-token" + assert config.project_name == "My Project" + assert config.project_id == 1234 + + def test_default_values(self) -> None: + """ProjectConfig has sensible defaults for optional fields.""" + config = ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-token", + ) + assert config.project_name == "" + assert config.project_id == 0 + + def test_json_round_trip(self) -> None: + """ProjectConfig can be serialized to JSON and deserialized back.""" + original = ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-secret-token", + project_name="My Project", + project_id=1234, + ) + json_str = original.model_dump_json() + restored = ProjectConfig.model_validate_json(json_str) + assert restored == original + + def test_json_output_is_valid(self) -> None: + """ProjectConfig JSON output is valid JSON.""" + config = ProjectConfig( + stack_url="https://connection.keboola.com", + token="token", + ) + json_str = config.model_dump_json() + parsed = json.loads(json_str) + assert "stack_url" in parsed + assert "token" in parsed + + +class TestAppConfig: + """Tests for AppConfig model.""" + + def test_empty_config(self) -> None: + """AppConfig can be created with defaults (no projects).""" + config = AppConfig() + assert config.version == 1 + assert config.default_project == "" + assert config.projects == {} + + def test_config_with_projects(self) -> None: + """AppConfig can hold multiple project connections.""" + config = AppConfig( + version=1, + default_project="prod-aws", + projects={ + "prod-aws": ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-token", + project_name="Production", + project_id=1001, + ), + "dev-azure": ProjectConfig( + stack_url="https://connection.north-europe.azure.keboola.com", + token="532-token", + project_name="Development", + project_id=2002, + ), + }, + ) + assert len(config.projects) == 2 + assert "prod-aws" in config.projects + assert "dev-azure" in config.projects + assert config.projects["prod-aws"].project_id == 1001 + + def test_json_round_trip(self) -> None: + """AppConfig can be serialized to JSON and deserialized back.""" + original = AppConfig( + version=1, + default_project="test", + projects={ + "test": ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-token", + project_name="Test", + project_id=999, + ), + }, + ) + json_str = original.model_dump_json() + restored = AppConfig.model_validate_json(json_str) + assert restored == original + assert restored.projects["test"].project_name == "Test" + + def test_json_output_structure(self) -> None: + """AppConfig JSON output has the expected top-level keys.""" + config = AppConfig( + version=1, + default_project="prod", + projects={ + "prod": ProjectConfig( + stack_url="https://connection.keboola.com", + token="t", + ), + }, + ) + parsed = json.loads(config.model_dump_json()) + assert parsed["version"] == 1 + assert parsed["default_project"] == "prod" + assert "prod" in parsed["projects"] + assert parsed["projects"]["prod"]["stack_url"] == "https://connection.keboola.com" + + +class TestErrorResponse: + """Tests for ErrorResponse model.""" + + def test_create(self) -> None: + """ErrorResponse can be created with all fields.""" + err = ErrorResponse( + code="INVALID_TOKEN", + message="Token is invalid or expired", + project="prod-aws", + retryable=False, + ) + assert err.code == "INVALID_TOKEN" + assert err.message == "Token is invalid or expired" + assert err.project == "prod-aws" + assert err.retryable is False + + def test_defaults(self) -> None: + """ErrorResponse has empty project and retryable=False by default.""" + err = ErrorResponse(code="ERR", message="Something failed") + assert err.project == "" + assert err.retryable is False + + def test_json_serialization(self) -> None: + """ErrorResponse serializes to valid JSON with expected keys.""" + err = ErrorResponse( + code="NETWORK_ERROR", + message="Connection timed out", + project="dev", + retryable=True, + ) + parsed = json.loads(err.model_dump_json()) + assert parsed["code"] == "NETWORK_ERROR" + assert parsed["retryable"] is True + + +class TestSuccessResponse: + """Tests for SuccessResponse model.""" + + def test_with_list_data(self) -> None: + """SuccessResponse can hold a list as data payload.""" + resp = SuccessResponse(status="ok", data=[{"name": "item1"}, {"name": "item2"}]) + assert resp.status == "ok" + assert len(resp.data) == 2 + + def test_with_empty_data(self) -> None: + """SuccessResponse can hold None or empty data.""" + resp = SuccessResponse() + assert resp.status == "ok" + assert resp.data is None + + def test_json_serialization(self) -> None: + """SuccessResponse serializes with status and data keys.""" + resp = SuccessResponse(status="ok", data={"message": "done"}) + parsed = json.loads(resp.model_dump_json()) + assert parsed["status"] == "ok" + assert parsed["data"]["message"] == "done" + + def test_json_round_trip(self) -> None: + """SuccessResponse can be round-tripped through JSON.""" + original = SuccessResponse(status="ok", data=["a", "b", "c"]) + json_str = original.model_dump_json() + restored = SuccessResponse.model_validate_json(json_str) + assert restored == original diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 00000000..7850b7d1 --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,147 @@ +"""Tests for OutputFormatter with JSON and Rich dual mode.""" + +import json +import sys +from io import StringIO + +from rich.console import Console + +from keboola_agent_cli.output import OutputFormatter + + +class TestOutputFormatterJsonMode: + """Tests for OutputFormatter in JSON mode.""" + + def test_output_list_data(self) -> None: + """JSON mode outputs a valid JSON envelope with list data.""" + formatter = OutputFormatter(json_mode=True, no_color=True) + captured = StringIO() + old_stdout = sys.stdout + sys.stdout = captured + try: + formatter.output([{"id": 1, "name": "test"}]) + finally: + sys.stdout = old_stdout + + result = json.loads(captured.getvalue()) + assert result["status"] == "ok" + assert result["data"] == [{"id": 1, "name": "test"}] + + def test_output_dict_data(self) -> None: + """JSON mode outputs valid JSON with dict data.""" + formatter = OutputFormatter(json_mode=True, no_color=True) + captured = StringIO() + old_stdout = sys.stdout + sys.stdout = captured + try: + formatter.output({"key": "value"}) + finally: + sys.stdout = old_stdout + + result = json.loads(captured.getvalue()) + assert result["status"] == "ok" + assert result["data"]["key"] == "value" + + def test_output_empty_list(self) -> None: + """JSON mode handles empty list data.""" + formatter = OutputFormatter(json_mode=True, no_color=True) + captured = StringIO() + old_stdout = sys.stdout + sys.stdout = captured + try: + formatter.output([]) + finally: + sys.stdout = old_stdout + + result = json.loads(captured.getvalue()) + assert result["status"] == "ok" + assert result["data"] == [] + + def test_error_json_output(self) -> None: + """JSON mode error outputs structured error envelope.""" + formatter = OutputFormatter(json_mode=True, no_color=True) + captured = StringIO() + old_stdout = sys.stdout + sys.stdout = captured + try: + formatter.error( + message="Token expired", + error_code="INVALID_TOKEN", + project="prod-aws", + retryable=False, + ) + finally: + sys.stdout = old_stdout + + result = json.loads(captured.getvalue()) + assert result["status"] == "error" + assert result["error"]["code"] == "INVALID_TOKEN" + assert result["error"]["message"] == "Token expired" + assert result["error"]["project"] == "prod-aws" + assert result["error"]["retryable"] is False + + def test_success_json_output(self) -> None: + """JSON mode success outputs structured response.""" + formatter = OutputFormatter(json_mode=True, no_color=True) + captured = StringIO() + old_stdout = sys.stdout + sys.stdout = captured + try: + formatter.success("Project added successfully") + finally: + sys.stdout = old_stdout + + result = json.loads(captured.getvalue()) + assert result["status"] == "ok" + assert result["data"]["message"] == "Project added successfully" + + +class TestOutputFormatterHumanMode: + """Tests for OutputFormatter in human (Rich) mode.""" + + def test_output_calls_human_formatter(self) -> None: + """Human mode calls the provided human_formatter callable.""" + formatter = OutputFormatter(json_mode=False, no_color=True) + called_with: list = [] + + def mock_formatter(console: Console, data: object) -> None: + called_with.append(data) + + formatter.output({"test": "data"}, mock_formatter) + assert len(called_with) == 1 + assert called_with[0] == {"test": "data"} + + def test_output_without_formatter_does_not_crash(self) -> None: + """Human mode without a formatter falls back to console.print and does not crash.""" + formatter = OutputFormatter(json_mode=False, no_color=True) + formatter.output("simple string") + + def test_error_does_not_crash(self) -> None: + """Human mode error output does not crash.""" + formatter = OutputFormatter(json_mode=False, no_color=True) + formatter.error("Something went wrong") + + def test_success_does_not_crash(self) -> None: + """Human mode success output does not crash.""" + formatter = OutputFormatter(json_mode=False, no_color=True) + formatter.success("All good") + + +class TestOutputFormatterInit: + """Tests for OutputFormatter initialization options.""" + + def test_json_mode_flag(self) -> None: + """json_mode flag is stored correctly.""" + formatter = OutputFormatter(json_mode=True) + assert formatter.json_mode is True + + def test_verbose_flag(self) -> None: + """verbose flag is stored correctly.""" + formatter = OutputFormatter(verbose=True) + assert formatter.verbose is True + + def test_no_color_creates_console(self) -> None: + """no_color creates a Console instance with color disabled.""" + formatter = OutputFormatter(no_color=True) + assert formatter.console is not None + assert formatter.err_console is not None diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..f9c16285 --- /dev/null +++ b/uv.lock @@ -0,0 +1,382 @@ +version = 1 +revision = 2 +requires-python = ">=3.12" + +[[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" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +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 = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[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 = "keboola-agent-cli" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "platformdirs" }, + { name = "pydantic" }, + { name = "rich" }, + { name = "typer" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-httpx" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "platformdirs", specifier = ">=4" }, + { name = "pydantic", specifier = ">=2.5" }, + { name = "rich", specifier = ">=13" }, + { name = "typer", extras = ["all"], specifier = ">=0.12" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8" }, + { name = "pytest-httpx", specifier = ">=0.30" }, +] + +[[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 = "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 = "platformdirs" +version = "4.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, +] + +[[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 = "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/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[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 = "pytest-httpx" +version = "0.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/5574834da9499066fa1a5ea9c336f94dba2eae02298d36dab192fcf95c86/pytest_httpx-0.36.0.tar.gz", hash = "sha256:9edb66a5fd4388ce3c343189bc67e7e1cb50b07c2e3fc83b97d511975e8a831b", size = 56793, upload-time = "2025-12-02T16:34:57.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/d2/1eb1ea9c84f0d2033eb0b49675afdc71aa4ea801b74615f00f3c33b725e3/pytest_httpx-0.36.0-py3-none-any.whl", hash = "sha256:bd4c120bb80e142df856e825ec9f17981effb84d159f9fa29ed97e2357c3a9c8", size = 20229, upload-time = "2025-12-02T16:34:56.45Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[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 = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, +] + +[[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" }, +]