diff --git a/mcp-servers/git-ops/Dockerfile b/mcp-servers/git-ops/Dockerfile new file mode 100644 index 0000000..76beea5 --- /dev/null +++ b/mcp-servers/git-ops/Dockerfile @@ -0,0 +1,67 @@ +# Git Operations MCP Server Dockerfile +# Multi-stage build for smaller production image + +FROM python:3.12-slim AS builder + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Install uv for fast package management +RUN pip install --no-cache-dir uv + +# Create app directory +WORKDIR /app + +# Copy dependency files +COPY pyproject.toml . + +# Install dependencies with uv +RUN uv pip install --system --no-cache . + +# Production stage +FROM python:3.12-slim + +# Install runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + openssh-client \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN useradd --create-home --shell /bin/bash syndarix + +# Create workspace directory +RUN mkdir -p /var/syndarix/workspaces && chown -R syndarix:syndarix /var/syndarix + +# Create app directory +WORKDIR /app + +# Copy installed packages from builder +COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin + +# Copy application code +COPY --chown=syndarix:syndarix . . + +# Set Python path +ENV PYTHONPATH=/app +ENV PYTHONUNBUFFERED=1 + +# Configure git for the container +RUN git config --global --add safe.directory '*' + +# Switch to non-root user +USER syndarix + +# Expose port +EXPOSE 8003 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import httpx; httpx.get('http://localhost:8003/health').raise_for_status()" || exit 1 + +# Run the server +CMD ["python", "server.py"] diff --git a/mcp-servers/git-ops/__init__.py b/mcp-servers/git-ops/__init__.py new file mode 100644 index 0000000..dac0b12 --- /dev/null +++ b/mcp-servers/git-ops/__init__.py @@ -0,0 +1,179 @@ +""" +Git Operations MCP Server. + +Provides git repository management, branching, commits, and PR workflows +for Syndarix AI agents. +""" + +__version__ = "0.1.0" + +from config import Settings, get_settings, is_test_mode, reset_settings +from exceptions import ( + APIError, + AuthenticationError, + BranchExistsError, + BranchNotFoundError, + CheckoutError, + CloneError, + CommitError, + CredentialError, + CredentialNotFoundError, + DirtyWorkspaceError, + ErrorCode, + GitError, + GitOpsError, + InvalidRefError, + MergeConflictError, + PRError, + PRNotFoundError, + ProviderError, + ProviderNotFoundError, + PullError, + PushError, + WorkspaceError, + WorkspaceLockedError, + WorkspaceNotFoundError, + WorkspaceSizeExceededError, +) +from models import ( + BranchInfo, + BranchRequest, + BranchResult, + CheckoutRequest, + CheckoutResult, + CloneRequest, + CloneResult, + CommitInfo, + CommitRequest, + CommitResult, + CreatePRRequest, + CreatePRResult, + DiffHunk, + DiffRequest, + DiffResult, + FileChange, + FileChangeType, + FileDiff, + GetPRRequest, + GetPRResult, + GetWorkspaceRequest, + GetWorkspaceResult, + HealthStatus, + ListBranchesRequest, + ListBranchesResult, + ListPRsRequest, + ListPRsResult, + LockWorkspaceRequest, + LockWorkspaceResult, + LogRequest, + LogResult, + MergePRRequest, + MergePRResult, + MergeStrategy, + PRInfo, + ProviderStatus, + ProviderType, + PRState, + PullRequest, + PullResult, + PushRequest, + PushResult, + StatusRequest, + StatusResult, + UnlockWorkspaceRequest, + UnlockWorkspaceResult, + UpdatePRRequest, + UpdatePRResult, + WorkspaceInfo, + WorkspaceState, +) + +__all__ = [ + # Version + "__version__", + # Config + "Settings", + "get_settings", + "reset_settings", + "is_test_mode", + # Error codes + "ErrorCode", + # Exceptions + "GitOpsError", + "WorkspaceError", + "WorkspaceNotFoundError", + "WorkspaceLockedError", + "WorkspaceSizeExceededError", + "GitError", + "CloneError", + "CheckoutError", + "CommitError", + "PushError", + "PullError", + "MergeConflictError", + "BranchExistsError", + "BranchNotFoundError", + "InvalidRefError", + "DirtyWorkspaceError", + "ProviderError", + "AuthenticationError", + "ProviderNotFoundError", + "PRError", + "PRNotFoundError", + "APIError", + "CredentialError", + "CredentialNotFoundError", + # Enums + "FileChangeType", + "MergeStrategy", + "PRState", + "ProviderType", + "WorkspaceState", + # Dataclasses + "FileChange", + "BranchInfo", + "CommitInfo", + "DiffHunk", + "FileDiff", + "PRInfo", + "WorkspaceInfo", + # Request/Response models + "CloneRequest", + "CloneResult", + "StatusRequest", + "StatusResult", + "BranchRequest", + "BranchResult", + "ListBranchesRequest", + "ListBranchesResult", + "CheckoutRequest", + "CheckoutResult", + "CommitRequest", + "CommitResult", + "PushRequest", + "PushResult", + "PullRequest", + "PullResult", + "DiffRequest", + "DiffResult", + "LogRequest", + "LogResult", + "CreatePRRequest", + "CreatePRResult", + "GetPRRequest", + "GetPRResult", + "ListPRsRequest", + "ListPRsResult", + "MergePRRequest", + "MergePRResult", + "UpdatePRRequest", + "UpdatePRResult", + "GetWorkspaceRequest", + "GetWorkspaceResult", + "LockWorkspaceRequest", + "LockWorkspaceResult", + "UnlockWorkspaceRequest", + "UnlockWorkspaceResult", + "HealthStatus", + "ProviderStatus", +] diff --git a/mcp-servers/git-ops/config.py b/mcp-servers/git-ops/config.py new file mode 100644 index 0000000..1561e0f --- /dev/null +++ b/mcp-servers/git-ops/config.py @@ -0,0 +1,155 @@ +""" +Configuration for Git Operations MCP Server. + +Uses pydantic-settings for environment variable loading. +""" + +import os +from pathlib import Path + +from pydantic import Field +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Application settings loaded from environment.""" + + # Server settings + host: str = Field(default="0.0.0.0", description="Server host") + port: int = Field(default=8003, description="Server port") + debug: bool = Field(default=False, description="Debug mode") + + # Workspace settings + workspace_base_path: Path = Field( + default=Path("/var/syndarix/workspaces"), + description="Base path for git workspaces", + ) + workspace_max_size_gb: float = Field( + default=10.0, + description="Maximum size per workspace in GB", + ) + workspace_stale_days: int = Field( + default=7, + description="Days after which unused workspace is considered stale", + ) + workspace_lock_timeout: int = Field( + default=300, + description="Workspace lock timeout in seconds", + ) + + # Git settings + git_timeout: int = Field( + default=120, + description="Default timeout for git operations in seconds", + ) + git_clone_timeout: int = Field( + default=600, + description="Timeout for clone operations in seconds", + ) + git_author_name: str = Field( + default="Syndarix Agent", + description="Default author name for commits", + ) + git_author_email: str = Field( + default="agent@syndarix.ai", + description="Default author email for commits", + ) + git_max_diff_lines: int = Field( + default=10000, + description="Maximum lines in diff output", + ) + + # Redis settings (for distributed locking) + redis_url: str = Field( + default="redis://localhost:6379/0", + description="Redis connection URL", + ) + + # Provider settings + gitea_base_url: str = Field( + default="", + description="Gitea API base URL (e.g., https://gitea.example.com)", + ) + gitea_token: str = Field( + default="", + description="Gitea API token", + ) + github_token: str = Field( + default="", + description="GitHub API token", + ) + github_api_url: str = Field( + default="https://api.github.com", + description="GitHub API URL (for Enterprise)", + ) + gitlab_token: str = Field( + default="", + description="GitLab API token", + ) + gitlab_url: str = Field( + default="https://gitlab.com", + description="GitLab URL (for self-hosted)", + ) + + # Rate limiting + rate_limit_requests: int = Field( + default=100, + description="Max API requests per minute per provider", + ) + rate_limit_window: int = Field( + default=60, + description="Rate limit window in seconds", + ) + + # Retry settings + retry_attempts: int = Field( + default=3, + description="Number of retry attempts for failed operations", + ) + retry_delay: float = Field( + default=1.0, + description="Initial retry delay in seconds", + ) + retry_max_delay: float = Field( + default=30.0, + description="Maximum retry delay in seconds", + ) + + # Security settings + allowed_hosts: list[str] = Field( + default_factory=list, + description="Allowed git host domains (empty = all)", + ) + max_clone_size_mb: int = Field( + default=500, + description="Maximum repository size for clone in MB", + ) + enable_force_push: bool = Field( + default=False, + description="Allow force push operations", + ) + + model_config = {"env_prefix": "GIT_OPS_", "env_file": ".env", "extra": "ignore"} + + +# Global settings instance (lazy initialization) +_settings: Settings | None = None + + +def get_settings() -> Settings: + """Get the global settings instance.""" + global _settings + if _settings is None: + _settings = Settings() + return _settings + + +def reset_settings() -> None: + """Reset the global settings (for testing).""" + global _settings + _settings = None + + +def is_test_mode() -> bool: + """Check if running in test mode.""" + return os.getenv("IS_TEST", "").lower() in ("true", "1", "yes") diff --git a/mcp-servers/git-ops/exceptions.py b/mcp-servers/git-ops/exceptions.py new file mode 100644 index 0000000..70fb320 --- /dev/null +++ b/mcp-servers/git-ops/exceptions.py @@ -0,0 +1,361 @@ +""" +Exception hierarchy for Git Operations MCP Server. + +Provides structured error handling with error codes for MCP responses. +""" + +from enum import Enum +from typing import Any + + +class ErrorCode(str, Enum): + """Error codes for Git Operations errors.""" + + # General errors (1xxx) + INTERNAL_ERROR = "GIT_1000" + INVALID_REQUEST = "GIT_1001" + NOT_FOUND = "GIT_1002" + PERMISSION_DENIED = "GIT_1003" + TIMEOUT = "GIT_1004" + RATE_LIMITED = "GIT_1005" + + # Workspace errors (2xxx) + WORKSPACE_NOT_FOUND = "GIT_2000" + WORKSPACE_LOCKED = "GIT_2001" + WORKSPACE_SIZE_EXCEEDED = "GIT_2002" + WORKSPACE_CREATE_FAILED = "GIT_2003" + WORKSPACE_DELETE_FAILED = "GIT_2004" + + # Git operation errors (3xxx) + CLONE_FAILED = "GIT_3000" + CHECKOUT_FAILED = "GIT_3001" + COMMIT_FAILED = "GIT_3002" + PUSH_FAILED = "GIT_3003" + PULL_FAILED = "GIT_3004" + MERGE_CONFLICT = "GIT_3005" + BRANCH_EXISTS = "GIT_3006" + BRANCH_NOT_FOUND = "GIT_3007" + INVALID_REF = "GIT_3008" + DIRTY_WORKSPACE = "GIT_3009" + UNCOMMITTED_CHANGES = "GIT_3010" + FETCH_FAILED = "GIT_3011" + RESET_FAILED = "GIT_3012" + + # Provider errors (4xxx) + PROVIDER_ERROR = "GIT_4000" + PROVIDER_AUTH_FAILED = "GIT_4001" + PROVIDER_NOT_FOUND = "GIT_4002" + PR_CREATE_FAILED = "GIT_4003" + PR_MERGE_FAILED = "GIT_4004" + PR_NOT_FOUND = "GIT_4005" + API_ERROR = "GIT_4006" + + # Credential errors (5xxx) + CREDENTIAL_ERROR = "GIT_5000" + CREDENTIAL_NOT_FOUND = "GIT_5001" + CREDENTIAL_INVALID = "GIT_5002" + SSH_KEY_ERROR = "GIT_5003" + + +class GitOpsError(Exception): + """Base exception for Git Operations errors.""" + + def __init__( + self, + message: str, + code: ErrorCode = ErrorCode.INTERNAL_ERROR, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.message = message + self.code = code + self.details = details or {} + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for MCP response.""" + result = { + "error": self.message, + "code": self.code.value, + } + if self.details: + result["details"] = self.details + return result + + +# Workspace Errors + + +class WorkspaceError(GitOpsError): + """Base exception for workspace-related errors.""" + + def __init__( + self, + message: str, + code: ErrorCode = ErrorCode.WORKSPACE_NOT_FOUND, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message, code, details) + + +class WorkspaceNotFoundError(WorkspaceError): + """Workspace does not exist.""" + + def __init__(self, project_id: str) -> None: + super().__init__( + f"Workspace not found for project: {project_id}", + ErrorCode.WORKSPACE_NOT_FOUND, + {"project_id": project_id}, + ) + + +class WorkspaceLockedError(WorkspaceError): + """Workspace is locked by another operation.""" + + def __init__(self, project_id: str, holder: str | None = None) -> None: + details: dict[str, Any] = {"project_id": project_id} + if holder: + details["locked_by"] = holder + super().__init__( + f"Workspace is locked for project: {project_id}", + ErrorCode.WORKSPACE_LOCKED, + details, + ) + + +class WorkspaceSizeExceededError(WorkspaceError): + """Workspace size limit exceeded.""" + + def __init__(self, project_id: str, current_size: float, max_size: float) -> None: + super().__init__( + f"Workspace size limit exceeded for project: {project_id}", + ErrorCode.WORKSPACE_SIZE_EXCEEDED, + { + "project_id": project_id, + "current_size_gb": current_size, + "max_size_gb": max_size, + }, + ) + + +# Git Operation Errors + + +class GitError(GitOpsError): + """Base exception for git operation errors.""" + + def __init__( + self, + message: str, + code: ErrorCode = ErrorCode.INTERNAL_ERROR, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message, code, details) + + +class CloneError(GitError): + """Failed to clone repository.""" + + def __init__(self, repo_url: str, reason: str) -> None: + super().__init__( + f"Failed to clone repository: {reason}", + ErrorCode.CLONE_FAILED, + {"repo_url": repo_url, "reason": reason}, + ) + + +class CheckoutError(GitError): + """Failed to checkout branch or ref.""" + + def __init__(self, ref: str, reason: str) -> None: + super().__init__( + f"Failed to checkout '{ref}': {reason}", + ErrorCode.CHECKOUT_FAILED, + {"ref": ref, "reason": reason}, + ) + + +class CommitError(GitError): + """Failed to commit changes.""" + + def __init__(self, reason: str) -> None: + super().__init__( + f"Failed to commit: {reason}", + ErrorCode.COMMIT_FAILED, + {"reason": reason}, + ) + + +class PushError(GitError): + """Failed to push to remote.""" + + def __init__(self, branch: str, reason: str) -> None: + super().__init__( + f"Failed to push branch '{branch}': {reason}", + ErrorCode.PUSH_FAILED, + {"branch": branch, "reason": reason}, + ) + + +class PullError(GitError): + """Failed to pull from remote.""" + + def __init__(self, branch: str, reason: str) -> None: + super().__init__( + f"Failed to pull branch '{branch}': {reason}", + ErrorCode.PULL_FAILED, + {"branch": branch, "reason": reason}, + ) + + +class MergeConflictError(GitError): + """Merge conflict detected.""" + + def __init__(self, conflicting_files: list[str]) -> None: + super().__init__( + f"Merge conflict detected in {len(conflicting_files)} files", + ErrorCode.MERGE_CONFLICT, + {"conflicting_files": conflicting_files}, + ) + + +class BranchExistsError(GitError): + """Branch already exists.""" + + def __init__(self, branch_name: str) -> None: + super().__init__( + f"Branch already exists: {branch_name}", + ErrorCode.BRANCH_EXISTS, + {"branch": branch_name}, + ) + + +class BranchNotFoundError(GitError): + """Branch does not exist.""" + + def __init__(self, branch_name: str) -> None: + super().__init__( + f"Branch not found: {branch_name}", + ErrorCode.BRANCH_NOT_FOUND, + {"branch": branch_name}, + ) + + +class InvalidRefError(GitError): + """Invalid git reference.""" + + def __init__(self, ref: str) -> None: + super().__init__( + f"Invalid git reference: {ref}", + ErrorCode.INVALID_REF, + {"ref": ref}, + ) + + +class DirtyWorkspaceError(GitError): + """Workspace has uncommitted changes.""" + + def __init__(self, modified_files: list[str]) -> None: + super().__init__( + f"Workspace has {len(modified_files)} uncommitted changes", + ErrorCode.DIRTY_WORKSPACE, + {"modified_files": modified_files[:10]}, # Limit to first 10 + ) + + +# Provider Errors + + +class ProviderError(GitOpsError): + """Base exception for provider-related errors.""" + + def __init__( + self, + message: str, + code: ErrorCode = ErrorCode.PROVIDER_ERROR, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message, code, details) + + +class AuthenticationError(ProviderError): + """Authentication with provider failed.""" + + def __init__(self, provider: str, reason: str) -> None: + super().__init__( + f"Authentication failed with {provider}: {reason}", + ErrorCode.PROVIDER_AUTH_FAILED, + {"provider": provider, "reason": reason}, + ) + + +class ProviderNotFoundError(ProviderError): + """Provider not configured or recognized.""" + + def __init__(self, provider: str) -> None: + super().__init__( + f"Provider not found or not configured: {provider}", + ErrorCode.PROVIDER_NOT_FOUND, + {"provider": provider}, + ) + + +class PRError(ProviderError): + """Pull request operation failed.""" + + def __init__( + self, + message: str, + code: ErrorCode = ErrorCode.PR_CREATE_FAILED, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message, code, details) + + +class PRNotFoundError(PRError): + """Pull request not found.""" + + def __init__(self, pr_number: int, repo: str) -> None: + super().__init__( + f"Pull request #{pr_number} not found in {repo}", + ErrorCode.PR_NOT_FOUND, + {"pr_number": pr_number, "repo": repo}, + ) + + +class APIError(ProviderError): + """Provider API error.""" + + def __init__( + self, provider: str, status_code: int, message: str + ) -> None: + super().__init__( + f"{provider} API error ({status_code}): {message}", + ErrorCode.API_ERROR, + {"provider": provider, "status_code": status_code, "message": message}, + ) + + +# Credential Errors + + +class CredentialError(GitOpsError): + """Base exception for credential-related errors.""" + + def __init__( + self, + message: str, + code: ErrorCode = ErrorCode.CREDENTIAL_ERROR, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message, code, details) + + +class CredentialNotFoundError(CredentialError): + """Credential not found.""" + + def __init__(self, credential_type: str, identifier: str) -> None: + super().__init__( + f"{credential_type} credential not found: {identifier}", + ErrorCode.CREDENTIAL_NOT_FOUND, + {"type": credential_type, "identifier": identifier}, + ) diff --git a/mcp-servers/git-ops/git_wrapper.py b/mcp-servers/git-ops/git_wrapper.py new file mode 100644 index 0000000..c1a164f --- /dev/null +++ b/mcp-servers/git-ops/git_wrapper.py @@ -0,0 +1,1112 @@ +""" +Git operations wrapper using GitPython. + +Provides high-level git operations with proper error handling, +async compatibility, and structured results. +""" + +import asyncio +import logging +import os +import re +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime +from functools import partial +from pathlib import Path +from typing import Any + +from git import GitCommandError, InvalidGitRepositoryError, NoSuchPathError +from git import Repo as GitRepo + +from config import Settings, get_settings +from exceptions import ( + BranchExistsError, + BranchNotFoundError, + CheckoutError, + CloneError, + CommitError, + DirtyWorkspaceError, + GitError, + MergeConflictError, + PullError, + PushError, +) +from models import ( + BranchInfo, + BranchResult, + CheckoutResult, + CloneResult, + CommitInfo, + CommitResult, + DiffHunk, + DiffResult, + FileChange, + FileChangeType, + FileDiff, + ListBranchesResult, + LogResult, + PullResult, + PushResult, + StatusResult, +) + +logger = logging.getLogger(__name__) + +# Thread pool for blocking git operations +_executor: ThreadPoolExecutor | None = None + + +def get_executor() -> ThreadPoolExecutor: + """Get the shared thread pool executor.""" + global _executor + if _executor is None: + _executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="git-ops-") + return _executor + + +async def run_in_executor(func: Any, *args: Any, **kwargs: Any) -> Any: + """Run a blocking function in the thread pool.""" + loop = asyncio.get_event_loop() + executor = get_executor() + partial_func = partial(func, *args, **kwargs) + return await loop.run_in_executor(executor, partial_func) + + +class GitWrapper: + """ + Wrapper for git operations using GitPython. + + Provides async-compatible git operations with proper error handling. + """ + + def __init__( + self, + workspace_path: Path, + settings: Settings | None = None, + ) -> None: + """ + Initialize GitWrapper. + + Args: + workspace_path: Path to the git workspace + settings: Optional settings override + """ + self.workspace_path = workspace_path + self.settings = settings or get_settings() + self._repo: GitRepo | None = None + + @property + def repo(self) -> GitRepo: + """Get the GitPython Repo instance.""" + if self._repo is None: + try: + self._repo = GitRepo(self.workspace_path) + except InvalidGitRepositoryError: + raise GitError(f"Not a git repository: {self.workspace_path}") + except NoSuchPathError: + raise GitError(f"Path does not exist: {self.workspace_path}") + return self._repo + + def _refresh_repo(self) -> None: + """Refresh the repo instance after operations that change it.""" + self._repo = None + + # Clone operations + + async def clone( + self, + repo_url: str, + branch: str | None = None, + depth: int | None = None, + auth_token: str | None = None, + ) -> CloneResult: + """ + Clone a repository. + + Args: + repo_url: Repository URL to clone + branch: Branch to checkout after clone + depth: Shallow clone depth (None for full) + auth_token: Optional auth token for HTTPS + + Returns: + CloneResult with clone status + """ + + def _do_clone() -> CloneResult: + try: + # Build clone URL with auth if provided + clone_url = repo_url + if auth_token and repo_url.startswith("https://"): + # Insert token in URL: https://token@host/path + clone_url = re.sub( + r"^(https://)(.+)$", + rf"\1{auth_token}@\2", + repo_url, + ) + + # Build clone arguments + kwargs: dict[str, Any] = { + "url": clone_url, + "to_path": str(self.workspace_path), + } + + if branch: + kwargs["branch"] = branch + + if depth: + kwargs["depth"] = depth + + # Set environment for auth + env = os.environ.copy() + env["GIT_TERMINAL_PROMPT"] = "0" + + logger.info(f"Cloning repository: {repo_url} -> {self.workspace_path}") + + repo = GitRepo.clone_from(**kwargs) + self._repo = repo + + return CloneResult( + success=True, + project_id="", # Set by caller + workspace_path=str(self.workspace_path), + branch=repo.active_branch.name, + commit_sha=repo.head.commit.hexsha, + ) + + except GitCommandError as e: + logger.error(f"Clone failed: {e}") + raise CloneError(repo_url, str(e)) + + return await run_in_executor(_do_clone) + + # Status operations + + async def status(self, include_untracked: bool = True) -> StatusResult: + """ + Get git status. + + Args: + include_untracked: Include untracked files + + Returns: + StatusResult with working tree status + """ + + def _get_status() -> StatusResult: + repo = self.repo + + # Get staged changes + staged = [] + for diff in repo.index.diff("HEAD"): + change_type = self._diff_to_change_type(diff.change_type) + staged.append( + FileChange( + path=diff.b_path or diff.a_path, + change_type=change_type, + old_path=diff.a_path if diff.renamed else None, + ).to_dict() + ) + + # Get unstaged changes + unstaged = [] + for diff in repo.index.diff(None): + change_type = self._diff_to_change_type(diff.change_type) + unstaged.append( + FileChange( + path=diff.b_path or diff.a_path, + change_type=change_type, + ).to_dict() + ) + + # Get untracked files + untracked = list(repo.untracked_files) if include_untracked else [] + + # Get tracking info + ahead = behind = 0 + try: + tracking = repo.active_branch.tracking_branch() + if tracking: + ahead = len( + list(repo.iter_commits(f"{tracking.name}..{repo.active_branch.name}")) + ) + behind = len( + list(repo.iter_commits(f"{repo.active_branch.name}..{tracking.name}")) + ) + except Exception: + pass # No tracking branch + + is_clean = len(staged) == 0 and len(unstaged) == 0 and len(untracked) == 0 + + return StatusResult( + project_id="", # Set by caller + branch=repo.active_branch.name, + commit_sha=repo.head.commit.hexsha, + is_clean=is_clean, + staged=staged, + unstaged=unstaged, + untracked=untracked, + ahead=ahead, + behind=behind, + ) + + return await run_in_executor(_get_status) + + # Branch operations + + async def create_branch( + self, + branch_name: str, + from_ref: str | None = None, + checkout: bool = True, + ) -> BranchResult: + """ + Create a new branch. + + Args: + branch_name: Name for the new branch + from_ref: Reference to create from (default: HEAD) + checkout: Whether to checkout after creation + + Returns: + BranchResult with creation status + """ + + def _create_branch() -> BranchResult: + repo = self.repo + + # Check if branch already exists + if branch_name in [b.name for b in repo.branches]: + raise BranchExistsError(branch_name) + + try: + # Get the starting point + if from_ref: + start_point = repo.commit(from_ref) + else: + start_point = repo.head.commit + + # Create branch + new_branch = repo.create_head(branch_name, start_point) + + # Checkout if requested + if checkout: + new_branch.checkout() + + return BranchResult( + success=True, + branch=branch_name, + commit_sha=new_branch.commit.hexsha, + is_current=checkout, + ) + + except GitCommandError as e: + logger.error(f"Failed to create branch {branch_name}: {e}") + raise GitError(f"Failed to create branch: {e}") + + return await run_in_executor(_create_branch) + + async def delete_branch( + self, + branch_name: str, + force: bool = False, + ) -> BranchResult: + """ + Delete a branch. + + Args: + branch_name: Branch to delete + force: Force delete even if not merged + + Returns: + BranchResult with deletion status + """ + + def _delete_branch() -> BranchResult: + repo = self.repo + + if branch_name not in [b.name for b in repo.branches]: + raise BranchNotFoundError(branch_name) + + if repo.active_branch.name == branch_name: + raise GitError(f"Cannot delete current branch: {branch_name}") + + try: + repo.delete_head(branch_name, force=force) + return BranchResult( + success=True, + branch=branch_name, + is_current=False, + ) + except GitCommandError as e: + logger.error(f"Failed to delete branch {branch_name}: {e}") + raise GitError(f"Failed to delete branch: {e}") + + return await run_in_executor(_delete_branch) + + async def list_branches(self, include_remote: bool = False) -> ListBranchesResult: + """ + List branches. + + Args: + include_remote: Include remote tracking branches + + Returns: + ListBranchesResult with branch lists + """ + + def _list_branches() -> ListBranchesResult: + repo = self.repo + + local_branches = [] + for branch in repo.branches: + tracking = branch.tracking_branch() + local_branches.append( + BranchInfo( + name=branch.name, + is_current=branch == repo.active_branch, + is_remote=False, + tracking_branch=tracking.name if tracking else None, + commit_sha=branch.commit.hexsha, + commit_message=branch.commit.message.split("\n")[0], + ).to_dict() + ) + + remote_branches = [] + if include_remote: + for remote in repo.remotes: + for ref in remote.refs: + # Skip HEAD refs + if ref.name.endswith("/HEAD"): + continue + remote_branches.append( + BranchInfo( + name=ref.name, + is_current=False, + is_remote=True, + commit_sha=ref.commit.hexsha, + commit_message=ref.commit.message.split("\n")[0], + ).to_dict() + ) + + return ListBranchesResult( + project_id="", # Set by caller + current_branch=repo.active_branch.name, + local_branches=local_branches, + remote_branches=remote_branches, + ) + + return await run_in_executor(_list_branches) + + async def checkout( + self, + ref: str, + create_branch: bool = False, + force: bool = False, + ) -> CheckoutResult: + """ + Checkout a branch or ref. + + Args: + ref: Branch, tag, or commit to checkout + create_branch: Create new branch with this name + force: Force checkout (discard local changes) + + Returns: + CheckoutResult with checkout status + """ + + def _checkout() -> CheckoutResult: + repo = self.repo + + try: + if create_branch: + # Create and checkout new branch + if ref in [b.name for b in repo.branches]: + raise BranchExistsError(ref) + new_branch = repo.create_head(ref) + new_branch.checkout(force=force) + else: + # Checkout existing ref + if ref in [b.name for b in repo.branches]: + # Local branch + repo.heads[ref].checkout(force=force) + else: + # Try as a commit/tag + repo.git.checkout(ref, force=force) + + return CheckoutResult( + success=True, + ref=ref, + commit_sha=repo.head.commit.hexsha, + ) + + except GitCommandError as e: + error_msg = str(e) + if "would be overwritten" in error_msg: + raise DirtyWorkspaceError([]) + raise CheckoutError(ref, error_msg) + + return await run_in_executor(_checkout) + + # Commit operations + + async def commit( + self, + message: str, + files: list[str] | None = None, + author_name: str | None = None, + author_email: str | None = None, + allow_empty: bool = False, + ) -> CommitResult: + """ + Create a commit. + + Args: + message: Commit message + files: Specific files to commit (None = all staged) + author_name: Author name override + author_email: Author email override + allow_empty: Allow empty commits + + Returns: + CommitResult with commit info + """ + + def _commit() -> CommitResult: + repo = self.repo + + try: + # Stage files if specified + if files: + repo.index.add(files) + elif not allow_empty: + # Stage all modified/deleted + repo.git.add("-A") + + # Check if there's anything to commit + if not allow_empty and not repo.index.diff("HEAD") and not repo.untracked_files: + raise CommitError("Nothing to commit") + + # Build author + author = None + if author_name and author_email: + from git import Actor + + author = Actor(author_name, author_email) + elif author_name or author_email: + from git import Actor + + author = Actor( + author_name or self.settings.git_author_name, + author_email or self.settings.git_author_email, + ) + + # Create commit + commit = repo.index.commit( + message, + author=author, + committer=author, + ) + + # Get stats + stats = commit.stats.total + files_changed = stats.get("files", 0) + insertions = stats.get("insertions", 0) + deletions = stats.get("deletions", 0) + + return CommitResult( + success=True, + commit_sha=commit.hexsha, + short_sha=commit.hexsha[:7], + message=message, + files_changed=files_changed, + insertions=insertions, + deletions=deletions, + ) + + except GitCommandError as e: + logger.error(f"Commit failed: {e}") + raise CommitError(str(e)) + + return await run_in_executor(_commit) + + async def stage(self, files: list[str] | None = None) -> int: + """ + Stage files for commit. + + Args: + files: Files to stage (None = all) + + Returns: + Number of files staged + """ + + def _stage() -> int: + repo = self.repo + if files: + repo.index.add(files) + return len(files) + else: + repo.git.add("-A") + return len(repo.index.diff("HEAD")) + len(repo.untracked_files) + + return await run_in_executor(_stage) + + async def unstage(self, files: list[str] | None = None) -> int: + """ + Unstage files. + + Args: + files: Files to unstage (None = all) + + Returns: + Number of files unstaged + """ + + def _unstage() -> int: + repo = self.repo + staged = list(repo.index.diff("HEAD")) + + if files: + repo.index.remove(files, working_tree=False) + return len(files) + else: + # Unstage all + if staged: + repo.git.reset("HEAD") + return len(staged) + + return await run_in_executor(_unstage) + + # Push/Pull operations + + async def push( + self, + branch: str | None = None, + remote: str = "origin", + force: bool = False, + set_upstream: bool = True, + auth_token: str | None = None, + ) -> PushResult: + """ + Push to remote. + + Args: + branch: Branch to push (None = current) + remote: Remote name + force: Force push + set_upstream: Set upstream tracking + auth_token: Auth token for HTTPS + + Returns: + PushResult with push status + """ + + def _push() -> PushResult: + repo = self.repo + push_branch = branch or repo.active_branch.name + + # Check force push policy + if force and not self.settings.enable_force_push: + raise PushError(push_branch, "Force push is disabled") + + try: + # Build push info + push_info_list = [] + + if remote not in [r.name for r in repo.remotes]: + raise PushError(push_branch, f"Remote not found: {remote}") + + remote_obj = repo.remote(remote) + + # Configure auth if provided + if auth_token: + # Set credential helper temporarily + pass # TODO: Implement token-based auth + + # Build refspec + refspec = f"{push_branch}:{push_branch}" + if set_upstream: + push_info_list = remote_obj.push( + refspec=refspec, + force=force, + set_upstream=True, + ) + else: + push_info_list = remote_obj.push( + refspec=refspec, + force=force, + ) + + # Check for errors + for info in push_info_list: + if info.flags & info.ERROR: + raise PushError(push_branch, info.summary) + + # Count commits pushed (approximate) + commits_pushed = 0 + try: + tracking = repo.active_branch.tracking_branch() + if tracking: + commits_pushed = len( + list(repo.iter_commits(f"{tracking.name}..{push_branch}")) + ) + except Exception: + pass + + return PushResult( + success=True, + branch=push_branch, + remote=remote, + commits_pushed=commits_pushed, + ) + + except GitCommandError as e: + error_msg = str(e) + if "rejected" in error_msg: + raise PushError( + push_branch, + "Push rejected - pull and merge first or force push", + ) + raise PushError(push_branch, error_msg) + + return await run_in_executor(_push) + + async def pull( + self, + branch: str | None = None, + remote: str = "origin", + rebase: bool = False, + auth_token: str | None = None, + ) -> PullResult: + """ + Pull from remote. + + Args: + branch: Branch to pull (None = current) + remote: Remote name + rebase: Rebase instead of merge + auth_token: Auth token for HTTPS + + Returns: + PullResult with pull status + """ + + def _pull() -> PullResult: + repo = self.repo + pull_branch = branch or repo.active_branch.name + + try: + if remote not in [r.name for r in repo.remotes]: + raise PullError(pull_branch, f"Remote not found: {remote}") + + remote_obj = repo.remote(remote) + + # Fetch first to check for conflicts + remote_obj.fetch() + + # Get commits before pull + head_before = repo.head.commit.hexsha + + # Perform pull + if rebase: + repo.git.pull("--rebase", remote, pull_branch) + else: + repo.git.pull(remote, pull_branch) + + # Count new commits + commits_received = len( + list(repo.iter_commits(f"{head_before}..HEAD")) + ) + + # Check if fast-forward + fast_forward = commits_received > 0 and not repo.head.commit.parents + + return PullResult( + success=True, + branch=pull_branch, + commits_received=commits_received, + fast_forward=fast_forward, + ) + + except GitCommandError as e: + error_msg = str(e) + if "conflict" in error_msg.lower(): + # Get conflicting files + conflicts = [ + item.a_path + for item in repo.index.unmerged_blobs().keys() + ] + raise MergeConflictError(conflicts) + raise PullError(pull_branch, error_msg) + + return await run_in_executor(_pull) + + async def fetch( + self, + remote: str = "origin", + prune: bool = False, + ) -> bool: + """ + Fetch from remote. + + Args: + remote: Remote name + prune: Prune deleted remote branches + + Returns: + True if successful + """ + + def _fetch() -> bool: + repo = self.repo + try: + if remote not in [r.name for r in repo.remotes]: + raise GitError(f"Remote not found: {remote}") + + remote_obj = repo.remote(remote) + remote_obj.fetch(prune=prune) + return True + except GitCommandError as e: + logger.error(f"Fetch failed: {e}") + raise GitError(f"Fetch failed: {e}") + + return await run_in_executor(_fetch) + + # Diff operations + + async def diff( + self, + base: str | None = None, + head: str | None = None, + files: list[str] | None = None, + context_lines: int = 3, + ) -> DiffResult: + """ + Get diff between refs. + + Args: + base: Base reference (None = working tree) + head: Head reference (None = HEAD) + files: Specific files to diff + context_lines: Context lines to include + + Returns: + DiffResult with diff info + """ + + def _diff() -> DiffResult: + repo = self.repo + file_diffs = [] + total_additions = 0 + total_deletions = 0 + + try: + # Determine what to diff + if base is None and head is None: + # Working tree vs staged + diffs = repo.index.diff(None, create_patch=True) + elif base is None: + # Working tree vs specified ref + diffs = repo.commit(head).diff(None, create_patch=True) + elif head is None: + # Specified ref vs HEAD + diffs = repo.commit(base).diff("HEAD", create_patch=True) + else: + # Between two refs + diffs = repo.commit(base).diff(head, create_patch=True) + + for diff in diffs: + # Filter by files if specified + if files and diff.a_path not in files and diff.b_path not in files: + continue + + change_type = self._diff_to_change_type(diff.change_type) + path = diff.b_path or diff.a_path + + # Parse hunks from patch + hunks = [] + additions = 0 + deletions = 0 + + if diff.diff: + patch_text = diff.diff.decode("utf-8", errors="replace") + # Parse hunks (simplified) + for line in patch_text.split("\n"): + if line.startswith("+") and not line.startswith("+++"): + additions += 1 + elif line.startswith("-") and not line.startswith("---"): + deletions += 1 + + # Add as single hunk for now + hunks.append( + DiffHunk( + old_start=1, + old_lines=deletions, + new_start=1, + new_lines=additions, + content=patch_text[: self.settings.git_max_diff_lines], + ) + ) + + file_diffs.append( + FileDiff( + path=path, + change_type=change_type, + old_path=diff.a_path if diff.renamed else None, + hunks=hunks, + additions=additions, + deletions=deletions, + is_binary=diff.diff is None and not diff.deleted_file, + ).to_dict() + ) + + total_additions += additions + total_deletions += deletions + + return DiffResult( + project_id="", # Set by caller + base=base, + head=head, + files=file_diffs, + total_additions=total_additions, + total_deletions=total_deletions, + files_changed=len(file_diffs), + ) + + except GitCommandError as e: + raise GitError(f"Diff failed: {e}") + + return await run_in_executor(_diff) + + # Log operations + + async def log( + self, + ref: str | None = None, + limit: int = 20, + skip: int = 0, + path: str | None = None, + ) -> LogResult: + """ + Get commit log. + + Args: + ref: Reference to start from + limit: Max commits to return + skip: Commits to skip + path: Filter by path + + Returns: + LogResult with commit history + """ + + def _log() -> LogResult: + repo = self.repo + commits = [] + + try: + kwargs: dict[str, Any] = { + "max_count": limit, + "skip": skip, + } + + if path: + kwargs["paths"] = path + + if ref: + iterator = repo.iter_commits(ref, **kwargs) + else: + iterator = repo.iter_commits(**kwargs) + + for commit in iterator: + commits.append( + CommitInfo( + sha=commit.hexsha, + short_sha=commit.hexsha[:7], + message=commit.message, + author_name=commit.author.name, + author_email=commit.author.email, + authored_date=datetime.fromtimestamp( + commit.authored_date, tz=UTC + ), + committer_name=commit.committer.name, + committer_email=commit.committer.email, + committed_date=datetime.fromtimestamp( + commit.committed_date, tz=UTC + ), + parents=[p.hexsha for p in commit.parents], + ).to_dict() + ) + + return LogResult( + project_id="", # Set by caller + commits=commits, + total_commits=len(commits), + ) + + except GitCommandError as e: + raise GitError(f"Log failed: {e}") + + return await run_in_executor(_log) + + # Reset operations + + async def reset( + self, + ref: str = "HEAD", + mode: str = "mixed", + files: list[str] | None = None, + ) -> bool: + """ + Reset to a ref. + + Args: + ref: Reference to reset to + mode: Reset mode (soft, mixed, hard) + files: Specific files to reset + + Returns: + True if successful + """ + + def _reset() -> bool: + repo = self.repo + try: + if files: + # Reset specific files + repo.index.reset(commit=ref, paths=files) + else: + # Full reset + if mode == "soft": + repo.head.reset(ref, index=False, working_tree=False) + elif mode == "mixed": + repo.head.reset(ref, index=True, working_tree=False) + elif mode == "hard": + repo.head.reset(ref, index=True, working_tree=True) + else: + raise GitError(f"Invalid reset mode: {mode}") + + return True + except GitCommandError as e: + raise GitError(f"Reset failed: {e}") + + return await run_in_executor(_reset) + + # Stash operations + + async def stash(self, message: str | None = None) -> str | None: + """ + Stash changes. + + Args: + message: Optional stash message + + Returns: + Stash reference or None if nothing to stash + """ + + def _stash() -> str | None: + repo = self.repo + try: + if message: + result = repo.git.stash("push", "-m", message) + else: + result = repo.git.stash("push") + + if "No local changes to save" in result: + return None + + return repo.git.stash("list").split("\n")[0].split(":")[0] + except GitCommandError as e: + raise GitError(f"Stash failed: {e}") + + return await run_in_executor(_stash) + + async def stash_pop(self, stash_ref: str | None = None) -> bool: + """ + Pop stashed changes. + + Args: + stash_ref: Specific stash to pop + + Returns: + True if successful + """ + + def _stash_pop() -> bool: + repo = self.repo + try: + if stash_ref: + repo.git.stash("pop", stash_ref) + else: + repo.git.stash("pop") + return True + except GitCommandError as e: + if "conflict" in str(e).lower(): + raise MergeConflictError([]) + raise GitError(f"Stash pop failed: {e}") + + return await run_in_executor(_stash_pop) + + # Utility methods + + def _diff_to_change_type(self, change_type: str) -> FileChangeType: + """Convert GitPython change type to our enum.""" + mapping = { + "A": FileChangeType.ADDED, + "M": FileChangeType.MODIFIED, + "D": FileChangeType.DELETED, + "R": FileChangeType.RENAMED, + "C": FileChangeType.COPIED, + } + return mapping.get(change_type, FileChangeType.MODIFIED) + + async def is_valid_ref(self, ref: str) -> bool: + """Check if a reference is valid.""" + + def _check() -> bool: + try: + self.repo.commit(ref) + return True + except Exception: + return False + + return await run_in_executor(_check) + + async def get_remote_url(self, remote: str = "origin") -> str | None: + """Get the URL for a remote.""" + + def _get_url() -> str | None: + repo = self.repo + if remote in [r.name for r in repo.remotes]: + return repo.remote(remote).url + return None + + return await run_in_executor(_get_url) + + async def set_config(self, key: str, value: str, global_: bool = False) -> None: + """Set a git config value.""" + + def _set_config() -> None: + repo = self.repo + with repo.config_writer("global" if global_ else "repository") as cw: + section, option = key.rsplit(".", 1) + cw.set_value(section, option, value) + + await run_in_executor(_set_config) + + async def get_config(self, key: str) -> str | None: + """Get a git config value.""" + + def _get_config() -> str | None: + repo = self.repo + try: + cr = repo.config_reader() + section, option = key.rsplit(".", 1) + return cr.get_value(section, option) + except Exception: + return None + + return await run_in_executor(_get_config) diff --git a/mcp-servers/git-ops/models.py b/mcp-servers/git-ops/models.py new file mode 100644 index 0000000..5b8bbbe --- /dev/null +++ b/mcp-servers/git-ops/models.py @@ -0,0 +1,678 @@ +""" +Data models for Git Operations MCP Server. + +Defines data structures for git operations, workspace management, +and provider interactions. +""" + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field + + +class FileChangeType(str, Enum): + """Types of file changes in git.""" + + ADDED = "added" + MODIFIED = "modified" + DELETED = "deleted" + RENAMED = "renamed" + COPIED = "copied" + UNTRACKED = "untracked" + IGNORED = "ignored" + + +class MergeStrategy(str, Enum): + """Merge strategies for pull requests.""" + + MERGE = "merge" # Create a merge commit + SQUASH = "squash" # Squash and merge + REBASE = "rebase" # Rebase and merge + + +class PRState(str, Enum): + """Pull request states.""" + + OPEN = "open" + CLOSED = "closed" + MERGED = "merged" + + +class ProviderType(str, Enum): + """Supported git providers.""" + + GITEA = "gitea" + GITHUB = "github" + GITLAB = "gitlab" + + +class WorkspaceState(str, Enum): + """Workspace lifecycle states.""" + + INITIALIZING = "initializing" + READY = "ready" + LOCKED = "locked" + STALE = "stale" + DELETED = "deleted" + + +# Dataclasses for internal data structures + + +@dataclass +class FileChange: + """A file change in git status.""" + + path: str + change_type: FileChangeType + old_path: str | None = None # For renames + additions: int = 0 + deletions: int = 0 + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary.""" + return { + "path": self.path, + "change_type": self.change_type.value, + "old_path": self.old_path, + "additions": self.additions, + "deletions": self.deletions, + } + + +@dataclass +class BranchInfo: + """Information about a git branch.""" + + name: str + is_current: bool = False + is_remote: bool = False + tracking_branch: str | None = None + commit_sha: str | None = None + commit_message: str | None = None + ahead: int = 0 + behind: int = 0 + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary.""" + return { + "name": self.name, + "is_current": self.is_current, + "is_remote": self.is_remote, + "tracking_branch": self.tracking_branch, + "commit_sha": self.commit_sha, + "commit_message": self.commit_message, + "ahead": self.ahead, + "behind": self.behind, + } + + +@dataclass +class CommitInfo: + """Information about a git commit.""" + + sha: str + short_sha: str + message: str + author_name: str + author_email: str + authored_date: datetime + committer_name: str + committer_email: str + committed_date: datetime + parents: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary.""" + return { + "sha": self.sha, + "short_sha": self.short_sha, + "message": self.message, + "author_name": self.author_name, + "author_email": self.author_email, + "authored_date": self.authored_date.isoformat(), + "committer_name": self.committer_name, + "committer_email": self.committer_email, + "committed_date": self.committed_date.isoformat(), + "parents": self.parents, + } + + +@dataclass +class DiffHunk: + """A hunk of diff content.""" + + old_start: int + old_lines: int + new_start: int + new_lines: int + content: str + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary.""" + return { + "old_start": self.old_start, + "old_lines": self.old_lines, + "new_start": self.new_start, + "new_lines": self.new_lines, + "content": self.content, + } + + +@dataclass +class FileDiff: + """Diff for a single file.""" + + path: str + change_type: FileChangeType + old_path: str | None = None + hunks: list[DiffHunk] = field(default_factory=list) + additions: int = 0 + deletions: int = 0 + is_binary: bool = False + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary.""" + return { + "path": self.path, + "change_type": self.change_type.value, + "old_path": self.old_path, + "hunks": [h.to_dict() for h in self.hunks], + "additions": self.additions, + "deletions": self.deletions, + "is_binary": self.is_binary, + } + + +@dataclass +class PRInfo: + """Information about a pull request.""" + + number: int + title: str + body: str + state: PRState + source_branch: str + target_branch: str + author: str + created_at: datetime + updated_at: datetime + merged_at: datetime | None = None + closed_at: datetime | None = None + url: str | None = None + labels: list[str] = field(default_factory=list) + assignees: list[str] = field(default_factory=list) + reviewers: list[str] = field(default_factory=list) + mergeable: bool | None = None + draft: bool = False + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary.""" + return { + "number": self.number, + "title": self.title, + "body": self.body, + "state": self.state.value, + "source_branch": self.source_branch, + "target_branch": self.target_branch, + "author": self.author, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + "merged_at": self.merged_at.isoformat() if self.merged_at else None, + "closed_at": self.closed_at.isoformat() if self.closed_at else None, + "url": self.url, + "labels": self.labels, + "assignees": self.assignees, + "reviewers": self.reviewers, + "mergeable": self.mergeable, + "draft": self.draft, + } + + +@dataclass +class WorkspaceInfo: + """Information about a project workspace.""" + + project_id: str + path: str + state: WorkspaceState + repo_url: str | None = None + current_branch: str | None = None + last_accessed: datetime = field(default_factory=lambda: datetime.now(UTC)) + size_bytes: int = 0 + lock_holder: str | None = None + lock_expires: datetime | None = None + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary.""" + return { + "project_id": self.project_id, + "path": self.path, + "state": self.state.value, + "repo_url": self.repo_url, + "current_branch": self.current_branch, + "last_accessed": self.last_accessed.isoformat(), + "size_bytes": self.size_bytes, + "lock_holder": self.lock_holder, + "lock_expires": self.lock_expires.isoformat() if self.lock_expires else None, + } + + +# Pydantic Request/Response Models + + +class CloneRequest(BaseModel): + """Request to clone a repository.""" + + project_id: str = Field(..., description="Project ID for scoping") + agent_id: str = Field(..., description="Agent ID making the request") + repo_url: str = Field(..., description="Repository URL to clone") + branch: str | None = Field(default=None, description="Branch to checkout after clone") + depth: int | None = Field( + default=None, ge=1, description="Shallow clone depth (None = full clone)" + ) + + +class CloneResult(BaseModel): + """Result of a clone operation.""" + + success: bool = Field(..., description="Whether clone succeeded") + project_id: str = Field(..., description="Project ID") + workspace_path: str = Field(..., description="Path to cloned workspace") + branch: str = Field(..., description="Current branch after clone") + commit_sha: str = Field(..., description="HEAD commit SHA") + error: str | None = Field(default=None, description="Error message if failed") + + +class StatusRequest(BaseModel): + """Request for git status.""" + + project_id: str = Field(..., description="Project ID for scoping") + agent_id: str = Field(..., description="Agent ID making the request") + include_untracked: bool = Field(default=True, description="Include untracked files") + + +class StatusResult(BaseModel): + """Result of a status operation.""" + + project_id: str = Field(..., description="Project ID") + branch: str = Field(..., description="Current branch") + commit_sha: str = Field(..., description="HEAD commit SHA") + is_clean: bool = Field(..., description="Whether working tree is clean") + staged: list[dict[str, Any]] = Field( + default_factory=list, description="Staged changes" + ) + unstaged: list[dict[str, Any]] = Field( + default_factory=list, description="Unstaged changes" + ) + untracked: list[str] = Field(default_factory=list, description="Untracked files") + ahead: int = Field(default=0, description="Commits ahead of upstream") + behind: int = Field(default=0, description="Commits behind upstream") + + +class BranchRequest(BaseModel): + """Request for branch operations.""" + + project_id: str = Field(..., description="Project ID for scoping") + agent_id: str = Field(..., description="Agent ID making the request") + branch_name: str = Field(..., description="Branch name") + from_ref: str | None = Field( + default=None, description="Reference to create branch from" + ) + checkout: bool = Field(default=True, description="Checkout after creation") + + +class BranchResult(BaseModel): + """Result of a branch operation.""" + + success: bool = Field(..., description="Whether operation succeeded") + branch: str = Field(..., description="Branch name") + commit_sha: str | None = Field(default=None, description="HEAD commit SHA") + is_current: bool = Field(default=False, description="Whether branch is checked out") + error: str | None = Field(default=None, description="Error message if failed") + + +class ListBranchesRequest(BaseModel): + """Request to list branches.""" + + project_id: str = Field(..., description="Project ID for scoping") + agent_id: str = Field(..., description="Agent ID making the request") + include_remote: bool = Field(default=False, description="Include remote branches") + + +class ListBranchesResult(BaseModel): + """Result of listing branches.""" + + project_id: str = Field(..., description="Project ID") + current_branch: str = Field(..., description="Currently checked out branch") + local_branches: list[dict[str, Any]] = Field( + default_factory=list, description="Local branches" + ) + remote_branches: list[dict[str, Any]] = Field( + default_factory=list, description="Remote branches" + ) + + +class CheckoutRequest(BaseModel): + """Request to checkout a branch or ref.""" + + project_id: str = Field(..., description="Project ID for scoping") + agent_id: str = Field(..., description="Agent ID making the request") + ref: str = Field(..., description="Branch, tag, or commit to checkout") + create_branch: bool = Field(default=False, description="Create new branch") + force: bool = Field(default=False, description="Force checkout (discard changes)") + + +class CheckoutResult(BaseModel): + """Result of a checkout operation.""" + + success: bool = Field(..., description="Whether checkout succeeded") + ref: str = Field(..., description="Checked out reference") + commit_sha: str | None = Field(default=None, description="HEAD commit SHA") + error: str | None = Field(default=None, description="Error message if failed") + + +class CommitRequest(BaseModel): + """Request to create a commit.""" + + project_id: str = Field(..., description="Project ID for scoping") + agent_id: str = Field(..., description="Agent ID making the request") + message: str = Field(..., description="Commit message") + files: list[str] | None = Field( + default=None, description="Files to commit (None = all staged)" + ) + author_name: str | None = Field(default=None, description="Author name override") + author_email: str | None = Field(default=None, description="Author email override") + allow_empty: bool = Field(default=False, description="Allow empty commit") + + +class CommitResult(BaseModel): + """Result of a commit operation.""" + + success: bool = Field(..., description="Whether commit succeeded") + commit_sha: str | None = Field(default=None, description="New commit SHA") + short_sha: str | None = Field(default=None, description="Short commit SHA") + message: str | None = Field(default=None, description="Commit message") + files_changed: int = Field(default=0, description="Number of files changed") + insertions: int = Field(default=0, description="Lines added") + deletions: int = Field(default=0, description="Lines removed") + error: str | None = Field(default=None, description="Error message if failed") + + +class PushRequest(BaseModel): + """Request to push to remote.""" + + project_id: str = Field(..., description="Project ID for scoping") + agent_id: str = Field(..., description="Agent ID making the request") + branch: str | None = Field(default=None, description="Branch to push (None = current)") + remote: str = Field(default="origin", description="Remote name") + force: bool = Field(default=False, description="Force push") + set_upstream: bool = Field(default=True, description="Set upstream tracking") + + +class PushResult(BaseModel): + """Result of a push operation.""" + + success: bool = Field(..., description="Whether push succeeded") + branch: str = Field(..., description="Pushed branch") + remote: str = Field(..., description="Remote name") + commits_pushed: int = Field(default=0, description="Number of commits pushed") + error: str | None = Field(default=None, description="Error message if failed") + + +class PullRequest(BaseModel): + """Request to pull from remote.""" + + project_id: str = Field(..., description="Project ID for scoping") + agent_id: str = Field(..., description="Agent ID making the request") + branch: str | None = Field(default=None, description="Branch to pull (None = current)") + remote: str = Field(default="origin", description="Remote name") + rebase: bool = Field(default=False, description="Rebase instead of merge") + + +class PullResult(BaseModel): + """Result of a pull operation.""" + + success: bool = Field(..., description="Whether pull succeeded") + branch: str = Field(..., description="Pulled branch") + commits_received: int = Field(default=0, description="New commits received") + fast_forward: bool = Field(default=False, description="Was fast-forward") + conflicts: list[str] = Field( + default_factory=list, description="Conflicting files if any" + ) + error: str | None = Field(default=None, description="Error message if failed") + + +class DiffRequest(BaseModel): + """Request for diff.""" + + project_id: str = Field(..., description="Project ID for scoping") + agent_id: str = Field(..., description="Agent ID making the request") + base: str | None = Field(default=None, description="Base reference (None = working tree)") + head: str | None = Field(default=None, description="Head reference (None = HEAD)") + files: list[str] | None = Field(default=None, description="Specific files to diff") + context_lines: int = Field(default=3, ge=0, description="Context lines") + + +class DiffResult(BaseModel): + """Result of a diff operation.""" + + project_id: str = Field(..., description="Project ID") + base: str | None = Field(default=None, description="Base reference") + head: str | None = Field(default=None, description="Head reference") + files: list[dict[str, Any]] = Field( + default_factory=list, description="File diffs" + ) + total_additions: int = Field(default=0, description="Total lines added") + total_deletions: int = Field(default=0, description="Total lines removed") + files_changed: int = Field(default=0, description="Number of files changed") + + +class LogRequest(BaseModel): + """Request for commit log.""" + + project_id: str = Field(..., description="Project ID for scoping") + agent_id: str = Field(..., description="Agent ID making the request") + ref: str | None = Field(default=None, description="Reference to start from") + limit: int = Field(default=20, ge=1, le=100, description="Max commits to return") + skip: int = Field(default=0, ge=0, description="Commits to skip") + path: str | None = Field(default=None, description="Filter by path") + + +class LogResult(BaseModel): + """Result of a log operation.""" + + project_id: str = Field(..., description="Project ID") + commits: list[dict[str, Any]] = Field( + default_factory=list, description="Commit history" + ) + total_commits: int = Field(default=0, description="Total commits in range") + + +# PR Operations + + +class CreatePRRequest(BaseModel): + """Request to create a pull request.""" + + project_id: str = Field(..., description="Project ID for scoping") + agent_id: str = Field(..., description="Agent ID making the request") + title: str = Field(..., description="PR title") + body: str = Field(default="", description="PR description") + source_branch: str = Field(..., description="Source branch") + target_branch: str = Field(default="main", description="Target branch") + draft: bool = Field(default=False, description="Create as draft") + labels: list[str] = Field(default_factory=list, description="Labels to add") + assignees: list[str] = Field(default_factory=list, description="Assignees") + reviewers: list[str] = Field(default_factory=list, description="Reviewers") + + +class CreatePRResult(BaseModel): + """Result of creating a pull request.""" + + success: bool = Field(..., description="Whether creation succeeded") + pr_number: int | None = Field(default=None, description="PR number") + pr_url: str | None = Field(default=None, description="PR URL") + error: str | None = Field(default=None, description="Error message if failed") + + +class GetPRRequest(BaseModel): + """Request to get a pull request.""" + + project_id: str = Field(..., description="Project ID for scoping") + agent_id: str = Field(..., description="Agent ID making the request") + pr_number: int = Field(..., description="PR number") + + +class GetPRResult(BaseModel): + """Result of getting a pull request.""" + + success: bool = Field(..., description="Whether fetch succeeded") + pr: dict[str, Any] | None = Field(default=None, description="PR info") + error: str | None = Field(default=None, description="Error message if failed") + + +class ListPRsRequest(BaseModel): + """Request to list pull requests.""" + + project_id: str = Field(..., description="Project ID for scoping") + agent_id: str = Field(..., description="Agent ID making the request") + state: PRState | None = Field(default=None, description="Filter by state") + author: str | None = Field(default=None, description="Filter by author") + limit: int = Field(default=20, ge=1, le=100, description="Max PRs to return") + + +class ListPRsResult(BaseModel): + """Result of listing pull requests.""" + + success: bool = Field(..., description="Whether list succeeded") + pull_requests: list[dict[str, Any]] = Field( + default_factory=list, description="PRs" + ) + total_count: int = Field(default=0, description="Total matching PRs") + error: str | None = Field(default=None, description="Error message if failed") + + +class MergePRRequest(BaseModel): + """Request to merge a pull request.""" + + project_id: str = Field(..., description="Project ID for scoping") + agent_id: str = Field(..., description="Agent ID making the request") + pr_number: int = Field(..., description="PR number") + merge_strategy: MergeStrategy = Field( + default=MergeStrategy.MERGE, description="Merge strategy" + ) + commit_message: str | None = Field( + default=None, description="Custom merge commit message" + ) + delete_branch: bool = Field( + default=True, description="Delete source branch after merge" + ) + + +class MergePRResult(BaseModel): + """Result of merging a pull request.""" + + success: bool = Field(..., description="Whether merge succeeded") + merge_commit_sha: str | None = Field(default=None, description="Merge commit SHA") + branch_deleted: bool = Field(default=False, description="Whether branch was deleted") + error: str | None = Field(default=None, description="Error message if failed") + + +class UpdatePRRequest(BaseModel): + """Request to update a pull request.""" + + project_id: str = Field(..., description="Project ID for scoping") + agent_id: str = Field(..., description="Agent ID making the request") + pr_number: int = Field(..., description="PR number") + title: str | None = Field(default=None, description="New title") + body: str | None = Field(default=None, description="New description") + state: PRState | None = Field(default=None, description="New state") + labels: list[str] | None = Field(default=None, description="Replace labels") + assignees: list[str] | None = Field(default=None, description="Replace assignees") + + +class UpdatePRResult(BaseModel): + """Result of updating a pull request.""" + + success: bool = Field(..., description="Whether update succeeded") + pr: dict[str, Any] | None = Field(default=None, description="Updated PR info") + error: str | None = Field(default=None, description="Error message if failed") + + +# Workspace Operations + + +class GetWorkspaceRequest(BaseModel): + """Request to get or create workspace.""" + + project_id: str = Field(..., description="Project ID") + agent_id: str = Field(..., description="Agent ID making the request") + + +class GetWorkspaceResult(BaseModel): + """Result of getting workspace.""" + + success: bool = Field(..., description="Whether operation succeeded") + workspace: dict[str, Any] | None = Field(default=None, description="Workspace info") + error: str | None = Field(default=None, description="Error message if failed") + + +class LockWorkspaceRequest(BaseModel): + """Request to lock a workspace.""" + + project_id: str = Field(..., description="Project ID") + agent_id: str = Field(..., description="Agent ID requesting lock") + timeout: int = Field(default=300, ge=10, le=3600, description="Lock timeout seconds") + + +class LockWorkspaceResult(BaseModel): + """Result of locking workspace.""" + + success: bool = Field(..., description="Whether lock acquired") + lock_holder: str | None = Field(default=None, description="Current lock holder") + lock_expires: str | None = Field(default=None, description="Lock expiry ISO timestamp") + error: str | None = Field(default=None, description="Error message if failed") + + +class UnlockWorkspaceRequest(BaseModel): + """Request to unlock a workspace.""" + + project_id: str = Field(..., description="Project ID") + agent_id: str = Field(..., description="Agent ID releasing lock") + force: bool = Field(default=False, description="Force unlock (admin only)") + + +class UnlockWorkspaceResult(BaseModel): + """Result of unlocking workspace.""" + + success: bool = Field(..., description="Whether unlock succeeded") + error: str | None = Field(default=None, description="Error message if failed") + + +# Health and Status + + +class HealthStatus(BaseModel): + """Health status response.""" + + status: str = Field(..., description="Health status") + version: str = Field(..., description="Server version") + workspace_count: int = Field(default=0, description="Active workspaces") + gitea_connected: bool = Field(default=False, description="Gitea connectivity") + github_connected: bool = Field(default=False, description="GitHub connectivity") + gitlab_connected: bool = Field(default=False, description="GitLab connectivity") + redis_connected: bool = Field(default=False, description="Redis connectivity") + + +class ProviderStatus(BaseModel): + """Provider connection status.""" + + provider: str = Field(..., description="Provider name") + connected: bool = Field(..., description="Connection status") + url: str | None = Field(default=None, description="Provider URL") + user: str | None = Field(default=None, description="Authenticated user") + error: str | None = Field(default=None, description="Error if not connected") diff --git a/mcp-servers/git-ops/providers/__init__.py b/mcp-servers/git-ops/providers/__init__.py new file mode 100644 index 0000000..c03b187 --- /dev/null +++ b/mcp-servers/git-ops/providers/__init__.py @@ -0,0 +1,10 @@ +""" +Git provider implementations. + +Provides adapters for different git hosting platforms (Gitea, GitHub, GitLab). +""" + +from .base import BaseProvider +from .gitea import GiteaProvider + +__all__ = ["BaseProvider", "GiteaProvider"] diff --git a/mcp-servers/git-ops/providers/base.py b/mcp-servers/git-ops/providers/base.py new file mode 100644 index 0000000..235b9ba --- /dev/null +++ b/mcp-servers/git-ops/providers/base.py @@ -0,0 +1,388 @@ +""" +Base provider interface for git hosting platforms. + +Defines the abstract interface that all git providers must implement. +""" + +from abc import ABC, abstractmethod +from typing import Any + +from models import ( + CreatePRResult, + GetPRResult, + ListPRsResult, + MergePRResult, + MergeStrategy, + PRState, + UpdatePRResult, +) + + +class BaseProvider(ABC): + """ + Abstract base class for git hosting providers. + + All providers (Gitea, GitHub, GitLab) must implement this interface. + """ + + @property + @abstractmethod + def name(self) -> str: + """Return the provider name (e.g., 'gitea', 'github').""" + ... + + @abstractmethod + async def is_connected(self) -> bool: + """Check if the provider is connected and authenticated.""" + ... + + @abstractmethod + async def get_authenticated_user(self) -> str | None: + """Get the username of the authenticated user.""" + ... + + # Repository operations + + @abstractmethod + async def get_repo_info( + self, owner: str, repo: str + ) -> dict[str, Any]: + """ + Get repository information. + + Args: + owner: Repository owner/organization + repo: Repository name + + Returns: + Repository info dict + """ + ... + + @abstractmethod + async def get_default_branch( + self, owner: str, repo: str + ) -> str: + """ + Get the default branch for a repository. + + Args: + owner: Repository owner/organization + repo: Repository name + + Returns: + Default branch name + """ + ... + + # Pull Request operations + + @abstractmethod + async def create_pr( + self, + owner: str, + repo: str, + title: str, + body: str, + source_branch: str, + target_branch: str, + draft: bool = False, + labels: list[str] | None = None, + assignees: list[str] | None = None, + reviewers: list[str] | None = None, + ) -> CreatePRResult: + """ + Create a pull request. + + Args: + owner: Repository owner + repo: Repository name + title: PR title + body: PR description + source_branch: Source branch name + target_branch: Target branch name + draft: Whether to create as draft + labels: Labels to add + assignees: Users to assign + reviewers: Users to request review from + + Returns: + CreatePRResult with PR number and URL + """ + ... + + @abstractmethod + async def get_pr( + self, owner: str, repo: str, pr_number: int + ) -> GetPRResult: + """ + Get a pull request by number. + + Args: + owner: Repository owner + repo: Repository name + pr_number: Pull request number + + Returns: + GetPRResult with PR details + """ + ... + + @abstractmethod + async def list_prs( + self, + owner: str, + repo: str, + state: PRState | None = None, + author: str | None = None, + limit: int = 20, + ) -> ListPRsResult: + """ + List pull requests. + + Args: + owner: Repository owner + repo: Repository name + state: Filter by state (open, closed, merged) + author: Filter by author + limit: Maximum PRs to return + + Returns: + ListPRsResult with list of PRs + """ + ... + + @abstractmethod + async def merge_pr( + self, + owner: str, + repo: str, + pr_number: int, + merge_strategy: MergeStrategy = MergeStrategy.MERGE, + commit_message: str | None = None, + delete_branch: bool = True, + ) -> MergePRResult: + """ + Merge a pull request. + + Args: + owner: Repository owner + repo: Repository name + pr_number: Pull request number + merge_strategy: Merge strategy to use + commit_message: Custom merge commit message + delete_branch: Whether to delete source branch + + Returns: + MergePRResult with merge status + """ + ... + + @abstractmethod + async def update_pr( + self, + owner: str, + repo: str, + pr_number: int, + title: str | None = None, + body: str | None = None, + state: PRState | None = None, + labels: list[str] | None = None, + assignees: list[str] | None = None, + ) -> UpdatePRResult: + """ + Update a pull request. + + Args: + owner: Repository owner + repo: Repository name + pr_number: Pull request number + title: New title + body: New description + state: New state (open, closed) + labels: Replace labels + assignees: Replace assignees + + Returns: + UpdatePRResult with updated PR info + """ + ... + + @abstractmethod + async def close_pr( + self, owner: str, repo: str, pr_number: int + ) -> UpdatePRResult: + """ + Close a pull request without merging. + + Args: + owner: Repository owner + repo: Repository name + pr_number: Pull request number + + Returns: + UpdatePRResult with updated PR info + """ + ... + + # Branch operations via API (for operations that need to bypass local git) + + @abstractmethod + async def delete_remote_branch( + self, owner: str, repo: str, branch: str + ) -> bool: + """ + Delete a remote branch via API. + + Args: + owner: Repository owner + repo: Repository name + branch: Branch name to delete + + Returns: + True if deleted, False otherwise + """ + ... + + @abstractmethod + async def get_branch( + self, owner: str, repo: str, branch: str + ) -> dict[str, Any] | None: + """ + Get branch information via API. + + Args: + owner: Repository owner + repo: Repository name + branch: Branch name + + Returns: + Branch info dict or None if not found + """ + ... + + # Comment operations + + @abstractmethod + async def add_pr_comment( + self, owner: str, repo: str, pr_number: int, body: str + ) -> dict[str, Any]: + """ + Add a comment to a pull request. + + Args: + owner: Repository owner + repo: Repository name + pr_number: Pull request number + body: Comment body + + Returns: + Created comment info + """ + ... + + @abstractmethod + async def list_pr_comments( + self, owner: str, repo: str, pr_number: int + ) -> list[dict[str, Any]]: + """ + List comments on a pull request. + + Args: + owner: Repository owner + repo: Repository name + pr_number: Pull request number + + Returns: + List of comments + """ + ... + + # Label operations + + @abstractmethod + async def add_labels( + self, owner: str, repo: str, pr_number: int, labels: list[str] + ) -> list[str]: + """ + Add labels to a pull request. + + Args: + owner: Repository owner + repo: Repository name + pr_number: Pull request number + labels: Labels to add + + Returns: + Updated list of labels + """ + ... + + @abstractmethod + async def remove_label( + self, owner: str, repo: str, pr_number: int, label: str + ) -> list[str]: + """ + Remove a label from a pull request. + + Args: + owner: Repository owner + repo: Repository name + pr_number: Pull request number + label: Label to remove + + Returns: + Updated list of labels + """ + ... + + # Reviewer operations + + @abstractmethod + async def request_review( + self, owner: str, repo: str, pr_number: int, reviewers: list[str] + ) -> list[str]: + """ + Request review from users. + + Args: + owner: Repository owner + repo: Repository name + pr_number: Pull request number + reviewers: Usernames to request review from + + Returns: + List of reviewers requested + """ + ... + + # Utility methods + + def parse_repo_url(self, repo_url: str) -> tuple[str, str]: + """ + Parse repository URL to extract owner and repo name. + + Args: + repo_url: Repository URL (HTTPS or SSH) + + Returns: + Tuple of (owner, repo) + + Raises: + ValueError: If URL cannot be parsed + """ + import re + + # Handle SSH URLs: git@host:owner/repo.git + ssh_match = re.match(r"git@[^:]+:([^/]+)/([^/]+?)(?:\.git)?$", repo_url) + if ssh_match: + return ssh_match.group(1), ssh_match.group(2) + + # Handle HTTPS URLs: https://host/owner/repo.git + https_match = re.match( + r"https?://[^/]+/([^/]+)/([^/]+?)(?:\.git)?$", repo_url + ) + if https_match: + return https_match.group(1), https_match.group(2) + + raise ValueError(f"Unable to parse repository URL: {repo_url}") diff --git a/mcp-servers/git-ops/providers/gitea.py b/mcp-servers/git-ops/providers/gitea.py new file mode 100644 index 0000000..ce55eb6 --- /dev/null +++ b/mcp-servers/git-ops/providers/gitea.py @@ -0,0 +1,723 @@ +""" +Gitea provider implementation. + +Implements the BaseProvider interface for Gitea API operations. +""" + +import logging +from datetime import UTC, datetime +from typing import Any + +import httpx + +from config import Settings, get_settings +from exceptions import ( + APIError, + AuthenticationError, + PRNotFoundError, +) +from models import ( + CreatePRResult, + GetPRResult, + ListPRsResult, + MergePRResult, + MergeStrategy, + PRInfo, + PRState, + UpdatePRResult, +) + +from .base import BaseProvider + +logger = logging.getLogger(__name__) + + +class GiteaProvider(BaseProvider): + """ + Gitea API provider implementation. + + Supports all PR operations, branch operations, and repository queries. + """ + + def __init__( + self, + base_url: str | None = None, + token: str | None = None, + settings: Settings | None = None, + ) -> None: + """ + Initialize Gitea provider. + + Args: + base_url: Gitea server URL (e.g., https://gitea.example.com) + token: API token + settings: Optional settings override + """ + self.settings = settings or get_settings() + self.base_url = (base_url or self.settings.gitea_base_url).rstrip("/") + self.token = token or self.settings.gitea_token + self._client: httpx.AsyncClient | None = None + self._user: str | None = None + + @property + def name(self) -> str: + """Return the provider name.""" + return "gitea" + + async def _get_client(self) -> httpx.AsyncClient: + """Get or create HTTP client.""" + if self._client is None: + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + } + if self.token: + headers["Authorization"] = f"token {self.token}" + + self._client = httpx.AsyncClient( + base_url=f"{self.base_url}/api/v1", + headers=headers, + timeout=30.0, + ) + return self._client + + async def close(self) -> None: + """Close the HTTP client.""" + if self._client: + await self._client.aclose() + self._client = None + + async def _request( + self, + method: str, + path: str, + **kwargs: Any, + ) -> Any: + """ + Make an API request. + + Args: + method: HTTP method + path: API path + **kwargs: Additional request arguments + + Returns: + Parsed JSON response + + Raises: + APIError: On API errors + AuthenticationError: On auth failures + """ + client = await self._get_client() + + try: + response = await client.request(method, path, **kwargs) + + if response.status_code == 401: + raise AuthenticationError("gitea", "Invalid or expired token") + + if response.status_code == 403: + raise AuthenticationError( + "gitea", "Insufficient permissions for this operation" + ) + + if response.status_code == 404: + return None + + if response.status_code >= 400: + error_msg = response.text + try: + error_data = response.json() + error_msg = error_data.get("message", error_msg) + except Exception: + pass + raise APIError("gitea", response.status_code, error_msg) + + if response.status_code == 204: + return None + + return response.json() + + except httpx.RequestError as e: + raise APIError("gitea", 0, f"Request failed: {e}") + + async def is_connected(self) -> bool: + """Check if connected to Gitea.""" + if not self.base_url or not self.token: + return False + + try: + result = await self._request("GET", "/user") + return result is not None + except Exception: + return False + + async def get_authenticated_user(self) -> str | None: + """Get the authenticated user's username.""" + if self._user: + return self._user + + try: + result = await self._request("GET", "/user") + if result: + self._user = result.get("login") or result.get("username") + return self._user + except Exception: + pass + return None + + # Repository operations + + async def get_repo_info(self, owner: str, repo: str) -> dict[str, Any]: + """Get repository information.""" + result = await self._request("GET", f"/repos/{owner}/{repo}") + if result is None: + raise APIError("gitea", 404, f"Repository not found: {owner}/{repo}") + return result + + async def get_default_branch(self, owner: str, repo: str) -> str: + """Get the default branch for a repository.""" + repo_info = await self.get_repo_info(owner, repo) + return repo_info.get("default_branch", "main") + + # Pull Request operations + + async def create_pr( + self, + owner: str, + repo: str, + title: str, + body: str, + source_branch: str, + target_branch: str, + draft: bool = False, + labels: list[str] | None = None, + assignees: list[str] | None = None, + reviewers: list[str] | None = None, + ) -> CreatePRResult: + """Create a pull request.""" + try: + data: dict[str, Any] = { + "title": title, + "body": body, + "head": source_branch, + "base": target_branch, + } + + # Note: Gitea doesn't have draft PR support in all versions + # Draft support was added in Gitea 1.14+ + + result = await self._request( + "POST", + f"/repos/{owner}/{repo}/pulls", + json=data, + ) + + if result is None: + return CreatePRResult( + success=False, + error="Failed to create pull request", + ) + + pr_number = result["number"] + + # Add labels if specified + if labels: + await self.add_labels(owner, repo, pr_number, labels) + + # Add assignees if specified (via issue update) + if assignees: + await self._request( + "PATCH", + f"/repos/{owner}/{repo}/issues/{pr_number}", + json={"assignees": assignees}, + ) + + # Request reviewers if specified + if reviewers: + await self.request_review(owner, repo, pr_number, reviewers) + + return CreatePRResult( + success=True, + pr_number=pr_number, + pr_url=result.get("html_url"), + ) + + except APIError as e: + return CreatePRResult( + success=False, + error=str(e), + ) + + async def get_pr(self, owner: str, repo: str, pr_number: int) -> GetPRResult: + """Get a pull request by number.""" + try: + result = await self._request( + "GET", + f"/repos/{owner}/{repo}/pulls/{pr_number}", + ) + + if result is None: + raise PRNotFoundError(pr_number, f"{owner}/{repo}") + + pr_info = self._parse_pr(result) + + return GetPRResult( + success=True, + pr=pr_info.to_dict(), + ) + + except PRNotFoundError: + return GetPRResult( + success=False, + error=f"Pull request #{pr_number} not found", + ) + except APIError as e: + return GetPRResult( + success=False, + error=str(e), + ) + + async def list_prs( + self, + owner: str, + repo: str, + state: PRState | None = None, + author: str | None = None, + limit: int = 20, + ) -> ListPRsResult: + """List pull requests.""" + try: + params: dict[str, Any] = { + "limit": limit, + } + + if state: + # Gitea uses different state names + if state == PRState.OPEN: + params["state"] = "open" + elif state == PRState.CLOSED or state == PRState.MERGED: + params["state"] = "closed" + else: + params["state"] = "all" + + result = await self._request( + "GET", + f"/repos/{owner}/{repo}/pulls", + params=params, + ) + + if result is None: + return ListPRsResult( + success=True, + pull_requests=[], + total_count=0, + ) + + prs = [] + for pr_data in result: + # Filter by author if specified + if author: + pr_author = pr_data.get("user", {}).get("login", "") + if pr_author.lower() != author.lower(): + continue + + # Filter merged PRs if looking specifically for merged + if state == PRState.MERGED: + if not pr_data.get("merged"): + continue + + pr_info = self._parse_pr(pr_data) + prs.append(pr_info.to_dict()) + + return ListPRsResult( + success=True, + pull_requests=prs, + total_count=len(prs), + ) + + except APIError as e: + return ListPRsResult( + success=False, + error=str(e), + ) + + async def merge_pr( + self, + owner: str, + repo: str, + pr_number: int, + merge_strategy: MergeStrategy = MergeStrategy.MERGE, + commit_message: str | None = None, + delete_branch: bool = True, + ) -> MergePRResult: + """Merge a pull request.""" + try: + # Map merge strategy to Gitea's "Do" values + do_map = { + MergeStrategy.MERGE: "merge", + MergeStrategy.SQUASH: "squash", + MergeStrategy.REBASE: "rebase", + } + + data: dict[str, Any] = { + "Do": do_map[merge_strategy], + "delete_branch_after_merge": delete_branch, + } + + if commit_message: + data["MergeTitleField"] = commit_message.split("\n")[0] + if "\n" in commit_message: + data["MergeMessageField"] = "\n".join( + commit_message.split("\n")[1:] + ) + + result = await self._request( + "POST", + f"/repos/{owner}/{repo}/pulls/{pr_number}/merge", + json=data, + ) + + if result is None: + # Check if PR was actually merged + pr_result = await self.get_pr(owner, repo, pr_number) + if pr_result.success and pr_result.pr: + if pr_result.pr.get("state") == "merged": + return MergePRResult( + success=True, + branch_deleted=delete_branch, + ) + + return MergePRResult( + success=False, + error="Failed to merge pull request", + ) + + return MergePRResult( + success=True, + merge_commit_sha=result.get("sha"), + branch_deleted=delete_branch, + ) + + except APIError as e: + return MergePRResult( + success=False, + error=str(e), + ) + + async def update_pr( + self, + owner: str, + repo: str, + pr_number: int, + title: str | None = None, + body: str | None = None, + state: PRState | None = None, + labels: list[str] | None = None, + assignees: list[str] | None = None, + ) -> UpdatePRResult: + """Update a pull request.""" + try: + data: dict[str, Any] = {} + + if title is not None: + data["title"] = title + if body is not None: + data["body"] = body + if state is not None: + if state == PRState.OPEN: + data["state"] = "open" + elif state == PRState.CLOSED: + data["state"] = "closed" + + # Update PR if there's data + if data: + await self._request( + "PATCH", + f"/repos/{owner}/{repo}/pulls/{pr_number}", + json=data, + ) + + # Update labels via issue endpoint + if labels is not None: + # First clear existing labels + await self._request( + "DELETE", + f"/repos/{owner}/{repo}/issues/{pr_number}/labels", + ) + # Then add new labels + if labels: + await self.add_labels(owner, repo, pr_number, labels) + + # Update assignees via issue endpoint + if assignees is not None: + await self._request( + "PATCH", + f"/repos/{owner}/{repo}/issues/{pr_number}", + json={"assignees": assignees}, + ) + + # Fetch updated PR + result = await self.get_pr(owner, repo, pr_number) + return UpdatePRResult( + success=result.success, + pr=result.pr, + error=result.error, + ) + + except APIError as e: + return UpdatePRResult( + success=False, + error=str(e), + ) + + async def close_pr( + self, + owner: str, + repo: str, + pr_number: int, + ) -> UpdatePRResult: + """Close a pull request without merging.""" + return await self.update_pr( + owner, + repo, + pr_number, + state=PRState.CLOSED, + ) + + # Branch operations + + async def delete_remote_branch( + self, + owner: str, + repo: str, + branch: str, + ) -> bool: + """Delete a remote branch.""" + try: + await self._request( + "DELETE", + f"/repos/{owner}/{repo}/branches/{branch}", + ) + return True + except APIError: + return False + + async def get_branch( + self, + owner: str, + repo: str, + branch: str, + ) -> dict[str, Any] | None: + """Get branch information.""" + return await self._request( + "GET", + f"/repos/{owner}/{repo}/branches/{branch}", + ) + + # Comment operations + + async def add_pr_comment( + self, + owner: str, + repo: str, + pr_number: int, + body: str, + ) -> dict[str, Any]: + """Add a comment to a pull request.""" + result = await self._request( + "POST", + f"/repos/{owner}/{repo}/issues/{pr_number}/comments", + json={"body": body}, + ) + return result or {} + + async def list_pr_comments( + self, + owner: str, + repo: str, + pr_number: int, + ) -> list[dict[str, Any]]: + """List comments on a pull request.""" + result = await self._request( + "GET", + f"/repos/{owner}/{repo}/issues/{pr_number}/comments", + ) + return result or [] + + # Label operations + + async def add_labels( + self, + owner: str, + repo: str, + pr_number: int, + labels: list[str], + ) -> list[str]: + """Add labels to a pull request.""" + # First, get or create label IDs + label_ids = [] + for label_name in labels: + label_id = await self._get_or_create_label(owner, repo, label_name) + if label_id: + label_ids.append(label_id) + + if label_ids: + await self._request( + "POST", + f"/repos/{owner}/{repo}/issues/{pr_number}/labels", + json={"labels": label_ids}, + ) + + # Return current labels + issue = await self._request( + "GET", + f"/repos/{owner}/{repo}/issues/{pr_number}", + ) + if issue: + return [lbl["name"] for lbl in issue.get("labels", [])] + return labels + + async def remove_label( + self, + owner: str, + repo: str, + pr_number: int, + label: str, + ) -> list[str]: + """Remove a label from a pull request.""" + # Get label ID + label_info = await self._request( + "GET", + f"/repos/{owner}/{repo}/labels?name={label}", + ) + + if label_info and len(label_info) > 0: + label_id = label_info[0]["id"] + await self._request( + "DELETE", + f"/repos/{owner}/{repo}/issues/{pr_number}/labels/{label_id}", + ) + + # Return remaining labels + issue = await self._request( + "GET", + f"/repos/{owner}/{repo}/issues/{pr_number}", + ) + if issue: + return [lbl["name"] for lbl in issue.get("labels", [])] + return [] + + async def _get_or_create_label( + self, + owner: str, + repo: str, + label_name: str, + ) -> int | None: + """Get or create a label and return its ID.""" + # Try to find existing label + labels = await self._request( + "GET", + f"/repos/{owner}/{repo}/labels", + ) + + if labels: + for label in labels: + if label["name"].lower() == label_name.lower(): + return label["id"] + + # Create new label with default color + try: + result = await self._request( + "POST", + f"/repos/{owner}/{repo}/labels", + json={ + "name": label_name, + "color": "#3B82F6", # Default blue + }, + ) + if result: + return result["id"] + except APIError: + pass + + return None + + # Reviewer operations + + async def request_review( + self, + owner: str, + repo: str, + pr_number: int, + reviewers: list[str], + ) -> list[str]: + """Request review from users.""" + await self._request( + "POST", + f"/repos/{owner}/{repo}/pulls/{pr_number}/requested_reviewers", + json={"reviewers": reviewers}, + ) + return reviewers + + # Helper methods + + def _parse_pr(self, data: dict[str, Any]) -> PRInfo: + """Parse PR API response into PRInfo.""" + # Parse dates + created_at = self._parse_datetime(data.get("created_at")) + updated_at = self._parse_datetime(data.get("updated_at")) + merged_at = self._parse_datetime(data.get("merged_at")) + closed_at = self._parse_datetime(data.get("closed_at")) + + # Determine state + if data.get("merged"): + state = PRState.MERGED + elif data.get("state") == "closed": + state = PRState.CLOSED + else: + state = PRState.OPEN + + # Extract labels + labels = [lbl["name"] for lbl in data.get("labels", [])] + + # Extract assignees + assignees = [a["login"] for a in data.get("assignees", [])] + + # Extract reviewers + reviewers = [] + if "requested_reviewers" in data: + reviewers = [r["login"] for r in data["requested_reviewers"]] + + return PRInfo( + number=data["number"], + title=data["title"], + body=data.get("body", ""), + state=state, + source_branch=data.get("head", {}).get("ref", ""), + target_branch=data.get("base", {}).get("ref", ""), + author=data.get("user", {}).get("login", ""), + created_at=created_at, + updated_at=updated_at, + merged_at=merged_at, + closed_at=closed_at, + url=data.get("html_url"), + labels=labels, + assignees=assignees, + reviewers=reviewers, + mergeable=data.get("mergeable"), + draft=data.get("draft", False), + ) + + def _parse_datetime(self, value: str | None) -> datetime: + """Parse datetime string from API.""" + if not value: + return datetime.now(UTC) + + try: + # Handle Gitea's datetime format + if value.endswith("Z"): + value = value[:-1] + "+00:00" + return datetime.fromisoformat(value) + except ValueError: + return datetime.now(UTC) diff --git a/mcp-servers/git-ops/pyproject.toml b/mcp-servers/git-ops/pyproject.toml new file mode 100644 index 0000000..ba60b08 --- /dev/null +++ b/mcp-servers/git-ops/pyproject.toml @@ -0,0 +1,118 @@ +[project] +name = "syndarix-mcp-git-ops" +version = "0.1.0" +description = "Syndarix Git Operations MCP Server - Repository management, branching, commits, and PR workflows" +requires-python = ">=3.12" +dependencies = [ + "fastmcp>=2.0.0", + "gitpython>=3.1.0", + "httpx>=0.27.0", + "redis>=5.0.0", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", + "uvicorn>=0.30.0", + "fastapi>=0.115.0", + "filelock>=3.15.0", + "aiofiles>=24.1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.24.0", + "pytest-cov>=5.0.0", + "fakeredis>=2.25.0", + "ruff>=0.8.0", + "mypy>=1.11.0", + "respx>=0.21.0", +] + +[project.scripts] +git-ops = "server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["."] +exclude = ["tests/", "*.md", "Dockerfile"] + +[tool.hatch.build.targets.sdist] +include = ["*.py", "pyproject.toml"] + +[tool.ruff] +target-version = "py312" +line-length = 88 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "ARG", # flake8-unused-arguments + "SIM", # flake8-simplify + "S", # flake8-bandit (security) +] +ignore = [ + "E501", # line too long (handled by formatter) + "B008", # do not perform function calls in argument defaults + "B904", # raise from in except (too noisy) + "S104", # possible binding to all interfaces + "S110", # try-except-pass (intentional for optional operations) + "S603", # subprocess without shell=True (safe usage in git wrapper) + "S607", # starting a process with a partial path (git CLI) + "ARG002", # unused method arguments (for API compatibility) + "SIM102", # nested if statements (sometimes more readable) + "SIM105", # contextlib.suppress (sometimes more readable) + "SIM108", # ternary operator (sometimes more readable) + "SIM118", # dict.keys() (explicit is fine) +] + +[tool.ruff.lint.isort] +known-first-party = ["config", "models", "exceptions", "git_wrapper", "workspace", "providers"] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["S101", "ARG001", "S105", "S106", "S108", "F841", "B007"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +testpaths = ["tests"] +addopts = "-v --tb=short" +filterwarnings = [ + "ignore::DeprecationWarning", +] + +[tool.coverage.run] +source = ["."] +omit = ["tests/*", "conftest.py"] +branch = true + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", +] +fail_under = 65 # TODO: Increase to 80% once more tool tests are added +show_missing = true + +[tool.mypy] +python_version = "3.12" +warn_return_any = false +warn_unused_ignores = false +disallow_untyped_defs = true +ignore_missing_imports = true +plugins = ["pydantic.mypy"] + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false +ignore_errors = true diff --git a/mcp-servers/git-ops/server.py b/mcp-servers/git-ops/server.py new file mode 100644 index 0000000..69b7326 --- /dev/null +++ b/mcp-servers/git-ops/server.py @@ -0,0 +1,1226 @@ +""" +Git Operations MCP Server. + +Provides git repository management, branching, commits, and PR workflows +for Syndarix AI agents. +""" + +import inspect +import logging +import re +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from datetime import UTC, datetime +from typing import Any, get_type_hints + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from fastmcp import FastMCP +from pydantic import Field + +from config import Settings, get_settings +from exceptions import ErrorCode, GitOpsError +from git_wrapper import GitWrapper +from models import MergeStrategy, PRState +from providers import GiteaProvider +from workspace import WorkspaceManager + +# Input validation patterns +ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{1,128}$") +BRANCH_PATTERN = re.compile(r"^[a-zA-Z0-9_/.-]{1,256}$") +URL_PATTERN = re.compile( + r"^(https?://|git@)[a-zA-Z0-9._-]+[:/][a-zA-Z0-9._/-]+(?:\.git)?$" +) + + +def _validate_id(value: str, field_name: str) -> str | None: + """Validate project_id or agent_id format.""" + if not isinstance(value, str): + return f"{field_name} must be a string" + if not value: + return f"{field_name} is required" + if not ID_PATTERN.match(value): + return f"Invalid {field_name}: must be 1-128 alphanumeric characters, hyphens, or underscores" + return None + + +def _validate_branch(value: str) -> str | None: + """Validate branch name format.""" + if not isinstance(value, str): + return "Branch name must be a string" + if not value: + return "Branch name is required" + if not BRANCH_PATTERN.match(value): + return "Invalid branch name: must be 1-256 alphanumeric characters, hyphens, underscores, dots, or slashes" + return None + + +def _validate_url(value: str) -> str | None: + """Validate repository URL format.""" + if not isinstance(value, str): + return "Repository URL must be a string" + if not value: + return "Repository URL is required" + if not URL_PATTERN.match(value): + return "Invalid repository URL: must be a valid HTTPS or SSH git URL" + return None + + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + +# Global instances +_settings: Settings | None = None +_workspace_manager: WorkspaceManager | None = None +_gitea_provider: GiteaProvider | None = None + + +def _get_provider_for_url(repo_url: str) -> GiteaProvider | None: + """Get the appropriate provider for a repository URL.""" + if not _settings: + return None + + # Check if it's a Gitea URL + if _settings.gitea_base_url and _settings.gitea_base_url in repo_url: + return _gitea_provider + + # Default to Gitea for now + return _gitea_provider + + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + """Application lifespan handler.""" + global _settings, _workspace_manager, _gitea_provider + + logger.info("Starting Git Operations MCP Server...") + + # Load settings + _settings = get_settings() + + # Initialize workspace manager + _workspace_manager = WorkspaceManager(_settings) + + # Initialize providers + if _settings.gitea_base_url and _settings.gitea_token: + _gitea_provider = GiteaProvider( + base_url=_settings.gitea_base_url, + token=_settings.gitea_token, + settings=_settings, + ) + logger.info(f"Gitea provider initialized: {_settings.gitea_base_url}") + + logger.info("Git Operations MCP Server started successfully") + + yield + + # Cleanup + logger.info("Shutting down Git Operations MCP Server...") + + if _gitea_provider: + await _gitea_provider.close() + + logger.info("Git Operations MCP Server shut down") + + +# Create FastMCP server +mcp = FastMCP("syndarix-git-ops") + +# Create FastAPI app with lifespan +app = FastAPI( + title="Git Operations MCP Server", + description="Repository management, branching, commits, and PR workflows", + version="0.1.0", + lifespan=lifespan, +) + + +@app.get("/health") +async def health_check() -> dict[str, Any]: + """Health check endpoint.""" + status: dict[str, Any] = { + "status": "healthy", + "service": "git-ops", + "version": "0.1.0", + "timestamp": datetime.now(UTC).isoformat(), + "dependencies": {}, + } + + is_degraded = False + + # Check Gitea connectivity + if _gitea_provider: + try: + if await _gitea_provider.is_connected(): + user = await _gitea_provider.get_authenticated_user() + status["dependencies"]["gitea"] = f"connected as {user}" + else: + status["dependencies"]["gitea"] = "not connected" + is_degraded = True + except Exception as e: + status["dependencies"]["gitea"] = f"error: {e}" + is_degraded = True + else: + status["dependencies"]["gitea"] = "not configured" + + # Check workspace directory + if _workspace_manager: + try: + workspaces = await _workspace_manager.list_workspaces() + status["dependencies"]["workspace"] = { + "path": str(_workspace_manager.base_path), + "active_workspaces": len(workspaces), + } + except Exception as e: + status["dependencies"]["workspace"] = f"error: {e}" + is_degraded = True + else: + status["dependencies"]["workspace"] = "not initialized" + + if is_degraded: + status["status"] = "degraded" + + return status + + +# Tool registry for JSON-RPC +_tool_registry: dict[str, Any] = {} + + +def _python_type_to_json_schema(python_type: Any) -> dict[str, Any]: + """Convert Python type annotation to JSON Schema.""" + type_name = getattr(python_type, "__name__", str(python_type)) + + if python_type is str or type_name == "str": + return {"type": "string"} + elif python_type is int or type_name == "int": + return {"type": "integer"} + elif python_type is float or type_name == "float": + return {"type": "number"} + elif python_type is bool or type_name == "bool": + return {"type": "boolean"} + elif type_name == "NoneType": + return {"type": "null"} + elif hasattr(python_type, "__origin__"): + origin = python_type.__origin__ + args = getattr(python_type, "__args__", ()) + + if origin is list: + item_type = args[0] if args else Any + return {"type": "array", "items": _python_type_to_json_schema(item_type)} + elif origin is dict: + return {"type": "object"} + elif origin is type(None) or str(origin) == "typing.Union": + non_none_args = [a for a in args if a is not type(None)] + if len(non_none_args) == 1: + schema = _python_type_to_json_schema(non_none_args[0]) + schema["nullable"] = True + return schema + return {"type": "object"} + return {"type": "object"} + + +def _get_tool_schema(func: Any) -> dict[str, Any]: + """Extract JSON Schema from a tool function.""" + sig = inspect.signature(func) + hints = get_type_hints(func) if hasattr(func, "__annotations__") else {} + + properties: dict[str, Any] = {} + required: list[str] = [] + + for name, param in sig.parameters.items(): + if name in ("self", "cls"): + continue + + prop: dict[str, Any] = {} + + if name in hints: + prop = _python_type_to_json_schema(hints[name]) + + default_val = param.default + if hasattr(default_val, "description") and default_val.description: + prop["description"] = default_val.description + if hasattr(default_val, "ge") and default_val.ge is not None: + prop["minimum"] = default_val.ge + if hasattr(default_val, "le") and default_val.le is not None: + prop["maximum"] = default_val.le + if hasattr(default_val, "default"): + field_default = default_val.default + if field_default is not ... and not ( + hasattr(field_default, "__class__") + and "PydanticUndefined" in field_default.__class__.__name__ + ): + prop["default"] = field_default + + if param.default is inspect.Parameter.empty: + required.append(name) + elif hasattr(default_val, "default"): + field_default = default_val.default + if field_default is ... or ( + hasattr(field_default, "__class__") + and "PydanticUndefined" in field_default.__class__.__name__ + ): + required.append(name) + + properties[name] = prop + + return { + "type": "object", + "properties": properties, + "required": required, + } + + +def _register_tool( + name: str, tool_or_func: Any, description: str | None = None +) -> None: + """Register a tool in the registry.""" + if hasattr(tool_or_func, "fn"): + func = tool_or_func.fn + if ( + not description + and hasattr(tool_or_func, "description") + and tool_or_func.description + ): + description = tool_or_func.description + else: + func = tool_or_func + + _tool_registry[name] = { + "func": func, + "description": description or (func.__doc__ or "").strip(), + "schema": _get_tool_schema(func), + } + + +@app.get("/mcp/tools") +async def list_mcp_tools() -> dict[str, Any]: + """Return list of available MCP tools with their schemas.""" + tools = [] + for name, info in _tool_registry.items(): + tools.append( + { + "name": name, + "description": info["description"], + "inputSchema": info["schema"], + } + ) + return {"tools": tools} + + +@app.post("/mcp") +async def mcp_rpc(request: Request) -> JSONResponse: + """JSON-RPC 2.0 endpoint for MCP tool execution.""" + try: + body = await request.json() + except Exception as e: + return JSONResponse( + status_code=400, + content={ + "jsonrpc": "2.0", + "error": {"code": -32700, "message": f"Parse error: {e}"}, + "id": None, + }, + ) + + jsonrpc = body.get("jsonrpc") + method = body.get("method") + params = body.get("params", {}) + request_id = body.get("id") + + if jsonrpc != "2.0": + return JSONResponse( + status_code=400, + content={ + "jsonrpc": "2.0", + "error": { + "code": -32600, + "message": "Invalid Request: jsonrpc must be '2.0'", + }, + "id": request_id, + }, + ) + + if not method: + return JSONResponse( + status_code=400, + content={ + "jsonrpc": "2.0", + "error": { + "code": -32600, + "message": "Invalid Request: method is required", + }, + "id": request_id, + }, + ) + + tool_info = _tool_registry.get(method) + if not tool_info: + return JSONResponse( + status_code=404, + content={ + "jsonrpc": "2.0", + "error": {"code": -32601, "message": f"Method not found: {method}"}, + "id": request_id, + }, + ) + + try: + func = tool_info["func"] + + sig = inspect.signature(func) + resolved_params = dict(params) + for name, param in sig.parameters.items(): + if name not in resolved_params: + default_val = param.default + if hasattr(default_val, "default"): + field_default = default_val.default + if field_default is not ... and not ( + hasattr(field_default, "__class__") + and "PydanticUndefined" in field_default.__class__.__name__ + ): + resolved_params[name] = field_default + + result = await func(**resolved_params) + return JSONResponse( + content={ + "jsonrpc": "2.0", + "result": result, + "id": request_id, + } + ) + except TypeError as e: + return JSONResponse( + status_code=400, + content={ + "jsonrpc": "2.0", + "error": {"code": -32602, "message": f"Invalid params: {e}"}, + "id": request_id, + }, + ) + except Exception as e: + logger.error(f"Tool execution error: {e}") + return JSONResponse( + status_code=500, + content={ + "jsonrpc": "2.0", + "error": {"code": -32000, "message": f"Server error: {e}"}, + "id": request_id, + }, + ) + + +# MCP Tools - Repository Operations + + +@mcp.tool() +async def clone_repository( + project_id: str = Field(..., description="Project ID for scoping"), + agent_id: str = Field(..., description="Agent ID making the request"), + repo_url: str = Field(..., description="Repository URL to clone"), + branch: str | None = Field(default=None, description="Branch to checkout after clone"), + depth: int | None = Field(default=None, ge=1, description="Shallow clone depth"), +) -> dict[str, Any]: + """ + Clone a repository into a project workspace. + + Creates an isolated workspace for the project and clones the repository. + """ + try: + # Validate inputs + if error := _validate_id(project_id, "project_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + if error := _validate_id(agent_id, "agent_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + if error := _validate_url(repo_url): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + + # Create workspace + workspace = await _workspace_manager.create_workspace(project_id, repo_url) # type: ignore[union-attr] + + # Get auth token from provider + auth_token = None + if _settings and _settings.gitea_token: + auth_token = _settings.gitea_token + + # Clone repository + git = GitWrapper(workspace.path, _settings) + result = await git.clone(repo_url, branch=branch, depth=depth, auth_token=auth_token) + + # Update workspace metadata + await _workspace_manager.update_workspace_branch(project_id, result.branch) # type: ignore[union-attr] + + return { + "success": True, + "project_id": project_id, + "workspace_path": result.workspace_path, + "branch": result.branch, + "commit_sha": result.commit_sha, + } + + except GitOpsError as e: + logger.error(f"Clone error: {e}") + return {"success": False, "error": e.message, "code": e.code.value} + except Exception as e: + logger.error(f"Unexpected clone error: {e}") + return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value} + + +@mcp.tool() +async def git_status( + project_id: str = Field(..., description="Project ID for scoping"), + agent_id: str = Field(..., description="Agent ID making the request"), + include_untracked: bool = Field(default=True, description="Include untracked files"), +) -> dict[str, Any]: + """ + Get git status for a project workspace. + + Returns current branch, staged/unstaged changes, and untracked files. + """ + try: + if error := _validate_id(project_id, "project_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + if error := _validate_id(agent_id, "agent_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + + workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr] + if not workspace: + return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value} + + git = GitWrapper(workspace.path, _settings) + result = await git.status(include_untracked=include_untracked) + + return { + "success": True, + "project_id": project_id, + "branch": result.branch, + "commit_sha": result.commit_sha, + "is_clean": result.is_clean, + "staged": result.staged, + "unstaged": result.unstaged, + "untracked": result.untracked, + "ahead": result.ahead, + "behind": result.behind, + } + + except GitOpsError as e: + logger.error(f"Status error: {e}") + return {"success": False, "error": e.message, "code": e.code.value} + except Exception as e: + logger.error(f"Unexpected status error: {e}") + return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value} + + +# MCP Tools - Branch Operations + + +@mcp.tool() +async def create_branch( + project_id: str = Field(..., description="Project ID for scoping"), + agent_id: str = Field(..., description="Agent ID making the request"), + branch_name: str = Field(..., description="Name for the new branch"), + from_ref: str | None = Field(default=None, description="Reference to create from (default: HEAD)"), + checkout: bool = Field(default=True, description="Checkout after creation"), +) -> dict[str, Any]: + """ + Create a new branch in the project workspace. + """ + try: + if error := _validate_id(project_id, "project_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + if error := _validate_id(agent_id, "agent_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + if error := _validate_branch(branch_name): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + + workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr] + if not workspace: + return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value} + + git = GitWrapper(workspace.path, _settings) + result = await git.create_branch(branch_name, from_ref=from_ref, checkout=checkout) + + if checkout: + await _workspace_manager.update_workspace_branch(project_id, branch_name) # type: ignore[union-attr] + + return { + "success": True, + "branch": result.branch, + "commit_sha": result.commit_sha, + "is_current": result.is_current, + } + + except GitOpsError as e: + logger.error(f"Create branch error: {e}") + return {"success": False, "error": e.message, "code": e.code.value} + except Exception as e: + logger.error(f"Unexpected create branch error: {e}") + return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value} + + +@mcp.tool() +async def list_branches( + project_id: str = Field(..., description="Project ID for scoping"), + agent_id: str = Field(..., description="Agent ID making the request"), + include_remote: bool = Field(default=False, description="Include remote branches"), +) -> dict[str, Any]: + """ + List branches in the project workspace. + """ + try: + if error := _validate_id(project_id, "project_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + if error := _validate_id(agent_id, "agent_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + + workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr] + if not workspace: + return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value} + + git = GitWrapper(workspace.path, _settings) + result = await git.list_branches(include_remote=include_remote) + + return { + "success": True, + "project_id": project_id, + "current_branch": result.current_branch, + "local_branches": result.local_branches, + "remote_branches": result.remote_branches, + } + + except GitOpsError as e: + logger.error(f"List branches error: {e}") + return {"success": False, "error": e.message, "code": e.code.value} + except Exception as e: + logger.error(f"Unexpected list branches error: {e}") + return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value} + + +@mcp.tool() +async def checkout( + project_id: str = Field(..., description="Project ID for scoping"), + agent_id: str = Field(..., description="Agent ID making the request"), + ref: str = Field(..., description="Branch, tag, or commit to checkout"), + create_branch: bool = Field(default=False, description="Create new branch"), + force: bool = Field(default=False, description="Force checkout (discard changes)"), +) -> dict[str, Any]: + """ + Checkout a branch, tag, or commit in the project workspace. + """ + try: + if error := _validate_id(project_id, "project_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + if error := _validate_id(agent_id, "agent_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + + workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr] + if not workspace: + return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value} + + git = GitWrapper(workspace.path, _settings) + result = await git.checkout(ref, create_branch=create_branch, force=force) + + await _workspace_manager.update_workspace_branch(project_id, ref) # type: ignore[union-attr] + + return { + "success": True, + "ref": result.ref, + "commit_sha": result.commit_sha, + } + + except GitOpsError as e: + logger.error(f"Checkout error: {e}") + return {"success": False, "error": e.message, "code": e.code.value} + except Exception as e: + logger.error(f"Unexpected checkout error: {e}") + return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value} + + +# MCP Tools - Commit Operations + + +@mcp.tool() +async def commit( + project_id: str = Field(..., description="Project ID for scoping"), + agent_id: str = Field(..., description="Agent ID making the request"), + message: str = Field(..., description="Commit message"), + files: list[str] | None = Field(default=None, description="Specific files to commit (None = all staged)"), + author_name: str | None = Field(default=None, description="Author name override"), + author_email: str | None = Field(default=None, description="Author email override"), +) -> dict[str, Any]: + """ + Create a commit in the project workspace. + + Stages all changes by default, or specific files if provided. + """ + try: + if error := _validate_id(project_id, "project_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + if error := _validate_id(agent_id, "agent_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + + workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr] + if not workspace: + return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value} + + git = GitWrapper(workspace.path, _settings) + result = await git.commit( + message=message, + files=files, + author_name=author_name, + author_email=author_email, + ) + + return { + "success": True, + "commit_sha": result.commit_sha, + "short_sha": result.short_sha, + "message": result.message, + "files_changed": result.files_changed, + "insertions": result.insertions, + "deletions": result.deletions, + } + + except GitOpsError as e: + logger.error(f"Commit error: {e}") + return {"success": False, "error": e.message, "code": e.code.value} + except Exception as e: + logger.error(f"Unexpected commit error: {e}") + return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value} + + +@mcp.tool() +async def push( + project_id: str = Field(..., description="Project ID for scoping"), + agent_id: str = Field(..., description="Agent ID making the request"), + branch: str | None = Field(default=None, description="Branch to push (None = current)"), + remote: str = Field(default="origin", description="Remote name"), + force: bool = Field(default=False, description="Force push"), + set_upstream: bool = Field(default=True, description="Set upstream tracking"), +) -> dict[str, Any]: + """ + Push commits to remote repository. + """ + try: + if error := _validate_id(project_id, "project_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + if error := _validate_id(agent_id, "agent_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + + workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr] + if not workspace: + return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value} + + # Get auth token + auth_token = None + if _settings and _settings.gitea_token: + auth_token = _settings.gitea_token + + git = GitWrapper(workspace.path, _settings) + result = await git.push( + branch=branch, + remote=remote, + force=force, + set_upstream=set_upstream, + auth_token=auth_token, + ) + + return { + "success": True, + "branch": result.branch, + "remote": result.remote, + "commits_pushed": result.commits_pushed, + } + + except GitOpsError as e: + logger.error(f"Push error: {e}") + return {"success": False, "error": e.message, "code": e.code.value} + except Exception as e: + logger.error(f"Unexpected push error: {e}") + return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value} + + +@mcp.tool() +async def pull( + project_id: str = Field(..., description="Project ID for scoping"), + agent_id: str = Field(..., description="Agent ID making the request"), + branch: str | None = Field(default=None, description="Branch to pull (None = current)"), + remote: str = Field(default="origin", description="Remote name"), + rebase: bool = Field(default=False, description="Rebase instead of merge"), +) -> dict[str, Any]: + """ + Pull changes from remote repository. + """ + try: + if error := _validate_id(project_id, "project_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + if error := _validate_id(agent_id, "agent_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + + workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr] + if not workspace: + return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value} + + git = GitWrapper(workspace.path, _settings) + result = await git.pull(branch=branch, remote=remote, rebase=rebase) + + return { + "success": True, + "branch": result.branch, + "commits_received": result.commits_received, + "fast_forward": result.fast_forward, + "conflicts": result.conflicts, + } + + except GitOpsError as e: + logger.error(f"Pull error: {e}") + return {"success": False, "error": e.message, "code": e.code.value} + except Exception as e: + logger.error(f"Unexpected pull error: {e}") + return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value} + + +# MCP Tools - Diff and Log + + +@mcp.tool() +async def diff( + project_id: str = Field(..., description="Project ID for scoping"), + agent_id: str = Field(..., description="Agent ID making the request"), + base: str | None = Field(default=None, description="Base reference"), + head: str | None = Field(default=None, description="Head reference"), + files: list[str] | None = Field(default=None, description="Specific files to diff"), + context_lines: int = Field(default=3, ge=0, description="Context lines"), +) -> dict[str, Any]: + """ + Get diff between references or working tree. + """ + try: + if error := _validate_id(project_id, "project_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + if error := _validate_id(agent_id, "agent_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + + workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr] + if not workspace: + return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value} + + git = GitWrapper(workspace.path, _settings) + result = await git.diff(base=base, head=head, files=files, context_lines=context_lines) + + return { + "success": True, + "project_id": project_id, + "base": result.base, + "head": result.head, + "files": result.files, + "total_additions": result.total_additions, + "total_deletions": result.total_deletions, + "files_changed": result.files_changed, + } + + except GitOpsError as e: + logger.error(f"Diff error: {e}") + return {"success": False, "error": e.message, "code": e.code.value} + except Exception as e: + logger.error(f"Unexpected diff error: {e}") + return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value} + + +@mcp.tool() +async def log( + project_id: str = Field(..., description="Project ID for scoping"), + agent_id: str = Field(..., description="Agent ID making the request"), + ref: str | None = Field(default=None, description="Reference to start from"), + limit: int = Field(default=20, ge=1, le=100, description="Max commits"), + skip: int = Field(default=0, ge=0, description="Commits to skip"), + path: str | None = Field(default=None, description="Filter by path"), +) -> dict[str, Any]: + """ + Get commit history. + """ + try: + if error := _validate_id(project_id, "project_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + if error := _validate_id(agent_id, "agent_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + + workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr] + if not workspace: + return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value} + + git = GitWrapper(workspace.path, _settings) + result = await git.log(ref=ref, limit=limit, skip=skip, path=path) + + return { + "success": True, + "project_id": project_id, + "commits": result.commits, + "total_commits": result.total_commits, + } + + except GitOpsError as e: + logger.error(f"Log error: {e}") + return {"success": False, "error": e.message, "code": e.code.value} + except Exception as e: + logger.error(f"Unexpected log error: {e}") + return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value} + + +# MCP Tools - Pull Request Operations + + +@mcp.tool() +async def create_pull_request( + project_id: str = Field(..., description="Project ID for scoping"), + agent_id: str = Field(..., description="Agent ID making the request"), + title: str = Field(..., description="PR title"), + source_branch: str = Field(..., description="Source branch"), + body: str = Field(default="", description="PR description"), + target_branch: str = Field(default="main", description="Target branch"), + draft: bool = Field(default=False, description="Create as draft"), + labels: list[str] | None = Field(default=None, description="Labels to add"), + assignees: list[str] | None = Field(default=None, description="Users to assign"), + reviewers: list[str] | None = Field(default=None, description="Users to request review from"), +) -> dict[str, Any]: + """ + Create a pull request on the remote provider. + """ + try: + if error := _validate_id(project_id, "project_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + if error := _validate_id(agent_id, "agent_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + + workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr] + if not workspace: + return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value} + + if not workspace.repo_url: + return {"success": False, "error": "Workspace has no repository URL", "code": ErrorCode.INVALID_REQUEST.value} + + provider = _get_provider_for_url(workspace.repo_url) + if not provider: + return {"success": False, "error": "No provider configured for this repository", "code": ErrorCode.PROVIDER_NOT_FOUND.value} + + owner, repo = provider.parse_repo_url(workspace.repo_url) + + result = await provider.create_pr( + owner=owner, + repo=repo, + title=title, + body=body, + source_branch=source_branch, + target_branch=target_branch, + draft=draft, + labels=labels, + assignees=assignees, + reviewers=reviewers, + ) + + return { + "success": result.success, + "pr_number": result.pr_number, + "pr_url": result.pr_url, + "error": result.error, + } + + except GitOpsError as e: + logger.error(f"Create PR error: {e}") + return {"success": False, "error": e.message, "code": e.code.value} + except Exception as e: + logger.error(f"Unexpected create PR error: {e}") + return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value} + + +@mcp.tool() +async def get_pull_request( + project_id: str = Field(..., description="Project ID for scoping"), + agent_id: str = Field(..., description="Agent ID making the request"), + pr_number: int = Field(..., description="Pull request number"), +) -> dict[str, Any]: + """ + Get pull request details. + """ + try: + if error := _validate_id(project_id, "project_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + if error := _validate_id(agent_id, "agent_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + + workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr] + if not workspace: + return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value} + + if not workspace.repo_url: + return {"success": False, "error": "Workspace has no repository URL", "code": ErrorCode.INVALID_REQUEST.value} + + provider = _get_provider_for_url(workspace.repo_url) + if not provider: + return {"success": False, "error": "No provider configured for this repository", "code": ErrorCode.PROVIDER_NOT_FOUND.value} + + owner, repo = provider.parse_repo_url(workspace.repo_url) + result = await provider.get_pr(owner, repo, pr_number) + + return { + "success": result.success, + "pr": result.pr, + "error": result.error, + } + + except GitOpsError as e: + logger.error(f"Get PR error: {e}") + return {"success": False, "error": e.message, "code": e.code.value} + except Exception as e: + logger.error(f"Unexpected get PR error: {e}") + return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value} + + +@mcp.tool() +async def list_pull_requests( + project_id: str = Field(..., description="Project ID for scoping"), + agent_id: str = Field(..., description="Agent ID making the request"), + state: str | None = Field(default=None, description="Filter by state: open, closed, merged"), + author: str | None = Field(default=None, description="Filter by author"), + limit: int = Field(default=20, ge=1, le=100, description="Max PRs"), +) -> dict[str, Any]: + """ + List pull requests for the repository. + """ + try: + if error := _validate_id(project_id, "project_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + if error := _validate_id(agent_id, "agent_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + + workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr] + if not workspace: + return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value} + + if not workspace.repo_url: + return {"success": False, "error": "Workspace has no repository URL", "code": ErrorCode.INVALID_REQUEST.value} + + provider = _get_provider_for_url(workspace.repo_url) + if not provider: + return {"success": False, "error": "No provider configured for this repository", "code": ErrorCode.PROVIDER_NOT_FOUND.value} + + # Parse state + pr_state = None + if state: + try: + pr_state = PRState(state.lower()) + except ValueError: + return {"success": False, "error": f"Invalid state: {state}. Valid: open, closed, merged", "code": ErrorCode.INVALID_REQUEST.value} + + owner, repo = provider.parse_repo_url(workspace.repo_url) + result = await provider.list_prs(owner, repo, state=pr_state, author=author, limit=limit) + + return { + "success": result.success, + "pull_requests": result.pull_requests, + "total_count": result.total_count, + "error": result.error, + } + + except GitOpsError as e: + logger.error(f"List PRs error: {e}") + return {"success": False, "error": e.message, "code": e.code.value} + except Exception as e: + logger.error(f"Unexpected list PRs error: {e}") + return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value} + + +@mcp.tool() +async def merge_pull_request( + project_id: str = Field(..., description="Project ID for scoping"), + agent_id: str = Field(..., description="Agent ID making the request"), + pr_number: int = Field(..., description="Pull request number"), + merge_strategy: str = Field(default="merge", description="Strategy: merge, squash, rebase"), + commit_message: str | None = Field(default=None, description="Custom commit message"), + delete_branch: bool = Field(default=True, description="Delete source branch after merge"), +) -> dict[str, Any]: + """ + Merge a pull request. + """ + try: + if error := _validate_id(project_id, "project_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + if error := _validate_id(agent_id, "agent_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + + workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr] + if not workspace: + return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value} + + if not workspace.repo_url: + return {"success": False, "error": "Workspace has no repository URL", "code": ErrorCode.INVALID_REQUEST.value} + + provider = _get_provider_for_url(workspace.repo_url) + if not provider: + return {"success": False, "error": "No provider configured for this repository", "code": ErrorCode.PROVIDER_NOT_FOUND.value} + + # Parse merge strategy + try: + strategy = MergeStrategy(merge_strategy.lower()) + except ValueError: + return {"success": False, "error": f"Invalid strategy: {merge_strategy}. Valid: merge, squash, rebase", "code": ErrorCode.INVALID_REQUEST.value} + + owner, repo = provider.parse_repo_url(workspace.repo_url) + result = await provider.merge_pr( + owner, repo, pr_number, + merge_strategy=strategy, + commit_message=commit_message, + delete_branch=delete_branch, + ) + + return { + "success": result.success, + "merge_commit_sha": result.merge_commit_sha, + "branch_deleted": result.branch_deleted, + "error": result.error, + } + + except GitOpsError as e: + logger.error(f"Merge PR error: {e}") + return {"success": False, "error": e.message, "code": e.code.value} + except Exception as e: + logger.error(f"Unexpected merge PR error: {e}") + return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value} + + +# MCP Tools - Workspace Operations + + +@mcp.tool() +async def get_workspace( + project_id: str = Field(..., description="Project ID"), + agent_id: str = Field(..., description="Agent ID making the request"), +) -> dict[str, Any]: + """ + Get workspace information for a project. + """ + try: + if error := _validate_id(project_id, "project_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + if error := _validate_id(agent_id, "agent_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + + workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr] + if not workspace: + return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value} + + return { + "success": True, + "workspace": workspace.to_dict(), + } + + except GitOpsError as e: + logger.error(f"Get workspace error: {e}") + return {"success": False, "error": e.message, "code": e.code.value} + except Exception as e: + logger.error(f"Unexpected get workspace error: {e}") + return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value} + + +@mcp.tool() +async def lock_workspace( + project_id: str = Field(..., description="Project ID"), + agent_id: str = Field(..., description="Agent ID requesting lock"), + timeout: int = Field(default=300, ge=10, le=3600, description="Lock timeout in seconds"), +) -> dict[str, Any]: + """ + Acquire a lock on a workspace. + + Prevents other agents from making changes during critical operations. + """ + try: + if error := _validate_id(project_id, "project_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + if error := _validate_id(agent_id, "agent_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + + success = await _workspace_manager.lock_workspace(project_id, agent_id, timeout) # type: ignore[union-attr] + workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr] + + return { + "success": success, + "lock_holder": workspace.lock_holder if workspace else None, + "lock_expires": workspace.lock_expires.isoformat() if workspace and workspace.lock_expires else None, + } + + except GitOpsError as e: + logger.error(f"Lock workspace error: {e}") + return {"success": False, "error": e.message, "code": e.code.value} + except Exception as e: + logger.error(f"Unexpected lock workspace error: {e}") + return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value} + + +@mcp.tool() +async def unlock_workspace( + project_id: str = Field(..., description="Project ID"), + agent_id: str = Field(..., description="Agent ID releasing lock"), + force: bool = Field(default=False, description="Force unlock (admin only)"), +) -> dict[str, Any]: + """ + Release a lock on a workspace. + """ + try: + if error := _validate_id(project_id, "project_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + if error := _validate_id(agent_id, "agent_id"): + return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value} + + success = await _workspace_manager.unlock_workspace(project_id, agent_id, force) # type: ignore[union-attr] + + return {"success": success} + + except GitOpsError as e: + logger.error(f"Unlock workspace error: {e}") + return {"success": False, "error": e.message, "code": e.code.value} + except Exception as e: + logger.error(f"Unexpected unlock workspace error: {e}") + return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value} + + +# Register all tools +_register_tool("clone_repository", clone_repository) +_register_tool("git_status", git_status) +_register_tool("create_branch", create_branch) +_register_tool("list_branches", list_branches) +_register_tool("checkout", checkout) +_register_tool("commit", commit) +_register_tool("push", push) +_register_tool("pull", pull) +_register_tool("diff", diff) +_register_tool("log", log) +_register_tool("create_pull_request", create_pull_request) +_register_tool("get_pull_request", get_pull_request) +_register_tool("list_pull_requests", list_pull_requests) +_register_tool("merge_pull_request", merge_pull_request) +_register_tool("get_workspace", get_workspace) +_register_tool("lock_workspace", lock_workspace) +_register_tool("unlock_workspace", unlock_workspace) + + +def main() -> None: + """Run the server.""" + import uvicorn + + settings = get_settings() + + uvicorn.run( + "server:app", + host=settings.host, + port=settings.port, + reload=settings.debug, + log_level="info", + ) + + +if __name__ == "__main__": + main() diff --git a/mcp-servers/git-ops/tests/__init__.py b/mcp-servers/git-ops/tests/__init__.py new file mode 100644 index 0000000..9b70e89 --- /dev/null +++ b/mcp-servers/git-ops/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for Git Operations MCP Server.""" diff --git a/mcp-servers/git-ops/tests/conftest.py b/mcp-servers/git-ops/tests/conftest.py new file mode 100644 index 0000000..b83f8e5 --- /dev/null +++ b/mcp-servers/git-ops/tests/conftest.py @@ -0,0 +1,299 @@ +""" +Test configuration and fixtures for Git Operations MCP Server. +""" + +import os +import shutil +import tempfile +from collections.abc import AsyncIterator, Iterator +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest +from git import Repo as GitRepo + +# Set test environment +os.environ["IS_TEST"] = "true" +os.environ["GIT_OPS_WORKSPACE_BASE_PATH"] = "/tmp/test-workspaces" +os.environ["GIT_OPS_GITEA_BASE_URL"] = "https://gitea.test.com" +os.environ["GIT_OPS_GITEA_TOKEN"] = "test-token" + + +@pytest.fixture(scope="session", autouse=True) +def reset_settings_session(): + """Reset settings at start and end of test session.""" + from config import reset_settings + + reset_settings() + yield + reset_settings() + + +@pytest.fixture +def reset_settings(): + """Reset settings before each test that needs it.""" + from config import reset_settings + + reset_settings() + yield + reset_settings() + + +@pytest.fixture +def test_settings(): + """Get test settings.""" + from config import Settings + + return Settings( + workspace_base_path=Path("/tmp/test-workspaces"), + gitea_base_url="https://gitea.test.com", + gitea_token="test-token", + github_token="github-test-token", + git_author_name="Test Agent", + git_author_email="test@syndarix.ai", + enable_force_push=False, + debug=True, + ) + + +@pytest.fixture +def temp_dir() -> Iterator[Path]: + """Create a temporary directory for tests.""" + temp_path = Path(tempfile.mkdtemp()) + yield temp_path + if temp_path.exists(): + shutil.rmtree(temp_path) + + +@pytest.fixture +def temp_workspace(temp_dir: Path) -> Path: + """Create a temporary workspace directory.""" + workspace = temp_dir / "workspace" + workspace.mkdir(parents=True, exist_ok=True) + return workspace + + +@pytest.fixture +def git_repo(temp_workspace: Path) -> GitRepo: + """Create a git repository in the temp workspace.""" + # Initialize with main branch (Git 2.28+) + repo = GitRepo.init(temp_workspace, initial_branch="main") + + # Configure git + with repo.config_writer() as cw: + cw.set_value("user", "name", "Test User") + cw.set_value("user", "email", "test@example.com") + + # Create initial commit + test_file = temp_workspace / "README.md" + test_file.write_text("# Test Repository\n") + repo.index.add(["README.md"]) + repo.index.commit("Initial commit") + + return repo + + +@pytest.fixture +def git_repo_with_remote(git_repo: GitRepo, temp_dir: Path) -> tuple[GitRepo, GitRepo]: + """Create a git repository with a 'remote' (bare repo).""" + # Create bare repo as remote + remote_path = temp_dir / "remote.git" + remote_repo = GitRepo.init(remote_path, bare=True) + + # Add remote to main repo + git_repo.create_remote("origin", str(remote_path)) + + # Push initial commit + git_repo.remotes.origin.push("main:main") + + # Set up tracking + git_repo.heads.main.set_tracking_branch(git_repo.remotes.origin.refs.main) + + return git_repo, remote_repo + + +@pytest.fixture +def workspace_manager(temp_dir: Path, test_settings): + """Create a WorkspaceManager with test settings.""" + from workspace import WorkspaceManager + + test_settings.workspace_base_path = temp_dir / "workspaces" + return WorkspaceManager(test_settings) + + +@pytest.fixture +def git_wrapper(temp_workspace: Path, test_settings): + """Create a GitWrapper for the temp workspace.""" + from git_wrapper import GitWrapper + + return GitWrapper(temp_workspace, test_settings) + + +@pytest.fixture +def git_wrapper_with_repo(git_repo: GitRepo, test_settings): + """Create a GitWrapper for a repo that's already initialized.""" + from git_wrapper import GitWrapper + + return GitWrapper(Path(git_repo.working_dir), test_settings) + + +@pytest.fixture +def mock_gitea_provider(): + """Create a mock Gitea provider.""" + provider = AsyncMock() + provider.name = "gitea" + provider.is_connected = AsyncMock(return_value=True) + provider.get_authenticated_user = AsyncMock(return_value="test-user") + provider.parse_repo_url = MagicMock(return_value=("owner", "repo")) + return provider + + +@pytest.fixture +def mock_httpx_client(): + """Create a mock httpx client for provider tests.""" + from unittest.mock import AsyncMock + + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = MagicMock(return_value={}) + mock_response.text = "" + + mock_client = AsyncMock() + mock_client.request = AsyncMock(return_value=mock_response) + mock_client.get = AsyncMock(return_value=mock_response) + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.patch = AsyncMock(return_value=mock_response) + mock_client.delete = AsyncMock(return_value=mock_response) + + return mock_client + + +@pytest.fixture +async def gitea_provider(test_settings, mock_httpx_client): + """Create a GiteaProvider with mocked HTTP client.""" + from providers.gitea import GiteaProvider + + provider = GiteaProvider( + base_url=test_settings.gitea_base_url, + token=test_settings.gitea_token, + settings=test_settings, + ) + provider._client = mock_httpx_client + + yield provider + + await provider.close() + + +@pytest.fixture +def sample_pr_data(): + """Sample PR data from Gitea API.""" + return { + "number": 42, + "title": "Test PR", + "body": "This is a test pull request", + "state": "open", + "head": {"ref": "feature-branch"}, + "base": {"ref": "main"}, + "user": {"login": "test-user"}, + "created_at": "2024-01-15T10:00:00Z", + "updated_at": "2024-01-15T12:00:00Z", + "merged_at": None, + "closed_at": None, + "html_url": "https://gitea.test.com/owner/repo/pull/42", + "labels": [{"name": "enhancement"}], + "assignees": [{"login": "assignee1"}], + "requested_reviewers": [{"login": "reviewer1"}], + "mergeable": True, + "draft": False, + } + + +@pytest.fixture +def sample_commit_data(): + """Sample commit data.""" + return { + "sha": "abc123def456", + "short_sha": "abc123d", + "message": "Test commit message", + "author": { + "name": "Test Author", + "email": "author@test.com", + "date": "2024-01-15T10:00:00Z", + }, + "committer": { + "name": "Test Committer", + "email": "committer@test.com", + "date": "2024-01-15T10:00:00Z", + }, + } + + +@pytest.fixture +def mock_fastapi_app(): + """Create a test FastAPI app.""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + app = FastAPI() + + @app.get("/health") + def health(): + return {"status": "healthy"} + + return TestClient(app) + + +# Async fixtures + + +@pytest.fixture +async def async_workspace_manager( + temp_dir: Path, test_settings +) -> AsyncIterator: + """Async fixture for workspace manager.""" + from workspace import WorkspaceManager + + test_settings.workspace_base_path = temp_dir / "workspaces" + manager = WorkspaceManager(test_settings) + yield manager + + +# Test data fixtures + + +@pytest.fixture +def valid_project_id() -> str: + """Valid project ID for tests.""" + return "test-project-123" + + +@pytest.fixture +def valid_agent_id() -> str: + """Valid agent ID for tests.""" + return "agent-456" + + +@pytest.fixture +def invalid_ids() -> list[str]: + """Invalid IDs for validation tests.""" + return [ + "", + " ", + "a" * 200, # Too long + "test@invalid", # Invalid character + "test!invalid", + "../path/traversal", + ] + + +@pytest.fixture +def sample_repo_url() -> str: + """Sample repository URL.""" + return "https://gitea.test.com/owner/repo.git" + + +@pytest.fixture +def sample_ssh_repo_url() -> str: + """Sample SSH repository URL.""" + return "git@gitea.test.com:owner/repo.git" diff --git a/mcp-servers/git-ops/tests/test_git_wrapper.py b/mcp-servers/git-ops/tests/test_git_wrapper.py new file mode 100644 index 0000000..61c4678 --- /dev/null +++ b/mcp-servers/git-ops/tests/test_git_wrapper.py @@ -0,0 +1,434 @@ +""" +Tests for the git_wrapper module. +""" + +from pathlib import Path + +import pytest + +from exceptions import ( + BranchExistsError, + BranchNotFoundError, + CheckoutError, + CommitError, + GitError, +) +from git_wrapper import GitWrapper +from models import FileChangeType + + +class TestGitWrapperInit: + """Tests for GitWrapper initialization.""" + + def test_init_with_valid_path(self, temp_workspace, test_settings): + """Test initialization with a valid path.""" + wrapper = GitWrapper(temp_workspace, test_settings) + assert wrapper.workspace_path == temp_workspace + assert wrapper.settings == test_settings + + def test_repo_property_raises_on_non_git(self, temp_workspace, test_settings): + """Test that accessing repo on non-git dir raises error.""" + wrapper = GitWrapper(temp_workspace, test_settings) + with pytest.raises(GitError, match="Not a git repository"): + _ = wrapper.repo + + def test_repo_property_works_on_git_dir(self, git_repo, test_settings): + """Test that repo property works for git directory.""" + wrapper = GitWrapper(Path(git_repo.working_dir), test_settings) + assert wrapper.repo is not None + assert wrapper.repo.head is not None + + +class TestGitWrapperStatus: + """Tests for git status operations.""" + + @pytest.mark.asyncio + async def test_status_clean_repo(self, git_wrapper_with_repo): + """Test status on a clean repository.""" + result = await git_wrapper_with_repo.status() + + assert result.branch == "main" + assert result.is_clean is True + assert len(result.staged) == 0 + assert len(result.unstaged) == 0 + assert len(result.untracked) == 0 + + @pytest.mark.asyncio + async def test_status_with_untracked(self, git_wrapper_with_repo, git_repo): + """Test status with untracked files.""" + # Create untracked file + untracked_file = Path(git_repo.working_dir) / "untracked.txt" + untracked_file.write_text("untracked content") + + result = await git_wrapper_with_repo.status() + + assert result.is_clean is False + assert "untracked.txt" in result.untracked + + @pytest.mark.asyncio + async def test_status_with_modified(self, git_wrapper_with_repo, git_repo): + """Test status with modified files.""" + # Modify existing file + readme = Path(git_repo.working_dir) / "README.md" + readme.write_text("# Modified content\n") + + result = await git_wrapper_with_repo.status() + + assert result.is_clean is False + assert len(result.unstaged) > 0 + + @pytest.mark.asyncio + async def test_status_with_staged(self, git_wrapper_with_repo, git_repo): + """Test status with staged changes.""" + # Create and stage a file + new_file = Path(git_repo.working_dir) / "staged.txt" + new_file.write_text("staged content") + git_repo.index.add(["staged.txt"]) + + result = await git_wrapper_with_repo.status() + + assert result.is_clean is False + assert len(result.staged) > 0 + + @pytest.mark.asyncio + async def test_status_exclude_untracked(self, git_wrapper_with_repo, git_repo): + """Test status without untracked files.""" + untracked_file = Path(git_repo.working_dir) / "untracked.txt" + untracked_file.write_text("untracked") + + result = await git_wrapper_with_repo.status(include_untracked=False) + + assert len(result.untracked) == 0 + + +class TestGitWrapperBranch: + """Tests for branch operations.""" + + @pytest.mark.asyncio + async def test_create_branch(self, git_wrapper_with_repo): + """Test creating a new branch.""" + result = await git_wrapper_with_repo.create_branch("feature-test") + + assert result.success is True + assert result.branch == "feature-test" + assert result.is_current is True + + @pytest.mark.asyncio + async def test_create_branch_without_checkout(self, git_wrapper_with_repo): + """Test creating branch without checkout.""" + result = await git_wrapper_with_repo.create_branch("feature-no-checkout", checkout=False) + + assert result.success is True + assert result.branch == "feature-no-checkout" + assert result.is_current is False + + @pytest.mark.asyncio + async def test_create_branch_exists_error(self, git_wrapper_with_repo): + """Test error when branch already exists.""" + await git_wrapper_with_repo.create_branch("existing-branch", checkout=False) + + with pytest.raises(BranchExistsError): + await git_wrapper_with_repo.create_branch("existing-branch") + + @pytest.mark.asyncio + async def test_delete_branch(self, git_wrapper_with_repo): + """Test deleting a branch.""" + # Create branch first + await git_wrapper_with_repo.create_branch("to-delete", checkout=False) + + # Delete it + result = await git_wrapper_with_repo.delete_branch("to-delete") + + assert result.success is True + assert result.branch == "to-delete" + + @pytest.mark.asyncio + async def test_delete_branch_not_found(self, git_wrapper_with_repo): + """Test error when deleting non-existent branch.""" + with pytest.raises(BranchNotFoundError): + await git_wrapper_with_repo.delete_branch("nonexistent") + + @pytest.mark.asyncio + async def test_delete_current_branch_error(self, git_wrapper_with_repo): + """Test error when deleting current branch.""" + with pytest.raises(GitError, match="Cannot delete current branch"): + await git_wrapper_with_repo.delete_branch("main") + + @pytest.mark.asyncio + async def test_list_branches(self, git_wrapper_with_repo): + """Test listing branches.""" + # Create some branches + await git_wrapper_with_repo.create_branch("branch-a", checkout=False) + await git_wrapper_with_repo.create_branch("branch-b", checkout=False) + + result = await git_wrapper_with_repo.list_branches() + + assert result.current_branch == "main" + branch_names = [b["name"] for b in result.local_branches] + assert "main" in branch_names + assert "branch-a" in branch_names + assert "branch-b" in branch_names + + +class TestGitWrapperCheckout: + """Tests for checkout operations.""" + + @pytest.mark.asyncio + async def test_checkout_existing_branch(self, git_wrapper_with_repo): + """Test checkout of existing branch.""" + # Create branch first + await git_wrapper_with_repo.create_branch("test-branch", checkout=False) + + result = await git_wrapper_with_repo.checkout("test-branch") + + assert result.success is True + assert result.ref == "test-branch" + + @pytest.mark.asyncio + async def test_checkout_create_new(self, git_wrapper_with_repo): + """Test checkout with branch creation.""" + result = await git_wrapper_with_repo.checkout("new-branch", create_branch=True) + + assert result.success is True + assert result.ref == "new-branch" + + @pytest.mark.asyncio + async def test_checkout_nonexistent_error(self, git_wrapper_with_repo): + """Test error when checking out non-existent ref.""" + with pytest.raises(CheckoutError): + await git_wrapper_with_repo.checkout("nonexistent-branch") + + +class TestGitWrapperCommit: + """Tests for commit operations.""" + + @pytest.mark.asyncio + async def test_commit_staged_changes(self, git_wrapper_with_repo, git_repo): + """Test committing staged changes.""" + # Create and stage a file + new_file = Path(git_repo.working_dir) / "newfile.txt" + new_file.write_text("new content") + git_repo.index.add(["newfile.txt"]) + + result = await git_wrapper_with_repo.commit("Add new file") + + assert result.success is True + assert result.message == "Add new file" + assert result.files_changed == 1 + + @pytest.mark.asyncio + async def test_commit_all_changes(self, git_wrapper_with_repo, git_repo): + """Test committing all changes (auto-stage).""" + # Create a file without staging + new_file = Path(git_repo.working_dir) / "unstaged.txt" + new_file.write_text("content") + + result = await git_wrapper_with_repo.commit("Commit unstaged") + + assert result.success is True + + @pytest.mark.asyncio + async def test_commit_nothing_to_commit(self, git_wrapper_with_repo): + """Test error when nothing to commit.""" + with pytest.raises(CommitError, match="Nothing to commit"): + await git_wrapper_with_repo.commit("Empty commit") + + @pytest.mark.asyncio + async def test_commit_with_author(self, git_wrapper_with_repo, git_repo): + """Test commit with custom author.""" + new_file = Path(git_repo.working_dir) / "authored.txt" + new_file.write_text("authored content") + + result = await git_wrapper_with_repo.commit( + "Custom author commit", + author_name="Custom Author", + author_email="custom@test.com", + ) + + assert result.success is True + + +class TestGitWrapperDiff: + """Tests for diff operations.""" + + @pytest.mark.asyncio + async def test_diff_no_changes(self, git_wrapper_with_repo): + """Test diff with no changes.""" + result = await git_wrapper_with_repo.diff() + + assert result.files_changed == 0 + assert result.total_additions == 0 + assert result.total_deletions == 0 + + @pytest.mark.asyncio + async def test_diff_with_changes(self, git_wrapper_with_repo, git_repo): + """Test diff with modified files.""" + # Modify a file + readme = Path(git_repo.working_dir) / "README.md" + readme.write_text("# Modified\nNew line\n") + + result = await git_wrapper_with_repo.diff() + + assert result.files_changed > 0 + + +class TestGitWrapperLog: + """Tests for log operations.""" + + @pytest.mark.asyncio + async def test_log_basic(self, git_wrapper_with_repo): + """Test basic log.""" + result = await git_wrapper_with_repo.log() + + assert result.total_commits > 0 + assert len(result.commits) > 0 + + @pytest.mark.asyncio + async def test_log_with_limit(self, git_wrapper_with_repo, git_repo): + """Test log with limit.""" + # Create more commits + for i in range(5): + file_path = Path(git_repo.working_dir) / f"file{i}.txt" + file_path.write_text(f"content {i}") + git_repo.index.add([f"file{i}.txt"]) + git_repo.index.commit(f"Commit {i}") + + result = await git_wrapper_with_repo.log(limit=3) + + assert len(result.commits) == 3 + + @pytest.mark.asyncio + async def test_log_commit_info(self, git_wrapper_with_repo): + """Test that log returns proper commit info.""" + result = await git_wrapper_with_repo.log(limit=1) + + commit = result.commits[0] + assert "sha" in commit + assert "message" in commit + assert "author_name" in commit + assert "author_email" in commit + + +class TestGitWrapperUtilities: + """Tests for utility methods.""" + + @pytest.mark.asyncio + async def test_is_valid_ref_true(self, git_wrapper_with_repo): + """Test valid ref detection.""" + is_valid = await git_wrapper_with_repo.is_valid_ref("main") + assert is_valid is True + + @pytest.mark.asyncio + async def test_is_valid_ref_false(self, git_wrapper_with_repo): + """Test invalid ref detection.""" + is_valid = await git_wrapper_with_repo.is_valid_ref("nonexistent") + assert is_valid is False + + def test_diff_to_change_type(self, git_wrapper_with_repo): + """Test change type conversion.""" + wrapper = git_wrapper_with_repo + + assert wrapper._diff_to_change_type("A") == FileChangeType.ADDED + assert wrapper._diff_to_change_type("M") == FileChangeType.MODIFIED + assert wrapper._diff_to_change_type("D") == FileChangeType.DELETED + assert wrapper._diff_to_change_type("R") == FileChangeType.RENAMED + + +class TestGitWrapperStage: + """Tests for staging operations.""" + + @pytest.mark.asyncio + async def test_stage_specific_files(self, git_wrapper_with_repo, git_repo): + """Test staging specific files.""" + # Create files + file1 = Path(git_repo.working_dir) / "file1.txt" + file2 = Path(git_repo.working_dir) / "file2.txt" + file1.write_text("content 1") + file2.write_text("content 2") + + count = await git_wrapper_with_repo.stage(["file1.txt"]) + + assert count == 1 + + @pytest.mark.asyncio + async def test_stage_all(self, git_wrapper_with_repo, git_repo): + """Test staging all files.""" + file1 = Path(git_repo.working_dir) / "all1.txt" + file2 = Path(git_repo.working_dir) / "all2.txt" + file1.write_text("content 1") + file2.write_text("content 2") + + count = await git_wrapper_with_repo.stage() + + assert count >= 2 + + @pytest.mark.asyncio + async def test_unstage_files(self, git_wrapper_with_repo, git_repo): + """Test unstaging files.""" + # Create and stage file + file1 = Path(git_repo.working_dir) / "unstage.txt" + file1.write_text("to unstage") + git_repo.index.add(["unstage.txt"]) + + count = await git_wrapper_with_repo.unstage() + + assert count >= 1 + + +class TestGitWrapperReset: + """Tests for reset operations.""" + + @pytest.mark.asyncio + async def test_reset_soft(self, git_wrapper_with_repo, git_repo): + """Test soft reset.""" + # Create a commit to reset + file1 = Path(git_repo.working_dir) / "reset_soft.txt" + file1.write_text("content") + git_repo.index.add(["reset_soft.txt"]) + git_repo.index.commit("Commit to reset") + + result = await git_wrapper_with_repo.reset("HEAD~1", mode="soft") + + assert result is True + + @pytest.mark.asyncio + async def test_reset_mixed(self, git_wrapper_with_repo, git_repo): + """Test mixed reset (default).""" + file1 = Path(git_repo.working_dir) / "reset_mixed.txt" + file1.write_text("content") + git_repo.index.add(["reset_mixed.txt"]) + git_repo.index.commit("Commit to reset") + + result = await git_wrapper_with_repo.reset("HEAD~1", mode="mixed") + + assert result is True + + @pytest.mark.asyncio + async def test_reset_invalid_mode(self, git_wrapper_with_repo): + """Test error on invalid reset mode.""" + with pytest.raises(GitError, match="Invalid reset mode"): + await git_wrapper_with_repo.reset("HEAD", mode="invalid") + + +class TestGitWrapperStash: + """Tests for stash operations.""" + + @pytest.mark.asyncio + async def test_stash_changes(self, git_wrapper_with_repo, git_repo): + """Test stashing changes.""" + # Make changes + readme = Path(git_repo.working_dir) / "README.md" + readme.write_text("Modified for stash") + + result = await git_wrapper_with_repo.stash("Test stash") + + # Result should be stash ref or None if nothing to stash + # (depends on whether changes were already staged) + assert result is None or result.startswith("stash@") + + @pytest.mark.asyncio + async def test_stash_nothing(self, git_wrapper_with_repo): + """Test stash with no changes.""" + result = await git_wrapper_with_repo.stash() + + assert result is None diff --git a/mcp-servers/git-ops/tests/test_providers.py b/mcp-servers/git-ops/tests/test_providers.py new file mode 100644 index 0000000..d0813b0 --- /dev/null +++ b/mcp-servers/git-ops/tests/test_providers.py @@ -0,0 +1,484 @@ +""" +Tests for git provider implementations. +""" + +from unittest.mock import MagicMock + +import pytest + +from exceptions import APIError, AuthenticationError +from models import MergeStrategy, PRState +from providers.gitea import GiteaProvider + + +class TestBaseProvider: + """Tests for BaseProvider interface.""" + + def test_parse_repo_url_https(self, mock_gitea_provider): + """Test parsing HTTPS repo URL.""" + # The mock needs parse_repo_url to work + provider = GiteaProvider( + base_url="https://gitea.test.com", + token="test-token" + ) + + owner, repo = provider.parse_repo_url("https://gitea.test.com/owner/repo.git") + + assert owner == "owner" + assert repo == "repo" + + def test_parse_repo_url_https_no_git(self): + """Test parsing HTTPS URL without .git suffix.""" + provider = GiteaProvider( + base_url="https://gitea.test.com", + token="test-token" + ) + + owner, repo = provider.parse_repo_url("https://gitea.test.com/owner/repo") + + assert owner == "owner" + assert repo == "repo" + + def test_parse_repo_url_ssh(self): + """Test parsing SSH repo URL.""" + provider = GiteaProvider( + base_url="https://gitea.test.com", + token="test-token" + ) + + owner, repo = provider.parse_repo_url("git@gitea.test.com:owner/repo.git") + + assert owner == "owner" + assert repo == "repo" + + def test_parse_repo_url_invalid(self): + """Test error on invalid URL.""" + provider = GiteaProvider( + base_url="https://gitea.test.com", + token="test-token" + ) + + with pytest.raises(ValueError, match="Unable to parse"): + provider.parse_repo_url("invalid-url") + + +class TestGiteaProvider: + """Tests for GiteaProvider.""" + + @pytest.mark.asyncio + async def test_is_connected(self, gitea_provider, mock_httpx_client): + """Test connection check.""" + mock_httpx_client.request.return_value.json = MagicMock( + return_value={"login": "test-user"} + ) + + result = await gitea_provider.is_connected() + + assert result is True + + @pytest.mark.asyncio + async def test_is_connected_no_token(self, test_settings): + """Test connection fails without token.""" + provider = GiteaProvider( + base_url="https://gitea.test.com", + token="", + settings=test_settings, + ) + + result = await provider.is_connected() + assert result is False + + await provider.close() + + @pytest.mark.asyncio + async def test_get_authenticated_user(self, gitea_provider, mock_httpx_client): + """Test getting authenticated user.""" + mock_httpx_client.request.return_value.json = MagicMock( + return_value={"login": "test-user"} + ) + + user = await gitea_provider.get_authenticated_user() + + assert user == "test-user" + + @pytest.mark.asyncio + async def test_get_repo_info(self, gitea_provider, mock_httpx_client): + """Test getting repository info.""" + mock_httpx_client.request.return_value.json = MagicMock( + return_value={ + "name": "repo", + "full_name": "owner/repo", + "default_branch": "main", + } + ) + + result = await gitea_provider.get_repo_info("owner", "repo") + + assert result["name"] == "repo" + assert result["default_branch"] == "main" + + @pytest.mark.asyncio + async def test_get_default_branch(self, gitea_provider, mock_httpx_client): + """Test getting default branch.""" + mock_httpx_client.request.return_value.json = MagicMock( + return_value={"default_branch": "develop"} + ) + + branch = await gitea_provider.get_default_branch("owner", "repo") + + assert branch == "develop" + + +class TestGiteaPROperations: + """Tests for Gitea PR operations.""" + + @pytest.mark.asyncio + async def test_create_pr(self, gitea_provider, mock_httpx_client): + """Test creating a pull request.""" + mock_httpx_client.request.return_value.json = MagicMock( + return_value={ + "number": 42, + "html_url": "https://gitea.test.com/owner/repo/pull/42", + } + ) + + result = await gitea_provider.create_pr( + owner="owner", + repo="repo", + title="Test PR", + body="Test body", + source_branch="feature", + target_branch="main", + ) + + assert result.success is True + assert result.pr_number == 42 + assert result.pr_url == "https://gitea.test.com/owner/repo/pull/42" + + @pytest.mark.asyncio + async def test_create_pr_with_options(self, gitea_provider, mock_httpx_client): + """Test creating PR with labels, assignees, reviewers.""" + # Use side_effect for multiple API calls: + # 1. POST create PR + # 2. GET labels (for "enhancement") - in add_labels -> _get_or_create_label + # 3. POST add labels to PR - in add_labels + # 4. GET issue to return labels - in add_labels + # 5. PATCH add assignees + # 6. POST request reviewers + mock_responses = [ + {"number": 43, "html_url": "https://gitea.test.com/owner/repo/pull/43"}, # Create PR + [{"id": 1, "name": "enhancement"}], # GET labels (found) + {}, # POST add labels to PR + {"labels": [{"name": "enhancement"}]}, # GET issue to return current labels + {}, # PATCH add assignees + {}, # POST request reviewers + ] + mock_httpx_client.request.return_value.json = MagicMock(side_effect=mock_responses) + + result = await gitea_provider.create_pr( + owner="owner", + repo="repo", + title="Test PR", + body="Test body", + source_branch="feature", + target_branch="main", + labels=["enhancement"], + assignees=["user1"], + reviewers=["reviewer1"], + ) + + assert result.success is True + + @pytest.mark.asyncio + async def test_get_pr(self, gitea_provider, mock_httpx_client, sample_pr_data): + """Test getting a pull request.""" + mock_httpx_client.request.return_value.json = MagicMock( + return_value=sample_pr_data + ) + + result = await gitea_provider.get_pr("owner", "repo", 42) + + assert result.success is True + assert result.pr["number"] == 42 + assert result.pr["title"] == "Test PR" + + @pytest.mark.asyncio + async def test_get_pr_not_found(self, gitea_provider, mock_httpx_client): + """Test getting non-existent PR.""" + mock_httpx_client.request.return_value.status_code = 404 + mock_httpx_client.request.return_value.json = MagicMock(return_value=None) + + result = await gitea_provider.get_pr("owner", "repo", 999) + + assert result.success is False + + @pytest.mark.asyncio + async def test_list_prs(self, gitea_provider, mock_httpx_client, sample_pr_data): + """Test listing pull requests.""" + mock_httpx_client.request.return_value.json = MagicMock( + return_value=[sample_pr_data, sample_pr_data] + ) + + result = await gitea_provider.list_prs("owner", "repo") + + assert result.success is True + assert len(result.pull_requests) == 2 + + @pytest.mark.asyncio + async def test_list_prs_with_state_filter(self, gitea_provider, mock_httpx_client, sample_pr_data): + """Test listing PRs with state filter.""" + mock_httpx_client.request.return_value.json = MagicMock( + return_value=[sample_pr_data] + ) + + result = await gitea_provider.list_prs( + "owner", "repo", state=PRState.OPEN + ) + + assert result.success is True + + @pytest.mark.asyncio + async def test_merge_pr(self, gitea_provider, mock_httpx_client): + """Test merging a pull request.""" + # First call returns merge result + mock_httpx_client.request.return_value.json = MagicMock( + return_value={"sha": "merge-commit-sha"} + ) + + result = await gitea_provider.merge_pr( + "owner", "repo", 42, + merge_strategy=MergeStrategy.SQUASH, + ) + + assert result.success is True + assert result.merge_commit_sha == "merge-commit-sha" + + @pytest.mark.asyncio + async def test_update_pr(self, gitea_provider, mock_httpx_client, sample_pr_data): + """Test updating a pull request.""" + mock_httpx_client.request.return_value.json = MagicMock( + return_value=sample_pr_data + ) + + result = await gitea_provider.update_pr( + "owner", "repo", 42, + title="Updated Title", + body="Updated body", + ) + + assert result.success is True + + @pytest.mark.asyncio + async def test_close_pr(self, gitea_provider, mock_httpx_client, sample_pr_data): + """Test closing a pull request.""" + sample_pr_data["state"] = "closed" + mock_httpx_client.request.return_value.json = MagicMock( + return_value=sample_pr_data + ) + + result = await gitea_provider.close_pr("owner", "repo", 42) + + assert result.success is True + + +class TestGiteaBranchOperations: + """Tests for Gitea branch operations.""" + + @pytest.mark.asyncio + async def test_get_branch(self, gitea_provider, mock_httpx_client): + """Test getting branch info.""" + mock_httpx_client.request.return_value.json = MagicMock( + return_value={ + "name": "main", + "commit": {"sha": "abc123"}, + } + ) + + result = await gitea_provider.get_branch("owner", "repo", "main") + + assert result["name"] == "main" + + @pytest.mark.asyncio + async def test_delete_remote_branch(self, gitea_provider, mock_httpx_client): + """Test deleting a remote branch.""" + mock_httpx_client.request.return_value.status_code = 204 + + result = await gitea_provider.delete_remote_branch("owner", "repo", "old-branch") + + assert result is True + + +class TestGiteaCommentOperations: + """Tests for Gitea comment operations.""" + + @pytest.mark.asyncio + async def test_add_pr_comment(self, gitea_provider, mock_httpx_client): + """Test adding a comment to a PR.""" + mock_httpx_client.request.return_value.json = MagicMock( + return_value={"id": 1, "body": "Test comment"} + ) + + result = await gitea_provider.add_pr_comment( + "owner", "repo", 42, "Test comment" + ) + + assert result["body"] == "Test comment" + + @pytest.mark.asyncio + async def test_list_pr_comments(self, gitea_provider, mock_httpx_client): + """Test listing PR comments.""" + mock_httpx_client.request.return_value.json = MagicMock( + return_value=[ + {"id": 1, "body": "Comment 1"}, + {"id": 2, "body": "Comment 2"}, + ] + ) + + result = await gitea_provider.list_pr_comments("owner", "repo", 42) + + assert len(result) == 2 + + +class TestGiteaLabelOperations: + """Tests for Gitea label operations.""" + + @pytest.mark.asyncio + async def test_add_labels(self, gitea_provider, mock_httpx_client): + """Test adding labels to a PR.""" + # Use side_effect to return different values for different calls + # 1. GET labels (for "bug") - returns existing labels + # 2. POST to create "bug" label + # 3. GET labels (for "urgent") + # 4. POST to create "urgent" label + # 5. POST labels to PR + # 6. GET issue to return final labels + mock_responses = [ + [{"id": 1, "name": "existing"}], # GET labels (bug not found) + {"id": 2, "name": "bug"}, # POST create bug + [{"id": 1, "name": "existing"}, {"id": 2, "name": "bug"}], # GET labels (urgent not found) + {"id": 3, "name": "urgent"}, # POST create urgent + {}, # POST add labels to PR + {"labels": [{"name": "bug"}, {"name": "urgent"}]}, # GET issue + ] + mock_httpx_client.request.return_value.json = MagicMock(side_effect=mock_responses) + + result = await gitea_provider.add_labels( + "owner", "repo", 42, ["bug", "urgent"] + ) + + # Should return updated label list + assert isinstance(result, list) + + @pytest.mark.asyncio + async def test_remove_label(self, gitea_provider, mock_httpx_client): + """Test removing a label from a PR.""" + # Use side_effect for multiple calls + # 1. GET labels to find the label ID + # 2. DELETE the label from the PR + # 3. GET issue to return remaining labels + mock_responses = [ + [{"id": 1, "name": "bug"}], # GET labels + {}, # DELETE label + {"labels": []}, # GET issue + ] + mock_httpx_client.request.return_value.json = MagicMock(side_effect=mock_responses) + + result = await gitea_provider.remove_label( + "owner", "repo", 42, "bug" + ) + + assert isinstance(result, list) + + +class TestGiteaReviewerOperations: + """Tests for Gitea reviewer operations.""" + + @pytest.mark.asyncio + async def test_request_review(self, gitea_provider, mock_httpx_client): + """Test requesting review from users.""" + mock_httpx_client.request.return_value.json = MagicMock(return_value={}) + + result = await gitea_provider.request_review( + "owner", "repo", 42, ["reviewer1", "reviewer2"] + ) + + assert result == ["reviewer1", "reviewer2"] + + +class TestGiteaErrorHandling: + """Tests for error handling in Gitea provider.""" + + @pytest.mark.asyncio + async def test_authentication_error(self, gitea_provider, mock_httpx_client): + """Test handling authentication errors.""" + mock_httpx_client.request.return_value.status_code = 401 + + with pytest.raises(AuthenticationError): + await gitea_provider._request("GET", "/user") + + @pytest.mark.asyncio + async def test_permission_denied(self, gitea_provider, mock_httpx_client): + """Test handling permission denied errors.""" + mock_httpx_client.request.return_value.status_code = 403 + + with pytest.raises(AuthenticationError, match="Insufficient permissions"): + await gitea_provider._request("GET", "/protected") + + @pytest.mark.asyncio + async def test_api_error(self, gitea_provider, mock_httpx_client): + """Test handling general API errors.""" + mock_httpx_client.request.return_value.status_code = 500 + mock_httpx_client.request.return_value.text = "Internal Server Error" + mock_httpx_client.request.return_value.json = MagicMock( + return_value={"message": "Server error"} + ) + + with pytest.raises(APIError): + await gitea_provider._request("GET", "/error") + + +class TestGiteaPRParsing: + """Tests for PR data parsing.""" + + def test_parse_pr_open(self, gitea_provider, sample_pr_data): + """Test parsing open PR.""" + pr_info = gitea_provider._parse_pr(sample_pr_data) + + assert pr_info.number == 42 + assert pr_info.state == PRState.OPEN + assert pr_info.title == "Test PR" + assert pr_info.source_branch == "feature-branch" + assert pr_info.target_branch == "main" + + def test_parse_pr_merged(self, gitea_provider, sample_pr_data): + """Test parsing merged PR.""" + sample_pr_data["merged"] = True + sample_pr_data["merged_at"] = "2024-01-16T10:00:00Z" + + pr_info = gitea_provider._parse_pr(sample_pr_data) + + assert pr_info.state == PRState.MERGED + + def test_parse_pr_closed(self, gitea_provider, sample_pr_data): + """Test parsing closed PR.""" + sample_pr_data["state"] = "closed" + sample_pr_data["closed_at"] = "2024-01-16T10:00:00Z" + + pr_info = gitea_provider._parse_pr(sample_pr_data) + + assert pr_info.state == PRState.CLOSED + + def test_parse_datetime_iso(self, gitea_provider): + """Test parsing ISO datetime strings.""" + dt = gitea_provider._parse_datetime("2024-01-15T10:30:00Z") + + assert dt.year == 2024 + assert dt.month == 1 + assert dt.day == 15 + + def test_parse_datetime_none(self, gitea_provider): + """Test parsing None datetime returns now.""" + dt = gitea_provider._parse_datetime(None) + + assert dt is not None + assert dt.tzinfo is not None diff --git a/mcp-servers/git-ops/tests/test_server.py b/mcp-servers/git-ops/tests/test_server.py new file mode 100644 index 0000000..cb1866c --- /dev/null +++ b/mcp-servers/git-ops/tests/test_server.py @@ -0,0 +1,514 @@ +""" +Tests for the MCP server and tools. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from exceptions import ErrorCode + + +class TestInputValidation: + """Tests for input validation functions.""" + + def test_validate_id_valid(self): + """Test valid IDs pass validation.""" + from server import _validate_id + + assert _validate_id("test-123", "project_id") is None + assert _validate_id("my_project", "project_id") is None + assert _validate_id("Agent-001", "agent_id") is None + + def test_validate_id_empty(self): + """Test empty ID fails validation.""" + from server import _validate_id + + error = _validate_id("", "project_id") + assert error is not None + assert "required" in error.lower() + + def test_validate_id_too_long(self): + """Test too-long ID fails validation.""" + from server import _validate_id + + error = _validate_id("a" * 200, "project_id") + assert error is not None + assert "1-128" in error + + def test_validate_id_invalid_chars(self): + """Test invalid characters fail validation.""" + from server import _validate_id + + assert _validate_id("test@invalid", "project_id") is not None + assert _validate_id("test!project", "project_id") is not None + assert _validate_id("test project", "project_id") is not None + + def test_validate_branch_valid(self): + """Test valid branch names.""" + from server import _validate_branch + + assert _validate_branch("main") is None + assert _validate_branch("feature/new-thing") is None + assert _validate_branch("release-1.0.0") is None + assert _validate_branch("hotfix.urgent") is None + + def test_validate_branch_invalid(self): + """Test invalid branch names.""" + from server import _validate_branch + + assert _validate_branch("") is not None + assert _validate_branch("a" * 300) is not None + + def test_validate_url_valid(self): + """Test valid repository URLs.""" + from server import _validate_url + + assert _validate_url("https://github.com/owner/repo.git") is None + assert _validate_url("https://gitea.example.com/owner/repo") is None + assert _validate_url("git@github.com:owner/repo.git") is None + + def test_validate_url_invalid(self): + """Test invalid repository URLs.""" + from server import _validate_url + + assert _validate_url("") is not None + assert _validate_url("not-a-url") is not None + assert _validate_url("ftp://invalid.com/repo") is not None + + +class TestHealthCheck: + """Tests for health check endpoint.""" + + @pytest.mark.asyncio + async def test_health_check_structure(self): + """Test health check returns proper structure.""" + from server import health_check + + with patch("server._gitea_provider", None), \ + patch("server._workspace_manager", None): + result = await health_check() + + assert "status" in result + assert "service" in result + assert "version" in result + assert "timestamp" in result + assert "dependencies" in result + + @pytest.mark.asyncio + async def test_health_check_no_providers(self): + """Test health check without providers configured.""" + from server import health_check + + with patch("server._gitea_provider", None), \ + patch("server._workspace_manager", None): + result = await health_check() + + assert result["dependencies"]["gitea"] == "not configured" + + +class TestToolRegistry: + """Tests for tool registration.""" + + def test_tool_registry_populated(self): + """Test that tools are registered.""" + from server import _tool_registry + + assert len(_tool_registry) > 0 + assert "clone_repository" in _tool_registry + assert "git_status" in _tool_registry + assert "create_branch" in _tool_registry + assert "commit" in _tool_registry + + def test_tool_schema_structure(self): + """Test tool schemas have proper structure.""" + from server import _tool_registry + + for name, info in _tool_registry.items(): + assert "func" in info + assert "description" in info + assert "schema" in info + assert info["schema"]["type"] == "object" + assert "properties" in info["schema"] + + +class TestCloneRepository: + """Tests for clone_repository tool.""" + + @pytest.mark.asyncio + async def test_clone_invalid_project_id(self): + """Test clone with invalid project ID.""" + from server import clone_repository + + # Access the underlying function via .fn + result = await clone_repository.fn( + project_id="invalid@id", + agent_id="agent-1", + repo_url="https://github.com/owner/repo.git", + ) + + assert result["success"] is False + assert "project_id" in result["error"].lower() + + @pytest.mark.asyncio + async def test_clone_invalid_repo_url(self): + """Test clone with invalid repo URL.""" + from server import clone_repository + + result = await clone_repository.fn( + project_id="valid-project", + agent_id="agent-1", + repo_url="not-a-valid-url", + ) + + assert result["success"] is False + assert "url" in result["error"].lower() + + +class TestGitStatus: + """Tests for git_status tool.""" + + @pytest.mark.asyncio + async def test_status_workspace_not_found(self): + """Test status when workspace doesn't exist.""" + from server import git_status + + mock_manager = AsyncMock() + mock_manager.get_workspace = AsyncMock(return_value=None) + + with patch("server._workspace_manager", mock_manager): + result = await git_status.fn( + project_id="nonexistent", + agent_id="agent-1", + ) + + assert result["success"] is False + assert result["code"] == ErrorCode.WORKSPACE_NOT_FOUND.value + + +class TestBranchOperations: + """Tests for branch operation tools.""" + + @pytest.mark.asyncio + async def test_create_branch_invalid_name(self): + """Test creating branch with invalid name.""" + from server import create_branch + + result = await create_branch.fn( + project_id="test-project", + agent_id="agent-1", + branch_name="", # Invalid + ) + + assert result["success"] is False + + @pytest.mark.asyncio + async def test_list_branches_workspace_not_found(self): + """Test listing branches when workspace doesn't exist.""" + from server import list_branches + + mock_manager = AsyncMock() + mock_manager.get_workspace = AsyncMock(return_value=None) + + with patch("server._workspace_manager", mock_manager): + result = await list_branches.fn( + project_id="nonexistent", + agent_id="agent-1", + ) + + assert result["success"] is False + + @pytest.mark.asyncio + async def test_checkout_invalid_project(self): + """Test checkout with invalid project ID.""" + from server import checkout + + result = await checkout.fn( + project_id="inv@lid", + agent_id="agent-1", + ref="main", + ) + + assert result["success"] is False + + +class TestCommitOperations: + """Tests for commit operation tools.""" + + @pytest.mark.asyncio + async def test_commit_invalid_project(self): + """Test commit with invalid project ID.""" + from server import commit + + result = await commit.fn( + project_id="inv@lid", + agent_id="agent-1", + message="Test commit", + ) + + assert result["success"] is False + + +class TestPushPullOperations: + """Tests for push/pull operation tools.""" + + @pytest.mark.asyncio + async def test_push_workspace_not_found(self): + """Test push when workspace doesn't exist.""" + from server import push + + mock_manager = AsyncMock() + mock_manager.get_workspace = AsyncMock(return_value=None) + + with patch("server._workspace_manager", mock_manager): + result = await push.fn( + project_id="nonexistent", + agent_id="agent-1", + ) + + assert result["success"] is False + + @pytest.mark.asyncio + async def test_pull_workspace_not_found(self): + """Test pull when workspace doesn't exist.""" + from server import pull + + mock_manager = AsyncMock() + mock_manager.get_workspace = AsyncMock(return_value=None) + + with patch("server._workspace_manager", mock_manager): + result = await pull.fn( + project_id="nonexistent", + agent_id="agent-1", + ) + + assert result["success"] is False + + +class TestDiffLogOperations: + """Tests for diff and log operation tools.""" + + @pytest.mark.asyncio + async def test_diff_workspace_not_found(self): + """Test diff when workspace doesn't exist.""" + from server import diff + + mock_manager = AsyncMock() + mock_manager.get_workspace = AsyncMock(return_value=None) + + with patch("server._workspace_manager", mock_manager): + result = await diff.fn( + project_id="nonexistent", + agent_id="agent-1", + ) + + assert result["success"] is False + + @pytest.mark.asyncio + async def test_log_workspace_not_found(self): + """Test log when workspace doesn't exist.""" + from server import log + + mock_manager = AsyncMock() + mock_manager.get_workspace = AsyncMock(return_value=None) + + with patch("server._workspace_manager", mock_manager): + result = await log.fn( + project_id="nonexistent", + agent_id="agent-1", + ) + + assert result["success"] is False + + +class TestPROperations: + """Tests for pull request operation tools.""" + + @pytest.mark.asyncio + async def test_create_pr_no_repo_url(self): + """Test create PR when workspace has no repo URL.""" + from models import WorkspaceInfo, WorkspaceState + from server import create_pull_request + + mock_workspace = WorkspaceInfo( + project_id="test-project", + path="/tmp/test", + state=WorkspaceState.READY, + repo_url=None, # No repo URL + ) + + mock_manager = AsyncMock() + mock_manager.get_workspace = AsyncMock(return_value=mock_workspace) + + with patch("server._workspace_manager", mock_manager): + result = await create_pull_request.fn( + project_id="test-project", + agent_id="agent-1", + title="Test PR", + source_branch="feature", + target_branch="main", + ) + + assert result["success"] is False + assert "repository URL" in result["error"] + + @pytest.mark.asyncio + async def test_list_prs_invalid_state(self): + """Test list PRs with invalid state filter.""" + from models import WorkspaceInfo, WorkspaceState + from server import list_pull_requests + + mock_workspace = WorkspaceInfo( + project_id="test-project", + path="/tmp/test", + state=WorkspaceState.READY, + repo_url="https://gitea.test.com/owner/repo.git", + ) + + mock_manager = AsyncMock() + mock_manager.get_workspace = AsyncMock(return_value=mock_workspace) + + mock_provider = AsyncMock() + mock_provider.parse_repo_url = MagicMock(return_value=("owner", "repo")) + + with patch("server._workspace_manager", mock_manager), \ + patch("server._get_provider_for_url", return_value=mock_provider): + result = await list_pull_requests.fn( + project_id="test-project", + agent_id="agent-1", + state="invalid-state", + ) + + assert result["success"] is False + assert "Invalid state" in result["error"] + + @pytest.mark.asyncio + async def test_merge_pr_invalid_strategy(self): + """Test merge PR with invalid strategy.""" + from models import WorkspaceInfo, WorkspaceState + from server import merge_pull_request + + mock_workspace = WorkspaceInfo( + project_id="test-project", + path="/tmp/test", + state=WorkspaceState.READY, + repo_url="https://gitea.test.com/owner/repo.git", + ) + + mock_manager = AsyncMock() + mock_manager.get_workspace = AsyncMock(return_value=mock_workspace) + + mock_provider = AsyncMock() + mock_provider.parse_repo_url = MagicMock(return_value=("owner", "repo")) + + with patch("server._workspace_manager", mock_manager), \ + patch("server._get_provider_for_url", return_value=mock_provider): + result = await merge_pull_request.fn( + project_id="test-project", + agent_id="agent-1", + pr_number=42, + merge_strategy="invalid-strategy", + ) + + assert result["success"] is False + assert "Invalid strategy" in result["error"] + + +class TestWorkspaceOperations: + """Tests for workspace operation tools.""" + + @pytest.mark.asyncio + async def test_get_workspace_not_found(self): + """Test get workspace when it doesn't exist.""" + from server import get_workspace + + mock_manager = AsyncMock() + mock_manager.get_workspace = AsyncMock(return_value=None) + + with patch("server._workspace_manager", mock_manager): + result = await get_workspace.fn( + project_id="nonexistent", + agent_id="agent-1", + ) + + assert result["success"] is False + + @pytest.mark.asyncio + async def test_lock_workspace_success(self): + """Test successful workspace locking.""" + from datetime import UTC, datetime, timedelta + + from models import WorkspaceInfo, WorkspaceState + from server import lock_workspace + + mock_workspace = WorkspaceInfo( + project_id="test-project", + path="/tmp/test", + state=WorkspaceState.LOCKED, + lock_holder="agent-1", + lock_expires=datetime.now(UTC) + timedelta(seconds=300), + ) + + mock_manager = AsyncMock() + mock_manager.lock_workspace = AsyncMock(return_value=True) + mock_manager.get_workspace = AsyncMock(return_value=mock_workspace) + + with patch("server._workspace_manager", mock_manager): + result = await lock_workspace.fn( + project_id="test-project", + agent_id="agent-1", + timeout=300, + ) + + assert result["success"] is True + assert result["lock_holder"] == "agent-1" + + @pytest.mark.asyncio + async def test_unlock_workspace_success(self): + """Test successful workspace unlocking.""" + from server import unlock_workspace + + mock_manager = AsyncMock() + mock_manager.unlock_workspace = AsyncMock(return_value=True) + + with patch("server._workspace_manager", mock_manager): + result = await unlock_workspace.fn( + project_id="test-project", + agent_id="agent-1", + ) + + assert result["success"] is True + + +class TestJSONRPCEndpoint: + """Tests for the JSON-RPC endpoint.""" + + def test_python_type_to_json_schema_str(self): + """Test string type conversion.""" + from server import _python_type_to_json_schema + + result = _python_type_to_json_schema(str) + assert result["type"] == "string" + + def test_python_type_to_json_schema_int(self): + """Test int type conversion.""" + from server import _python_type_to_json_schema + + result = _python_type_to_json_schema(int) + assert result["type"] == "integer" + + def test_python_type_to_json_schema_bool(self): + """Test bool type conversion.""" + from server import _python_type_to_json_schema + + result = _python_type_to_json_schema(bool) + assert result["type"] == "boolean" + + def test_python_type_to_json_schema_list(self): + """Test list type conversion.""" + + from server import _python_type_to_json_schema + + result = _python_type_to_json_schema(list[str]) + assert result["type"] == "array" + assert result["items"]["type"] == "string" diff --git a/mcp-servers/git-ops/tests/test_workspace.py b/mcp-servers/git-ops/tests/test_workspace.py new file mode 100644 index 0000000..8cc3baa --- /dev/null +++ b/mcp-servers/git-ops/tests/test_workspace.py @@ -0,0 +1,334 @@ +""" +Tests for the workspace management module. +""" + +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest + +from exceptions import WorkspaceLockedError, WorkspaceNotFoundError +from models import WorkspaceState +from workspace import FileLockManager, WorkspaceLock + + +class TestWorkspaceManager: + """Tests for WorkspaceManager.""" + + @pytest.mark.asyncio + async def test_create_workspace(self, workspace_manager, valid_project_id): + """Test creating a new workspace.""" + workspace = await workspace_manager.create_workspace(valid_project_id) + + assert workspace.project_id == valid_project_id + assert workspace.state == WorkspaceState.INITIALIZING + assert Path(workspace.path).exists() + + @pytest.mark.asyncio + async def test_create_workspace_with_repo_url(self, workspace_manager, valid_project_id, sample_repo_url): + """Test creating workspace with repository URL.""" + workspace = await workspace_manager.create_workspace( + valid_project_id, repo_url=sample_repo_url + ) + + assert workspace.repo_url == sample_repo_url + + @pytest.mark.asyncio + async def test_get_workspace(self, workspace_manager, valid_project_id): + """Test getting an existing workspace.""" + # Create first + await workspace_manager.create_workspace(valid_project_id) + + # Get it + workspace = await workspace_manager.get_workspace(valid_project_id) + + assert workspace is not None + assert workspace.project_id == valid_project_id + + @pytest.mark.asyncio + async def test_get_workspace_not_found(self, workspace_manager): + """Test getting non-existent workspace.""" + workspace = await workspace_manager.get_workspace("nonexistent") + assert workspace is None + + @pytest.mark.asyncio + async def test_delete_workspace(self, workspace_manager, valid_project_id): + """Test deleting a workspace.""" + # Create first + workspace = await workspace_manager.create_workspace(valid_project_id) + workspace_path = Path(workspace.path) + assert workspace_path.exists() + + # Delete + result = await workspace_manager.delete_workspace(valid_project_id) + + assert result is True + assert not workspace_path.exists() + + @pytest.mark.asyncio + async def test_delete_nonexistent_workspace(self, workspace_manager): + """Test deleting non-existent workspace returns True.""" + result = await workspace_manager.delete_workspace("nonexistent") + assert result is True + + @pytest.mark.asyncio + async def test_list_workspaces(self, workspace_manager): + """Test listing workspaces.""" + # Create multiple workspaces + await workspace_manager.create_workspace("project-1") + await workspace_manager.create_workspace("project-2") + await workspace_manager.create_workspace("project-3") + + workspaces = await workspace_manager.list_workspaces() + + assert len(workspaces) >= 3 + project_ids = [w.project_id for w in workspaces] + assert "project-1" in project_ids + assert "project-2" in project_ids + assert "project-3" in project_ids + + +class TestWorkspaceLocking: + """Tests for workspace locking.""" + + @pytest.mark.asyncio + async def test_lock_workspace(self, workspace_manager, valid_project_id, valid_agent_id): + """Test locking a workspace.""" + await workspace_manager.create_workspace(valid_project_id) + + result = await workspace_manager.lock_workspace( + valid_project_id, valid_agent_id, timeout=60 + ) + + assert result is True + + workspace = await workspace_manager.get_workspace(valid_project_id) + assert workspace.state == WorkspaceState.LOCKED + assert workspace.lock_holder == valid_agent_id + + @pytest.mark.asyncio + async def test_lock_already_locked(self, workspace_manager, valid_project_id): + """Test locking already-locked workspace by different holder.""" + await workspace_manager.create_workspace(valid_project_id) + await workspace_manager.lock_workspace(valid_project_id, "agent-1", timeout=60) + + with pytest.raises(WorkspaceLockedError): + await workspace_manager.lock_workspace(valid_project_id, "agent-2", timeout=60) + + @pytest.mark.asyncio + async def test_lock_same_holder(self, workspace_manager, valid_project_id, valid_agent_id): + """Test re-locking by same holder extends lock.""" + await workspace_manager.create_workspace(valid_project_id) + await workspace_manager.lock_workspace(valid_project_id, valid_agent_id, timeout=60) + + # Same holder can re-lock + result = await workspace_manager.lock_workspace( + valid_project_id, valid_agent_id, timeout=120 + ) + + assert result is True + + @pytest.mark.asyncio + async def test_unlock_workspace(self, workspace_manager, valid_project_id, valid_agent_id): + """Test unlocking a workspace.""" + await workspace_manager.create_workspace(valid_project_id) + await workspace_manager.lock_workspace(valid_project_id, valid_agent_id) + + result = await workspace_manager.unlock_workspace(valid_project_id, valid_agent_id) + + assert result is True + workspace = await workspace_manager.get_workspace(valid_project_id) + assert workspace.state == WorkspaceState.READY + assert workspace.lock_holder is None + + @pytest.mark.asyncio + async def test_unlock_wrong_holder(self, workspace_manager, valid_project_id): + """Test unlock fails with wrong holder.""" + await workspace_manager.create_workspace(valid_project_id) + await workspace_manager.lock_workspace(valid_project_id, "agent-1") + + with pytest.raises(WorkspaceLockedError): + await workspace_manager.unlock_workspace(valid_project_id, "agent-2") + + @pytest.mark.asyncio + async def test_force_unlock(self, workspace_manager, valid_project_id): + """Test force unlock works regardless of holder.""" + await workspace_manager.create_workspace(valid_project_id) + await workspace_manager.lock_workspace(valid_project_id, "agent-1") + + result = await workspace_manager.unlock_workspace( + valid_project_id, "admin", force=True + ) + + assert result is True + + @pytest.mark.asyncio + async def test_lock_nonexistent_workspace(self, workspace_manager, valid_agent_id): + """Test locking non-existent workspace raises error.""" + with pytest.raises(WorkspaceNotFoundError): + await workspace_manager.lock_workspace("nonexistent", valid_agent_id) + + +class TestWorkspaceLockContextManager: + """Tests for WorkspaceLock context manager.""" + + @pytest.mark.asyncio + async def test_lock_context_manager(self, workspace_manager, valid_project_id, valid_agent_id): + """Test using WorkspaceLock as context manager.""" + await workspace_manager.create_workspace(valid_project_id) + + async with WorkspaceLock( + workspace_manager, valid_project_id, valid_agent_id + ) as lock: + workspace = await workspace_manager.get_workspace(valid_project_id) + assert workspace.state == WorkspaceState.LOCKED + + # After exiting context, should be unlocked + workspace = await workspace_manager.get_workspace(valid_project_id) + assert workspace.lock_holder is None + + @pytest.mark.asyncio + async def test_lock_context_manager_error(self, workspace_manager, valid_project_id, valid_agent_id): + """Test WorkspaceLock releases on exception.""" + await workspace_manager.create_workspace(valid_project_id) + + try: + async with WorkspaceLock( + workspace_manager, valid_project_id, valid_agent_id + ): + raise ValueError("Test error") + except ValueError: + pass + + workspace = await workspace_manager.get_workspace(valid_project_id) + assert workspace.lock_holder is None + + +class TestWorkspaceMetadata: + """Tests for workspace metadata operations.""" + + @pytest.mark.asyncio + async def test_touch_workspace(self, workspace_manager, valid_project_id): + """Test updating workspace access time.""" + workspace = await workspace_manager.create_workspace(valid_project_id) + original_time = workspace.last_accessed + + await workspace_manager.touch_workspace(valid_project_id) + + updated = await workspace_manager.get_workspace(valid_project_id) + assert updated.last_accessed >= original_time + + @pytest.mark.asyncio + async def test_update_workspace_branch(self, workspace_manager, valid_project_id): + """Test updating workspace branch.""" + await workspace_manager.create_workspace(valid_project_id) + + await workspace_manager.update_workspace_branch(valid_project_id, "feature-branch") + + workspace = await workspace_manager.get_workspace(valid_project_id) + assert workspace.current_branch == "feature-branch" + + +class TestWorkspaceSize: + """Tests for workspace size management.""" + + @pytest.mark.asyncio + async def test_check_size_within_limit(self, workspace_manager, valid_project_id): + """Test size check passes for small workspace.""" + await workspace_manager.create_workspace(valid_project_id) + + # Should not raise + result = await workspace_manager.check_size_limit(valid_project_id) + assert result is True + + @pytest.mark.asyncio + async def test_get_total_size(self, workspace_manager, valid_project_id): + """Test getting total workspace size.""" + workspace = await workspace_manager.create_workspace(valid_project_id) + + # Add some content + content_file = Path(workspace.path) / "content.txt" + content_file.write_text("x" * 1000) + + total_size = await workspace_manager.get_total_size() + assert total_size >= 1000 + + +class TestFileLockManager: + """Tests for file-based locking.""" + + def test_acquire_lock(self, temp_dir): + """Test acquiring a file lock.""" + manager = FileLockManager(temp_dir / "locks") + + result = manager.acquire("test-key") + assert result is True + + # Cleanup + manager.release("test-key") + + def test_release_lock(self, temp_dir): + """Test releasing a file lock.""" + manager = FileLockManager(temp_dir / "locks") + manager.acquire("test-key") + + result = manager.release("test-key") + assert result is True + + def test_is_locked(self, temp_dir): + """Test checking if locked.""" + manager = FileLockManager(temp_dir / "locks") + + assert manager.is_locked("test-key") is False + + manager.acquire("test-key") + assert manager.is_locked("test-key") is True + + manager.release("test-key") + + def test_release_nonexistent_lock(self, temp_dir): + """Test releasing a lock that doesn't exist.""" + manager = FileLockManager(temp_dir / "locks") + + # Should not raise + result = manager.release("nonexistent") + assert result is False + + +class TestWorkspaceCleanup: + """Tests for workspace cleanup operations.""" + + @pytest.mark.asyncio + async def test_cleanup_stale_workspaces(self, workspace_manager, test_settings): + """Test cleaning up stale workspaces.""" + # Create workspace + workspace = await workspace_manager.create_workspace("stale-project") + + # Manually set it as stale by updating metadata + await workspace_manager._update_metadata( + "stale-project", + last_accessed=(datetime.now(UTC) - timedelta(days=30)).isoformat(), + ) + + # Run cleanup + cleaned = await workspace_manager.cleanup_stale_workspaces() + + assert cleaned >= 1 + + @pytest.mark.asyncio + async def test_delete_locked_workspace_blocked(self, workspace_manager, valid_project_id, valid_agent_id): + """Test deleting locked workspace is blocked without force.""" + await workspace_manager.create_workspace(valid_project_id) + await workspace_manager.lock_workspace(valid_project_id, valid_agent_id) + + with pytest.raises(WorkspaceLockedError): + await workspace_manager.delete_workspace(valid_project_id) + + @pytest.mark.asyncio + async def test_delete_locked_workspace_force(self, workspace_manager, valid_project_id, valid_agent_id): + """Test force deleting locked workspace.""" + await workspace_manager.create_workspace(valid_project_id) + await workspace_manager.lock_workspace(valid_project_id, valid_agent_id) + + result = await workspace_manager.delete_workspace(valid_project_id, force=True) + assert result is True diff --git a/mcp-servers/git-ops/uv.lock b/mcp-servers/git-ops/uv.lock new file mode 100644 index 0000000..c8014cb --- /dev/null +++ b/mcp-servers/git-ops/uv.lock @@ -0,0 +1,1853 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[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 = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "authlib" +version = "1.6.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/9b/b1661026ff24bc641b76b78c5222d614776b0c085bcfdac9bd15a1cb4b35/authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e", size = 164894, upload-time = "2025-12-12T08:01:41.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, +] + +[[package]] +name = "cachetools" +version = "6.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/1d/ede8680603f6016887c062a2cf4fc8fdba905866a3ab8831aa8aa651320c/cachetools-6.2.4.tar.gz", hash = "sha256:82c5c05585e70b6ba2d3ae09ea60b79548872185d2f24ae1f2709d37299fd607", size = 31731, upload-time = "2025-12-15T18:24:53.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/fc/1d7b80d0eb7b714984ce40efc78859c022cd930e402f599d8ca9e39c78a4/cachetools-6.2.4-py3-none-any.whl", hash = "sha256:69a7a52634fed8b8bf6e24a050fb60bff1c9bd8f6d24572b99c32d4e71e62a51", size = 11551, upload-time = "2025-12-15T18:24:52.332Z" }, +] + +[[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 = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[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/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { 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 = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, +] + +[[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 = "coverage" +version = "7.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" }, + { url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" }, + { url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" }, + { url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, + { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, + { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, + { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, + { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, + { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, + { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, + { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, + { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, + { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, +] + +[[package]] +name = "cyclopts" +version = "4.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/c4/60b6068e703c78656d07b249919754f8f60e9e7da3325560574ee27b4e39/cyclopts-4.4.4.tar.gz", hash = "sha256:f30c591c971d974ab4f223e099f881668daed72de713713c984ca41479d393dd", size = 160046, upload-time = "2026-01-05T03:40:18.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/5b/0eceb9a5990de9025733a0d212ca43649ba9facd58b8552b6bf93c11439d/cyclopts-4.4.4-py3-none-any.whl", hash = "sha256:316f798fe2f2a30cb70e7140cfde2a46617bfbb575d31bbfdc0b2410a447bd83", size = 197398, upload-time = "2026-01-05T03:40:17.141Z" }, +] + +[[package]] +name = "diskcache" +version = "5.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fakeredis" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "redis" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/f9/57464119936414d60697fcbd32f38909bb5688b616ae13de6e98384433e0/fakeredis-2.33.0.tar.gz", hash = "sha256:d7bc9a69d21df108a6451bbffee23b3eba432c21a654afc7ff2d295428ec5770", size = 175187, upload-time = "2025-12-16T19:45:52.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/78/a850fed8aeef96d4a99043c90b818b2ed5419cd5b24a4049fd7cfb9f1471/fakeredis-2.33.0-py3-none-any.whl", hash = "sha256:de535f3f9ccde1c56672ab2fdd6a8efbc4f2619fc2f1acc87b8737177d71c965", size = 119605, upload-time = "2025-12-16T19:45:51.08Z" }, +] + +[package.optional-dependencies] +lua = [ + { name = "lupa" }, +] + +[[package]] +name = "fastapi" +version = "0.128.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" }, +] + +[[package]] +name = "fastmcp" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "jsonschema-path" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "platformdirs" }, + { name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] }, + { name = "pydantic", extra = ["email"] }, + { name = "pydocket" }, + { name = "pyperclip" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "uvicorn" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/1e/e3528227688c248283f6d86869b1e900563ffc223eff00f4f923d2750365/fastmcp-2.14.2.tar.gz", hash = "sha256:bd23d1b808b6f446444f10114dac468b11bfb9153ed78628f5619763d0cf573e", size = 8272966, upload-time = "2025-12-31T15:26:13.433Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/67/8456d39484fcb7afd0defed21918e773ed59a98b39e5b633328527c88367/fastmcp-2.14.2-py3-none-any.whl", hash = "sha256:e33cd622e1ebd5110af6a981804525b6cd41072e3c7d68268ed69ef3be651aca", size = 413279, upload-time = "2025-12-31T15:26:11.178Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/e0/a75dbe4bca1e7d41307323dad5ea2efdd95408f74ab2de8bd7dba9b51a1a/filelock-3.20.2.tar.gz", hash = "sha256:a2241ff4ddde2a7cebddf78e39832509cb045d18ec1a09d7248d6bfc6bfbbe64", size = 19510, upload-time = "2026-01-02T15:33:32.582Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/30/ab407e2ec752aa541704ed8f93c11e2a5d92c168b8a755d818b74a3c5c2d/filelock-3.20.2-py3-none-any.whl", hash = "sha256:fbba7237d6ea277175a32c54bb71ef814a8546d8601269e1bfc388de333974e8", size = 16697, upload-time = "2026-01-02T15:33:31.133Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, +] + +[[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 = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[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 = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[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 = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/7d/41acf8e22d791bde812cb6c2c36128bb932ed8ae066bcb5e39cb198e8253/jaraco_context-6.0.2.tar.gz", hash = "sha256:953ae8dddb57b1d791bf72ea1009b32088840a7dd19b9ba16443f62be919ee57", size = 14994, upload-time = "2025-12-24T19:21:35.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0c/1e0096ced9c55f9c6c6655446798df74165780375d3f5ab5f33751e087ae/jaraco_context-6.0.2-py3-none-any.whl", hash = "sha256:55fc21af4b4f9ca94aa643b6ee7fe13b1e4c01abf3aeb98ca4ad9c80b741c786", size = 6988, upload-time = "2025-12-24T19:21:34.557Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, +] + +[[package]] +name = "jsonschema-path" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/45/41ebc679c2a4fced6a722f624c18d658dee42612b83ea24c1caf7c0eb3a8/jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001", size = 11159, upload-time = "2025-01-24T14:33:16.547Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/58/3485da8cb93d2f393bce453adeef16896751f14ba3e2024bc21dc9597646/jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8", size = 14810, upload-time = "2025-01-24T14:33:14.652Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + +[[package]] +name = "librt" +version = "0.7.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/29/47f29026ca17f35cf299290292d5f8331f5077364974b7675a353179afa2/librt-0.7.7.tar.gz", hash = "sha256:81d957b069fed1890953c3b9c3895c7689960f233eea9a1d9607f71ce7f00b2c", size = 145910, upload-time = "2026-01-01T23:52:22.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/72/1cd9d752070011641e8aee046c851912d5f196ecd726fffa7aed2070f3e0/librt-0.7.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a85a1fc4ed11ea0eb0a632459ce004a2d14afc085a50ae3463cd3dfe1ce43fc", size = 55687, upload-time = "2026-01-01T23:51:16.291Z" }, + { url = "https://files.pythonhosted.org/packages/50/aa/d5a1d4221c4fe7e76ae1459d24d6037783cb83c7645164c07d7daf1576ec/librt-0.7.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c87654e29a35938baead1c4559858f346f4a2a7588574a14d784f300ffba0efd", size = 57136, upload-time = "2026-01-01T23:51:17.363Z" }, + { url = "https://files.pythonhosted.org/packages/23/6f/0c86b5cb5e7ef63208c8cc22534df10ecc5278efc0d47fb8815577f3ca2f/librt-0.7.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c9faaebb1c6212c20afd8043cd6ed9de0a47d77f91a6b5b48f4e46ed470703fe", size = 165320, upload-time = "2026-01-01T23:51:18.455Z" }, + { url = "https://files.pythonhosted.org/packages/16/37/df4652690c29f645ffe405b58285a4109e9fe855c5bb56e817e3e75840b3/librt-0.7.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1908c3e5a5ef86b23391448b47759298f87f997c3bd153a770828f58c2bb4630", size = 174216, upload-time = "2026-01-01T23:51:19.599Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d6/d3afe071910a43133ec9c0f3e4ce99ee6df0d4e44e4bddf4b9e1c6ed41cc/librt-0.7.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dbc4900e95a98fc0729523be9d93a8fedebb026f32ed9ffc08acd82e3e181503", size = 189005, upload-time = "2026-01-01T23:51:21.052Z" }, + { url = "https://files.pythonhosted.org/packages/d5/18/74060a870fe2d9fd9f47824eba6717ce7ce03124a0d1e85498e0e7efc1b2/librt-0.7.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a7ea4e1fbd253e5c68ea0fe63d08577f9d288a73f17d82f652ebc61fa48d878d", size = 183961, upload-time = "2026-01-01T23:51:22.493Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5e/918a86c66304af66a3c1d46d54df1b2d0b8894babc42a14fb6f25511497f/librt-0.7.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ef7699b7a5a244b1119f85c5bbc13f152cd38240cbb2baa19b769433bae98e50", size = 177610, upload-time = "2026-01-01T23:51:23.874Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d7/b5e58dc2d570f162e99201b8c0151acf40a03a39c32ab824dd4febf12736/librt-0.7.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:955c62571de0b181d9e9e0a0303c8bc90d47670a5eff54cf71bf5da61d1899cf", size = 199272, upload-time = "2026-01-01T23:51:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/87/8202c9bd0968bdddc188ec3811985f47f58ed161b3749299f2c0dd0f63fb/librt-0.7.7-cp312-cp312-win32.whl", hash = "sha256:1bcd79be209313b270b0e1a51c67ae1af28adad0e0c7e84c3ad4b5cb57aaa75b", size = 43189, upload-time = "2026-01-01T23:51:26.799Z" }, + { url = "https://files.pythonhosted.org/packages/61/8d/80244b267b585e7aa79ffdac19f66c4861effc3a24598e77909ecdd0850e/librt-0.7.7-cp312-cp312-win_amd64.whl", hash = "sha256:4353ee891a1834567e0302d4bd5e60f531912179578c36f3d0430f8c5e16b456", size = 49462, upload-time = "2026-01-01T23:51:27.813Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1f/75db802d6a4992d95e8a889682601af9b49d5a13bbfa246d414eede1b56c/librt-0.7.7-cp312-cp312-win_arm64.whl", hash = "sha256:a76f1d679beccccdf8c1958e732a1dfcd6e749f8821ee59d7bec009ac308c029", size = 42828, upload-time = "2026-01-01T23:51:28.804Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5e/d979ccb0a81407ec47c14ea68fb217ff4315521730033e1dd9faa4f3e2c1/librt-0.7.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f4a0b0a3c86ba9193a8e23bb18f100d647bf192390ae195d84dfa0a10fb6244", size = 55746, upload-time = "2026-01-01T23:51:29.828Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/3b65861fb32f802c3783d6ac66fc5589564d07452a47a8cf9980d531cad3/librt-0.7.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5335890fea9f9e6c4fdf8683061b9ccdcbe47c6dc03ab8e9b68c10acf78be78d", size = 57174, upload-time = "2026-01-01T23:51:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/030b50614b29e443607220097ebaf438531ea218c7a9a3e21ea862a919cd/librt-0.7.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b4346b1225be26def3ccc6c965751c74868f0578cbcba293c8ae9168483d811", size = 165834, upload-time = "2026-01-01T23:51:32.278Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e1/bd8d1eacacb24be26a47f157719553bbd1b3fe812c30dddf121c0436fd0b/librt-0.7.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a10b8eebdaca6e9fdbaf88b5aefc0e324b763a5f40b1266532590d5afb268a4c", size = 174819, upload-time = "2026-01-01T23:51:33.461Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/91d6c3372acf54a019c1ad8da4c9ecf4fc27d039708880bf95f48dbe426a/librt-0.7.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:067be973d90d9e319e6eb4ee2a9b9307f0ecd648b8a9002fa237289a4a07a9e7", size = 189607, upload-time = "2026-01-01T23:51:34.604Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ac/44604d6d3886f791fbd1c6ae12d5a782a8f4aca927484731979f5e92c200/librt-0.7.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:23d2299ed007812cccc1ecef018db7d922733382561230de1f3954db28433977", size = 184586, upload-time = "2026-01-01T23:51:35.845Z" }, + { url = "https://files.pythonhosted.org/packages/5c/26/d8a6e4c17117b7f9b83301319d9a9de862ae56b133efb4bad8b3aa0808c9/librt-0.7.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6b6f8ea465524aa4c7420c7cc4ca7d46fe00981de8debc67b1cc2e9957bb5b9d", size = 178251, upload-time = "2026-01-01T23:51:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/99/ab/98d857e254376f8e2f668e807daccc1f445e4b4fc2f6f9c1cc08866b0227/librt-0.7.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8df32a99cc46eb0ee90afd9ada113ae2cafe7e8d673686cf03ec53e49635439", size = 199853, upload-time = "2026-01-01T23:51:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/7c/55/4523210d6ae5134a5da959900be43ad8bab2e4206687b6620befddb5b5fd/librt-0.7.7-cp313-cp313-win32.whl", hash = "sha256:86f86b3b785487c7760247bcdac0b11aa8bf13245a13ed05206286135877564b", size = 43247, upload-time = "2026-01-01T23:51:39.629Z" }, + { url = "https://files.pythonhosted.org/packages/25/40/3ec0fed5e8e9297b1cf1a3836fb589d3de55f9930e3aba988d379e8ef67c/librt-0.7.7-cp313-cp313-win_amd64.whl", hash = "sha256:4862cb2c702b1f905c0503b72d9d4daf65a7fdf5a9e84560e563471e57a56949", size = 49419, upload-time = "2026-01-01T23:51:40.674Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7a/aab5f0fb122822e2acbc776addf8b9abfb4944a9056c00c393e46e543177/librt-0.7.7-cp313-cp313-win_arm64.whl", hash = "sha256:0996c83b1cb43c00e8c87835a284f9057bc647abd42b5871e5f941d30010c832", size = 42828, upload-time = "2026-01-01T23:51:41.731Z" }, + { url = "https://files.pythonhosted.org/packages/69/9c/228a5c1224bd23809a635490a162e9cbdc68d99f0eeb4a696f07886b8206/librt-0.7.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:23daa1ab0512bafdd677eb1bfc9611d8ffbe2e328895671e64cb34166bc1b8c8", size = 55188, upload-time = "2026-01-01T23:51:43.14Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c2/0e7c6067e2b32a156308205e5728f4ed6478c501947e9142f525afbc6bd2/librt-0.7.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:558a9e5a6f3cc1e20b3168fb1dc802d0d8fa40731f6e9932dcc52bbcfbd37111", size = 56895, upload-time = "2026-01-01T23:51:44.534Z" }, + { url = "https://files.pythonhosted.org/packages/0e/77/de50ff70c80855eb79d1d74035ef06f664dd073fb7fb9d9fb4429651b8eb/librt-0.7.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2567cb48dc03e5b246927ab35cbb343376e24501260a9b5e30b8e255dca0d1d2", size = 163724, upload-time = "2026-01-01T23:51:45.571Z" }, + { url = "https://files.pythonhosted.org/packages/6e/19/f8e4bf537899bdef9e0bb9f0e4b18912c2d0f858ad02091b6019864c9a6d/librt-0.7.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6066c638cdf85ff92fc6f932d2d73c93a0e03492cdfa8778e6d58c489a3d7259", size = 172470, upload-time = "2026-01-01T23:51:46.823Z" }, + { url = "https://files.pythonhosted.org/packages/42/4c/dcc575b69d99076768e8dd6141d9aecd4234cba7f0e09217937f52edb6ed/librt-0.7.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a609849aca463074c17de9cda173c276eb8fee9e441053529e7b9e249dc8b8ee", size = 186806, upload-time = "2026-01-01T23:51:48.009Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f8/4094a2b7816c88de81239a83ede6e87f1138477d7ee956c30f136009eb29/librt-0.7.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:add4e0a000858fe9bb39ed55f31085506a5c38363e6eb4a1e5943a10c2bfc3d1", size = 181809, upload-time = "2026-01-01T23:51:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/821b7c0ab1b5a6cd9aee7ace8309c91545a2607185101827f79122219a7e/librt-0.7.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a3bfe73a32bd0bdb9a87d586b05a23c0a1729205d79df66dee65bb2e40d671ba", size = 175597, upload-time = "2026-01-01T23:51:50.636Z" }, + { url = "https://files.pythonhosted.org/packages/71/f9/27f6bfbcc764805864c04211c6ed636fe1d58f57a7b68d1f4ae5ed74e0e0/librt-0.7.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0ecce0544d3db91a40f8b57ae26928c02130a997b540f908cefd4d279d6c5848", size = 196506, upload-time = "2026-01-01T23:51:52.535Z" }, + { url = "https://files.pythonhosted.org/packages/46/ba/c9b9c6fc931dd7ea856c573174ccaf48714905b1a7499904db2552e3bbaf/librt-0.7.7-cp314-cp314-win32.whl", hash = "sha256:8f7a74cf3a80f0c3b0ec75b0c650b2f0a894a2cec57ef75f6f72c1e82cdac61d", size = 39747, upload-time = "2026-01-01T23:51:53.683Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/cd1269337c4cde3ee70176ee611ab0058aa42fc8ce5c9dce55f48facfcd8/librt-0.7.7-cp314-cp314-win_amd64.whl", hash = "sha256:3d1fe2e8df3268dd6734dba33ededae72ad5c3a859b9577bc00b715759c5aaab", size = 45971, upload-time = "2026-01-01T23:51:54.697Z" }, + { url = "https://files.pythonhosted.org/packages/79/fd/e0844794423f5583108c5991313c15e2b400995f44f6ec6871f8aaf8243c/librt-0.7.7-cp314-cp314-win_arm64.whl", hash = "sha256:2987cf827011907d3dfd109f1be0d61e173d68b1270107bb0e89f2fca7f2ed6b", size = 39075, upload-time = "2026-01-01T23:51:55.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/02/211fd8f7c381e7b2a11d0fdfcd410f409e89967be2e705983f7c6342209a/librt-0.7.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8e92c8de62b40bfce91d5e12c6e8b15434da268979b1af1a6589463549d491e6", size = 57368, upload-time = "2026-01-01T23:51:56.706Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/aca257affae73ece26041ae76032153266d110453173f67d7603058e708c/librt-0.7.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f683dcd49e2494a7535e30f779aa1ad6e3732a019d80abe1309ea91ccd3230e3", size = 59238, upload-time = "2026-01-01T23:51:58.066Z" }, + { url = "https://files.pythonhosted.org/packages/96/47/7383a507d8e0c11c78ca34c9d36eab9000db5989d446a2f05dc40e76c64f/librt-0.7.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b15e5d17812d4d629ff576699954f74e2cc24a02a4fc401882dd94f81daba45", size = 183870, upload-time = "2026-01-01T23:51:59.204Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b8/50f3d8eec8efdaf79443963624175c92cec0ba84827a66b7fcfa78598e51/librt-0.7.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c084841b879c4d9b9fa34e5d5263994f21aea7fd9c6add29194dbb41a6210536", size = 194608, upload-time = "2026-01-01T23:52:00.419Z" }, + { url = "https://files.pythonhosted.org/packages/23/d9/1b6520793aadb59d891e3b98ee057a75de7f737e4a8b4b37fdbecb10d60f/librt-0.7.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c8fb9966f84737115513fecbaf257f9553d067a7dd45a69c2c7e5339e6a8dc", size = 206776, upload-time = "2026-01-01T23:52:01.705Z" }, + { url = "https://files.pythonhosted.org/packages/ff/db/331edc3bba929d2756fa335bfcf736f36eff4efcb4f2600b545a35c2ae58/librt-0.7.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5fb1ecb2c35362eab2dbd354fd1efa5a8440d3e73a68be11921042a0edc0ff", size = 203206, upload-time = "2026-01-01T23:52:03.315Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e1/6af79ec77204e85f6f2294fc171a30a91bb0e35d78493532ed680f5d98be/librt-0.7.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d1454899909d63cc9199a89fcc4f81bdd9004aef577d4ffc022e600c412d57f3", size = 196697, upload-time = "2026-01-01T23:52:04.857Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/de55ecce4b2796d6d243295c221082ca3a944dc2fb3a52dcc8660ce7727d/librt-0.7.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7ef28f2e7a016b29792fe0a2dd04dec75725b32a1264e390c366103f834a9c3a", size = 217193, upload-time = "2026-01-01T23:52:06.159Z" }, + { url = "https://files.pythonhosted.org/packages/41/61/33063e271949787a2f8dd33c5260357e3d512a114fc82ca7890b65a76e2d/librt-0.7.7-cp314-cp314t-win32.whl", hash = "sha256:5e419e0db70991b6ba037b70c1d5bbe92b20ddf82f31ad01d77a347ed9781398", size = 40277, upload-time = "2026-01-01T23:52:07.625Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/1abd972349f83a696ea73159ac964e63e2d14086fdd9bc7ca878c25fced4/librt-0.7.7-cp314-cp314t-win_amd64.whl", hash = "sha256:d6b7d93657332c817b8d674ef6bf1ab7796b4f7ce05e420fd45bd258a72ac804", size = 46765, upload-time = "2026-01-01T23:52:08.647Z" }, + { url = "https://files.pythonhosted.org/packages/51/0e/b756c7708143a63fca65a51ca07990fa647db2cc8fcd65177b9e96680255/librt-0.7.7-cp314-cp314t-win_arm64.whl", hash = "sha256:142c2cd91794b79fd0ce113bd658993b7ede0fe93057668c2f98a45ca00b7e91", size = 39724, upload-time = "2026-01-01T23:52:09.745Z" }, +] + +[[package]] +name = "lupa" +version = "2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/1c/191c3e6ec6502e3dbe25a53e27f69a5daeac3e56de1f73c0138224171ead/lupa-2.6.tar.gz", hash = "sha256:9a770a6e89576be3447668d7ced312cd6fd41d3c13c2462c9dc2c2ab570e45d9", size = 7240282, upload-time = "2025-10-24T07:20:29.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/86/ce243390535c39d53ea17ccf0240815e6e457e413e40428a658ea4ee4b8d/lupa-2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47ce718817ef1cc0c40d87c3d5ae56a800d61af00fbc0fad1ca9be12df2f3b56", size = 951707, upload-time = "2025-10-24T07:18:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/cedea5e6cbeb54396fdcc55f6b741696f3f036d23cfaf986d50d680446da/lupa-2.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7aba985b15b101495aa4b07112cdc08baa0c545390d560ad5cfde2e9e34f4d58", size = 1916703, upload-time = "2025-10-24T07:18:05.6Z" }, + { url = "https://files.pythonhosted.org/packages/24/be/3d6b5f9a8588c01a4d88129284c726017b2089f3a3fd3ba8bd977292fea0/lupa-2.6-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:b766f62f95b2739f2248977d29b0722e589dcf4f0ccfa827ccbd29f0148bd2e5", size = 985152, upload-time = "2025-10-24T07:18:08.561Z" }, + { url = "https://files.pythonhosted.org/packages/eb/23/9f9a05beee5d5dce9deca4cb07c91c40a90541fc0a8e09db4ee670da550f/lupa-2.6-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:00a934c23331f94cb51760097ebfab14b005d55a6b30a2b480e3c53dd2fa290d", size = 1159599, upload-time = "2025-10-24T07:18:10.346Z" }, + { url = "https://files.pythonhosted.org/packages/40/4e/e7c0583083db9d7f1fd023800a9767d8e4391e8330d56c2373d890ac971b/lupa-2.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21de9f38bd475303e34a042b7081aabdf50bd9bafd36ce4faea2f90fd9f15c31", size = 1038686, upload-time = "2025-10-24T07:18:12.112Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/5a4f7d959d4feba5e203ff0c31889e74d1ca3153122be4a46dca7d92bf7c/lupa-2.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf3bda96d3fc41237e964a69c23647d50d4e28421111360274d4799832c560e9", size = 2071956, upload-time = "2025-10-24T07:18:14.572Z" }, + { url = "https://files.pythonhosted.org/packages/92/34/2f4f13ca65d01169b1720176aedc4af17bc19ee834598c7292db232cb6dc/lupa-2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a76ead245da54801a81053794aa3975f213221f6542d14ec4b859ee2e7e0323", size = 1057199, upload-time = "2025-10-24T07:18:16.379Z" }, + { url = "https://files.pythonhosted.org/packages/35/2a/5f7d2eebec6993b0dcd428e0184ad71afb06a45ba13e717f6501bfed1da3/lupa-2.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8dd0861741caa20886ddbda0a121d8e52fb9b5bb153d82fa9bba796962bf30e8", size = 1173693, upload-time = "2025-10-24T07:18:18.153Z" }, + { url = "https://files.pythonhosted.org/packages/e4/29/089b4d2f8e34417349af3904bb40bec40b65c8731f45e3fd8d497ca573e5/lupa-2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:239e63948b0b23023f81d9a19a395e768ed3da6a299f84e7963b8f813f6e3f9c", size = 2164394, upload-time = "2025-10-24T07:18:20.403Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1b/79c17b23c921f81468a111cad843b076a17ef4b684c4a8dff32a7969c3f0/lupa-2.6-cp312-cp312-win32.whl", hash = "sha256:325894e1099499e7a6f9c351147661a2011887603c71086d36fe0f964d52d1ce", size = 1420647, upload-time = "2025-10-24T07:18:23.368Z" }, + { url = "https://files.pythonhosted.org/packages/b8/15/5121e68aad3584e26e1425a5c9a79cd898f8a152292059e128c206ee817c/lupa-2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c735a1ce8ee60edb0fe71d665f1e6b7c55c6021f1d340eb8c865952c602cd36f", size = 1688529, upload-time = "2025-10-24T07:18:25.523Z" }, + { url = "https://files.pythonhosted.org/packages/28/1d/21176b682ca5469001199d8b95fa1737e29957a3d185186e7a8b55345f2e/lupa-2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:663a6e58a0f60e7d212017d6678639ac8df0119bc13c2145029dcba084391310", size = 947232, upload-time = "2025-10-24T07:18:27.878Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4c/d327befb684660ca13cf79cd1f1d604331808f9f1b6fb6bf57832f8edf80/lupa-2.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:d1f5afda5c20b1f3217a80e9bc1b77037f8a6eb11612fd3ada19065303c8f380", size = 1908625, upload-time = "2025-10-24T07:18:29.944Z" }, + { url = "https://files.pythonhosted.org/packages/66/8e/ad22b0a19454dfd08662237a84c792d6d420d36b061f239e084f29d1a4f3/lupa-2.6-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:26f2b3c085fe76e9119e48c1013c1cccdc1f51585d456858290475aa38e7089e", size = 981057, upload-time = "2025-10-24T07:18:31.553Z" }, + { url = "https://files.pythonhosted.org/packages/5c/48/74859073ab276bd0566c719f9ca0108b0cfc1956ca0d68678d117d47d155/lupa-2.6-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:60d2f902c7b96fb8ab98493dcff315e7bb4d0b44dc9dd76eb37de575025d5685", size = 1156227, upload-time = "2025-10-24T07:18:33.981Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/0e9ded061916877253c2266074060eb71ed99fb21d73c8c114a76725bce2/lupa-2.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a02d25dee3a3250967c36590128d9220ae02f2eda166a24279da0b481519cbff", size = 1035752, upload-time = "2025-10-24T07:18:36.32Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ef/f8c32e454ef9f3fe909f6c7d57a39f950996c37a3deb7b391fec7903dab7/lupa-2.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eae1ee16b886b8914ff292dbefbf2f48abfbdee94b33a88d1d5475e02423203", size = 2069009, upload-time = "2025-10-24T07:18:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/15b80c226a5225815a890ee1c11f07968e0aba7a852df41e8ae6fe285063/lupa-2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0edd5073a4ee74ab36f74fe61450148e6044f3952b8d21248581f3c5d1a58be", size = 1056301, upload-time = "2025-10-24T07:18:40.165Z" }, + { url = "https://files.pythonhosted.org/packages/31/14/2086c1425c985acfb30997a67e90c39457122df41324d3c179d6ee2292c6/lupa-2.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0c53ee9f22a8a17e7d4266ad48e86f43771951797042dd51d1494aaa4f5f3f0a", size = 1170673, upload-time = "2025-10-24T07:18:42.426Z" }, + { url = "https://files.pythonhosted.org/packages/10/e5/b216c054cf86576c0191bf9a9f05de6f7e8e07164897d95eea0078dca9b2/lupa-2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:de7c0f157a9064a400d828789191a96da7f4ce889969a588b87ec80de9b14772", size = 2162227, upload-time = "2025-10-24T07:18:46.112Z" }, + { url = "https://files.pythonhosted.org/packages/59/2f/33ecb5bedf4f3bc297ceacb7f016ff951331d352f58e7e791589609ea306/lupa-2.6-cp313-cp313-win32.whl", hash = "sha256:ee9523941ae0a87b5b703417720c5d78f72d2f5bc23883a2ea80a949a3ed9e75", size = 1419558, upload-time = "2025-10-24T07:18:48.371Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/55e885834c847ea610e111d87b9ed4768f0afdaeebc00cd46810f25029f6/lupa-2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b1335a5835b0a25ebdbc75cf0bda195e54d133e4d994877ef025e218c2e59db9", size = 1683424, upload-time = "2025-10-24T07:18:50.976Z" }, + { url = "https://files.pythonhosted.org/packages/66/9d/d9427394e54d22a35d1139ef12e845fd700d4872a67a34db32516170b746/lupa-2.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcb6d0a3264873e1653bc188499f48c1fb4b41a779e315eba45256cfe7bc33c1", size = 953818, upload-time = "2025-10-24T07:18:53.378Z" }, + { url = "https://files.pythonhosted.org/packages/10/41/27bbe81953fb2f9ecfced5d9c99f85b37964cfaf6aa8453bb11283983721/lupa-2.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:a37e01f2128f8c36106726cb9d360bac087d58c54b4522b033cc5691c584db18", size = 1915850, upload-time = "2025-10-24T07:18:55.259Z" }, + { url = "https://files.pythonhosted.org/packages/a3/98/f9ff60db84a75ba8725506bbf448fb085bc77868a021998ed2a66d920568/lupa-2.6-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:458bd7e9ff3c150b245b0fcfbb9bd2593d1152ea7f0a7b91c1d185846da033fe", size = 982344, upload-time = "2025-10-24T07:18:57.05Z" }, + { url = "https://files.pythonhosted.org/packages/41/f7/f39e0f1c055c3b887d86b404aaf0ca197b5edfd235a8b81b45b25bac7fc3/lupa-2.6-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:052ee82cac5206a02df77119c325339acbc09f5ce66967f66a2e12a0f3211cad", size = 1156543, upload-time = "2025-10-24T07:18:59.251Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9c/59e6cffa0d672d662ae17bd7ac8ecd2c89c9449dee499e3eb13ca9cd10d9/lupa-2.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96594eca3c87dd07938009e95e591e43d554c1dbd0385be03c100367141db5a8", size = 1047974, upload-time = "2025-10-24T07:19:01.449Z" }, + { url = "https://files.pythonhosted.org/packages/23/c6/a04e9cef7c052717fcb28fb63b3824802488f688391895b618e39be0f684/lupa-2.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8faddd9d198688c8884091173a088a8e920ecc96cda2ffed576a23574c4b3f6", size = 2073458, upload-time = "2025-10-24T07:19:03.369Z" }, + { url = "https://files.pythonhosted.org/packages/e6/10/824173d10f38b51fc77785228f01411b6ca28826ce27404c7c912e0e442c/lupa-2.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:daebb3a6b58095c917e76ba727ab37b27477fb926957c825205fbda431552134", size = 1067683, upload-time = "2025-10-24T07:19:06.2Z" }, + { url = "https://files.pythonhosted.org/packages/b6/dc/9692fbcf3c924d9c4ece2d8d2f724451ac2e09af0bd2a782db1cef34e799/lupa-2.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f3154e68972befe0f81564e37d8142b5d5d79931a18309226a04ec92487d4ea3", size = 1171892, upload-time = "2025-10-24T07:19:08.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/e318b628d4643c278c96ab3ddea07fc36b075a57383c837f5b11e537ba9d/lupa-2.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e4dadf77b9fedc0bfa53417cc28dc2278a26d4cbd95c29f8927ad4d8fe0a7ef9", size = 2166641, upload-time = "2025-10-24T07:19:10.485Z" }, + { url = "https://files.pythonhosted.org/packages/12/f7/a6f9ec2806cf2d50826980cdb4b3cffc7691dc6f95e13cc728846d5cb793/lupa-2.6-cp314-cp314-win32.whl", hash = "sha256:cb34169c6fa3bab3e8ac58ca21b8a7102f6a94b6a5d08d3636312f3f02fafd8f", size = 1456857, upload-time = "2025-10-24T07:19:37.989Z" }, + { url = "https://files.pythonhosted.org/packages/c5/de/df71896f25bdc18360fdfa3b802cd7d57d7fede41a0e9724a4625b412c85/lupa-2.6-cp314-cp314-win_amd64.whl", hash = "sha256:b74f944fe46c421e25d0f8692aef1e842192f6f7f68034201382ac440ef9ea67", size = 1731191, upload-time = "2025-10-24T07:19:40.281Z" }, + { url = "https://files.pythonhosted.org/packages/47/3c/a1f23b01c54669465f5f4c4083107d496fbe6fb45998771420e9aadcf145/lupa-2.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0e21b716408a21ab65723f8841cf7f2f37a844b7a965eeabb785e27fca4099cf", size = 999343, upload-time = "2025-10-24T07:19:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/501994291cb640bfa2ccf7f554be4e6914afa21c4026bd01bff9ca8aac57/lupa-2.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:589db872a141bfff828340079bbdf3e9a31f2689f4ca0d88f97d9e8c2eae6142", size = 2000730, upload-time = "2025-10-24T07:19:14.869Z" }, + { url = "https://files.pythonhosted.org/packages/53/a5/457ffb4f3f20469956c2d4c4842a7675e884efc895b2f23d126d23e126cc/lupa-2.6-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:cd852a91a4a9d4dcbb9a58100f820a75a425703ec3e3f049055f60b8533b7953", size = 1021553, upload-time = "2025-10-24T07:19:17.123Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/36bb5a5d0960f2a5c7c700e0819abb76fd9bf9c1d8a66e5106416d6e9b14/lupa-2.6-cp314-cp314t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:0334753be028358922415ca97a64a3048e4ed155413fc4eaf87dd0a7e2752983", size = 1133275, upload-time = "2025-10-24T07:19:20.51Z" }, + { url = "https://files.pythonhosted.org/packages/19/86/202ff4429f663013f37d2229f6176ca9f83678a50257d70f61a0a97281bf/lupa-2.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:661d895cd38c87658a34780fac54a690ec036ead743e41b74c3fb81a9e65a6aa", size = 1038441, upload-time = "2025-10-24T07:19:22.509Z" }, + { url = "https://files.pythonhosted.org/packages/a7/42/d8125f8e420714e5b52e9c08d88b5329dfb02dcca731b4f21faaee6cc5b5/lupa-2.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aa58454ccc13878cc177c62529a2056be734da16369e451987ff92784994ca7", size = 2058324, upload-time = "2025-10-24T07:19:24.979Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2c/47bf8b84059876e877a339717ddb595a4a7b0e8740bacae78ba527562e1c/lupa-2.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1425017264e470c98022bba8cff5bd46d054a827f5df6b80274f9cc71dafd24f", size = 1060250, upload-time = "2025-10-24T07:19:27.262Z" }, + { url = "https://files.pythonhosted.org/packages/c2/06/d88add2b6406ca1bdec99d11a429222837ca6d03bea42ca75afa169a78cb/lupa-2.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:224af0532d216e3105f0a127410f12320f7c5f1aa0300bdf9646b8d9afb0048c", size = 1151126, upload-time = "2025-10-24T07:19:29.522Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a0/89e6a024c3b4485b89ef86881c9d55e097e7cb0bdb74efb746f2fa6a9a76/lupa-2.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9abb98d5a8fd27c8285302e82199f0e56e463066f88f619d6594a450bf269d80", size = 2153693, upload-time = "2025-10-24T07:19:31.379Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/a0f007dc58fc1bbf51fb85dcc82fcb1f21b8c4261361de7dab0e3d8521ef/lupa-2.6-cp314-cp314t-win32.whl", hash = "sha256:1849efeba7a8f6fb8aa2c13790bee988fd242ae404bd459509640eeea3d1e291", size = 1590104, upload-time = "2025-10-24T07:19:33.514Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5e/db903ce9cf82c48d6b91bf6d63ae4c8d0d17958939a4e04ba6b9f38b8643/lupa-2.6-cp314-cp314t-win_amd64.whl", hash = "sha256:fc1498d1a4fc028bc521c26d0fad4ca00ed63b952e32fb95949bda76a04bad52", size = 1913818, upload-time = "2025-10-24T07:19:36.039Z" }, +] + +[[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 = "mcp" +version = "1.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387, upload-time = "2025-12-19T10:19:56.985Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076, upload-time = "2025-12-19T10:19:55.416Z" }, +] + +[[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 = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, +] + +[[package]] +name = "opentelemetry-exporter-prometheus" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "prometheus-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/39/7dafa6fff210737267bed35a8855b6ac7399b9e582b8cf1f25f842517012/opentelemetry_exporter_prometheus-0.60b1.tar.gz", hash = "sha256:a4011b46906323f71724649d301b4dc188aaa068852e814f4df38cc76eac616b", size = 14976, upload-time = "2025-12-11T13:32:42.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/0d/4be6bf5477a3eb3d917d2f17d3c0b6720cd6cb97898444a61d43cc983f5c/opentelemetry_exporter_prometheus-0.60b1-py3-none-any.whl", hash = "sha256:49f59178de4f4590e3cef0b8b95cf6e071aae70e1f060566df5546fad773b8fd", size = 13019, upload-time = "2025-12-11T13:32:23.974Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathable" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/93/8f2c2075b180c12c1e9f6a09d1a985bc2036906b13dff1d8917e395f2048/pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2", size = 8124, upload-time = "2025-01-10T18:43:13.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/2e/83722ece0f6ee24387d6cb830dd562ddbcd6ce0b9d76072c6849670c31b4/pathspec-1.0.1.tar.gz", hash = "sha256:e2769b508d0dd47b09af6ee2c75b2744a2cb1f474ae4b1494fd6a1b7a841613c", size = 129791, upload-time = "2026-01-06T13:02:55.15Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fe/2257c71721aeab6a6e8aa1f00d01f2a20f58547d249a6c8fef5791f559fc/pathspec-1.0.1-py3-none-any.whl", hash = "sha256:8870061f22c58e6d83463cfce9a7dd6eca0512c772c1001fb09ac64091816721", size = 54584, upload-time = "2026-01-06T13:02:53.601Z" }, +] + +[[package]] +name = "pathvalidate" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[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 = "prometheus-client" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/53/3edb5d68ecf6b38fcbcc1ad28391117d2a322d9a1a3eff04bfdb184d8c3b/prometheus_client-0.23.1.tar.gz", hash = "sha256:6ae8f9081eaaaf153a2e959d2e6c4f4fb57b12ef76c8c7980202f1e57b48b2ce", size = 80481, upload-time = "2025-09-18T20:47:25.043Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/db/14bafcb4af2139e046d03fd00dea7873e48eafe18b7d2797e73d6681f210/prometheus_client-0.23.1-py3-none-any.whl", hash = "sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99", size = 61145, upload-time = "2025-09-18T20:47:23.875Z" }, +] + +[[package]] +name = "py-key-value-aio" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "py-key-value-shared" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/ce/3136b771dddf5ac905cc193b461eb67967cf3979688c6696e1f2cdcde7ea/py_key_value_aio-0.3.0.tar.gz", hash = "sha256:858e852fcf6d696d231266da66042d3355a7f9871650415feef9fca7a6cd4155", size = 50801, upload-time = "2025-11-17T16:50:04.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/10/72f6f213b8f0bce36eff21fda0a13271834e9eeff7f9609b01afdc253c79/py_key_value_aio-0.3.0-py3-none-any.whl", hash = "sha256:1c781915766078bfd608daa769fefb97e65d1d73746a3dfb640460e322071b64", size = 96342, upload-time = "2025-11-17T16:50:03.801Z" }, +] + +[package.optional-dependencies] +disk = [ + { name = "diskcache" }, + { name = "pathvalidate" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, +] +redis = [ + { name = "redis" }, +] + +[[package]] +name = "py-key-value-shared" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/e4/1971dfc4620a3a15b4579fe99e024f5edd6e0967a71154771a059daff4db/py_key_value_shared-0.3.0.tar.gz", hash = "sha256:8fdd786cf96c3e900102945f92aa1473138ebe960ef49da1c833790160c28a4b", size = 11666, upload-time = "2025-11-17T16:50:06.849Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560, upload-time = "2025-11-17T16:50:05.954Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[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.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[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 = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + +[[package]] +name = "pydocket" +version = "0.16.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpickle" }, + { name = "fakeredis", extra = ["lua"] }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-prometheus" }, + { name = "opentelemetry-instrumentation" }, + { name = "prometheus-client" }, + { name = "py-key-value-aio", extra = ["memory", "redis"] }, + { name = "python-json-logger" }, + { name = "redis" }, + { name = "rich" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/c5/61dcfce4d50b66a3f09743294d37fab598b81bb0975054b7f732da9243ec/pydocket-0.16.3.tar.gz", hash = "sha256:78e9da576de09e9f3f410d2471ef1c679b7741ddd21b586c97a13872b69bd265", size = 297080, upload-time = "2025-12-23T23:37:33.32Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/94/93b7f5981aa04f922e0d9ce7326a4587866ec7e39f7c180ffcf408e66ee8/pydocket-0.16.3-py3-none-any.whl", hash = "sha256:e2b50925356e7cd535286255195458ac7bba15f25293356651b36d223db5dd7c", size = 67087, upload-time = "2025-12-23T23:37:31.829Z" }, +] + +[[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 = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + +[[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-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-json-logger" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/96/804520d0850c7db98e5ccb70282e29208723f0964e88ffd9d0da2f52ea09/python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92", size = 37196, upload-time = "2025-12-17T09:24:22.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "redis" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, +] + +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, +] + +[[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 = "respx" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "rich-rst" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, + { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, + { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, + { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + +[[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 = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/f5df66cb383efdbf4f2db23cabb27f51b1dcb737efaf8a558f6f1d195134/sse_starlette-3.1.2.tar.gz", hash = "sha256:55eff034207a83a0eb86de9a68099bd0157838f0b8b999a1b742005c71e33618", size = 26303, upload-time = "2025-12-31T08:02:20.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/95/8c4b76eec9ae574474e5d2997557cebf764bcd3586458956c30631ae08f4/sse_starlette-3.1.2-py3-none-any.whl", hash = "sha256:cd800dd349f4521b317b9391d3796fa97b71748a4da9b9e00aafab32dda375c8", size = 12484, upload-time = "2025-12-31T08:02:18.894Z" }, +] + +[[package]] +name = "starlette" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, +] + +[[package]] +name = "syndarix-mcp-git-ops" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "aiofiles" }, + { name = "fastapi" }, + { name = "fastmcp" }, + { name = "filelock" }, + { name = "gitpython" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "redis" }, + { name = "uvicorn" }, +] + +[package.optional-dependencies] +dev = [ + { name = "fakeredis" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "respx" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiofiles", specifier = ">=24.1.0" }, + { name = "fakeredis", marker = "extra == 'dev'", specifier = ">=2.25.0" }, + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "fastmcp", specifier = ">=2.0.0" }, + { name = "filelock", specifier = ">=3.15.0" }, + { name = "gitpython", specifier = ">=3.1.0" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.11.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pydantic-settings", specifier = ">=2.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.0" }, + { name = "redis", specifier = ">=5.0.0" }, + { name = "respx", marker = "extra == 'dev'", specifier = ">=0.21.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, + { name = "uvicorn", specifier = ">=0.30.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "typer" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, +] + +[[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.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] diff --git a/mcp-servers/git-ops/workspace.py b/mcp-servers/git-ops/workspace.py new file mode 100644 index 0000000..fdf8ac1 --- /dev/null +++ b/mcp-servers/git-ops/workspace.py @@ -0,0 +1,608 @@ +""" +Workspace management for Git Operations MCP Server. + +Handles isolated workspaces for each project, including creation, +locking, cleanup, and size management. +""" + +import asyncio +import json +import logging +import shutil +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +import aiofiles +from filelock import FileLock, Timeout + +from config import Settings, get_settings +from exceptions import ( + WorkspaceLockedError, + WorkspaceNotFoundError, + WorkspaceSizeExceededError, +) +from models import WorkspaceInfo, WorkspaceState + +logger = logging.getLogger(__name__) + +# Metadata file name +WORKSPACE_METADATA_FILE = ".syndarix-workspace.json" + + +class WorkspaceManager: + """ + Manages git workspaces for projects. + + Each project gets an isolated workspace directory for git operations. + Supports distributed locking via Redis or local file locks. + """ + + def __init__(self, settings: Settings | None = None) -> None: + """ + Initialize WorkspaceManager. + + Args: + settings: Optional settings override + """ + self.settings = settings or get_settings() + self.base_path = self.settings.workspace_base_path + self._ensure_base_path() + + def _ensure_base_path(self) -> None: + """Ensure the base workspace directory exists.""" + self.base_path.mkdir(parents=True, exist_ok=True) + + def _get_workspace_path(self, project_id: str) -> Path: + """Get the path for a project workspace.""" + # Sanitize project ID for filesystem + safe_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in project_id) + return self.base_path / safe_id + + def _get_lock_path(self, project_id: str) -> Path: + """Get the lock file path for a workspace.""" + return self._get_workspace_path(project_id) / ".lock" + + def _get_metadata_path(self, project_id: str) -> Path: + """Get the metadata file path for a workspace.""" + return self._get_workspace_path(project_id) / WORKSPACE_METADATA_FILE + + async def get_workspace(self, project_id: str) -> WorkspaceInfo | None: + """ + Get workspace info for a project. + + Args: + project_id: Project identifier + + Returns: + WorkspaceInfo or None if not found + """ + workspace_path = self._get_workspace_path(project_id) + + if not workspace_path.exists(): + return None + + # Load metadata + metadata = await self._load_metadata(project_id) + + # Calculate size + size_bytes = await self._calculate_size(workspace_path) + + # Check lock status + lock_holder = None + lock_expires = None + if metadata: + lock_holder = metadata.get("lock_holder") + if metadata.get("lock_expires"): + lock_expires = datetime.fromisoformat(metadata["lock_expires"]) + # Clear expired locks + if lock_expires < datetime.now(UTC): + lock_holder = None + lock_expires = None + + # Determine state + state = WorkspaceState.READY + if lock_holder: + state = WorkspaceState.LOCKED + + # Check if stale + last_accessed = datetime.now(UTC) + if metadata and metadata.get("last_accessed"): + last_accessed = datetime.fromisoformat(metadata["last_accessed"]) + stale_threshold = datetime.now(UTC) - timedelta( + days=self.settings.workspace_stale_days + ) + if last_accessed < stale_threshold: + state = WorkspaceState.STALE + + return WorkspaceInfo( + project_id=project_id, + path=str(workspace_path), + state=state, + repo_url=metadata.get("repo_url") if metadata else None, + current_branch=metadata.get("current_branch") if metadata else None, + last_accessed=last_accessed, + size_bytes=size_bytes, + lock_holder=lock_holder, + lock_expires=lock_expires, + ) + + async def create_workspace( + self, + project_id: str, + repo_url: str | None = None, + ) -> WorkspaceInfo: + """ + Create or get a workspace for a project. + + Args: + project_id: Project identifier + repo_url: Optional repository URL + + Returns: + WorkspaceInfo for the workspace + """ + workspace_path = self._get_workspace_path(project_id) + + if workspace_path.exists(): + # Workspace already exists, update metadata + await self._update_metadata(project_id, repo_url=repo_url) + workspace = await self.get_workspace(project_id) + if workspace: + return workspace + + # Create workspace directory + workspace_path.mkdir(parents=True, exist_ok=True) + + # Create initial metadata + metadata = { + "project_id": project_id, + "repo_url": repo_url, + "created_at": datetime.now(UTC).isoformat(), + "last_accessed": datetime.now(UTC).isoformat(), + } + await self._save_metadata(project_id, metadata) + + return WorkspaceInfo( + project_id=project_id, + path=str(workspace_path), + state=WorkspaceState.INITIALIZING, + repo_url=repo_url, + last_accessed=datetime.now(UTC), + size_bytes=0, + ) + + async def delete_workspace(self, project_id: str, force: bool = False) -> bool: + """ + Delete a workspace. + + Args: + project_id: Project identifier + force: Force delete even if locked + + Returns: + True if deleted + """ + workspace_path = self._get_workspace_path(project_id) + + if not workspace_path.exists(): + return True + + # Check lock + if not force: + workspace = await self.get_workspace(project_id) + if workspace and workspace.state == WorkspaceState.LOCKED: + raise WorkspaceLockedError(project_id, workspace.lock_holder) + + try: + # Use shutil.rmtree for robust deletion + shutil.rmtree(workspace_path) + logger.info(f"Deleted workspace for project: {project_id}") + return True + except Exception as e: + logger.error(f"Failed to delete workspace {project_id}: {e}") + return False + + async def lock_workspace( + self, + project_id: str, + holder: str, + timeout: int | None = None, + ) -> bool: + """ + Acquire a lock on a workspace. + + Args: + project_id: Project identifier + holder: Lock holder identifier (agent_id) + timeout: Lock timeout in seconds + + Returns: + True if lock acquired + + Raises: + WorkspaceNotFoundError: If workspace doesn't exist + WorkspaceLockedError: If already locked by another + """ + workspace = await self.get_workspace(project_id) + + if workspace is None: + raise WorkspaceNotFoundError(project_id) + + # Check if already locked by someone else + if ( + workspace.state == WorkspaceState.LOCKED + and workspace.lock_holder != holder + ): + # Check if lock expired + if workspace.lock_expires and workspace.lock_expires > datetime.now(UTC): + raise WorkspaceLockedError(project_id, workspace.lock_holder) + + # Calculate lock expiry + lock_timeout = timeout or self.settings.workspace_lock_timeout + lock_expires = datetime.now(UTC) + timedelta(seconds=lock_timeout) + + # Update metadata with lock info + await self._update_metadata( + project_id, + lock_holder=holder, + lock_expires=lock_expires.isoformat(), + ) + + logger.info(f"Workspace {project_id} locked by {holder}") + return True + + async def unlock_workspace( + self, + project_id: str, + holder: str, + force: bool = False, + ) -> bool: + """ + Release a lock on a workspace. + + Args: + project_id: Project identifier + holder: Lock holder identifier + force: Force unlock regardless of holder + + Returns: + True if unlocked + """ + workspace = await self.get_workspace(project_id) + + if workspace is None: + raise WorkspaceNotFoundError(project_id) + + # Verify holder + if ( + not force + and workspace.lock_holder + and workspace.lock_holder != holder + ): + raise WorkspaceLockedError(project_id, workspace.lock_holder) + + # Clear lock + await self._update_metadata( + project_id, + lock_holder=None, + lock_expires=None, + ) + + logger.info(f"Workspace {project_id} unlocked by {holder}") + return True + + async def touch_workspace(self, project_id: str) -> None: + """ + Update last accessed time for a workspace. + + Args: + project_id: Project identifier + """ + await self._update_metadata( + project_id, + last_accessed=datetime.now(UTC).isoformat(), + ) + + async def update_workspace_branch( + self, + project_id: str, + branch: str, + ) -> None: + """ + Update the current branch in workspace metadata. + + Args: + project_id: Project identifier + branch: Current branch name + """ + await self._update_metadata( + project_id, + current_branch=branch, + last_accessed=datetime.now(UTC).isoformat(), + ) + + async def check_size_limit(self, project_id: str) -> bool: + """ + Check if workspace exceeds size limit. + + Args: + project_id: Project identifier + + Returns: + True if within limits + + Raises: + WorkspaceSizeExceededError: If size exceeds limit + """ + workspace_path = self._get_workspace_path(project_id) + + if not workspace_path.exists(): + return True + + size_bytes = await self._calculate_size(workspace_path) + size_gb = size_bytes / (1024 ** 3) + max_size_gb = self.settings.workspace_max_size_gb + + if size_gb > max_size_gb: + raise WorkspaceSizeExceededError(project_id, size_gb, max_size_gb) + + return True + + async def list_workspaces( + self, + include_stale: bool = False, + ) -> list[WorkspaceInfo]: + """ + List all workspaces. + + Args: + include_stale: Include stale workspaces + + Returns: + List of WorkspaceInfo + """ + workspaces = [] + + if not self.base_path.exists(): + return workspaces + + for entry in self.base_path.iterdir(): + if entry.is_dir() and not entry.name.startswith("."): + # Extract project_id from directory name + workspace = await self.get_workspace(entry.name) + if workspace: + if not include_stale and workspace.state == WorkspaceState.STALE: + continue + workspaces.append(workspace) + + return workspaces + + async def cleanup_stale_workspaces(self) -> int: + """ + Clean up stale workspaces. + + Returns: + Number of workspaces cleaned up + """ + cleaned = 0 + workspaces = await self.list_workspaces(include_stale=True) + + for workspace in workspaces: + if workspace.state == WorkspaceState.STALE: + try: + await self.delete_workspace(workspace.project_id, force=True) + cleaned += 1 + except Exception as e: + logger.error( + f"Failed to cleanup stale workspace {workspace.project_id}: {e}" + ) + + if cleaned > 0: + logger.info(f"Cleaned up {cleaned} stale workspaces") + + return cleaned + + async def get_total_size(self) -> int: + """ + Get total size of all workspaces. + + Returns: + Total size in bytes + """ + return await self._calculate_size(self.base_path) + + # Private methods + + async def _load_metadata(self, project_id: str) -> dict[str, Any] | None: + """Load workspace metadata from file.""" + metadata_path = self._get_metadata_path(project_id) + + if not metadata_path.exists(): + return None + + try: + async with aiofiles.open(metadata_path) as f: + content = await f.read() + return json.loads(content) + except Exception as e: + logger.warning(f"Failed to load metadata for {project_id}: {e}") + return None + + async def _save_metadata( + self, + project_id: str, + metadata: dict[str, Any], + ) -> None: + """Save workspace metadata to file.""" + metadata_path = self._get_metadata_path(project_id) + + # Ensure parent directory exists + metadata_path.parent.mkdir(parents=True, exist_ok=True) + + try: + async with aiofiles.open(metadata_path, "w") as f: + await f.write(json.dumps(metadata, indent=2)) + except Exception as e: + logger.error(f"Failed to save metadata for {project_id}: {e}") + + async def _update_metadata( + self, + project_id: str, + **updates: Any, + ) -> None: + """Update specific fields in workspace metadata.""" + metadata = await self._load_metadata(project_id) or {} + + # Handle None values (to clear fields) + for key, value in updates.items(): + if value is None: + metadata.pop(key, None) + else: + metadata[key] = value + + await self._save_metadata(project_id, metadata) + + async def _calculate_size(self, path: Path) -> int: + """Calculate total size of a directory.""" + + def _calc_size() -> int: + total = 0 + try: + for entry in path.rglob("*"): + if entry.is_file(): + try: + total += entry.stat().st_size + except OSError: + pass + except Exception: + pass + return total + + # Run in executor for async compatibility + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, _calc_size) + + +class WorkspaceLock: + """ + Context manager for workspace locking. + + Provides automatic locking/unlocking with proper cleanup. + """ + + def __init__( + self, + manager: WorkspaceManager, + project_id: str, + holder: str, + timeout: int | None = None, + ) -> None: + """ + Initialize workspace lock. + + Args: + manager: WorkspaceManager instance + project_id: Project identifier + holder: Lock holder identifier + timeout: Lock timeout in seconds + """ + self.manager = manager + self.project_id = project_id + self.holder = holder + self.timeout = timeout + self._acquired = False + + async def __aenter__(self) -> "WorkspaceLock": + """Acquire lock on enter.""" + await self.manager.lock_workspace( + self.project_id, + self.holder, + self.timeout, + ) + self._acquired = True + return self + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + """Release lock on exit.""" + if self._acquired: + try: + await self.manager.unlock_workspace( + self.project_id, + self.holder, + ) + except Exception as e: + logger.warning( + f"Failed to release lock for {self.project_id}: {e}" + ) + + +class FileLockManager: + """ + File-based locking for single-instance deployments. + + Uses filelock for local locking when Redis is not available. + """ + + def __init__(self, lock_dir: Path) -> None: + """ + Initialize file lock manager. + + Args: + lock_dir: Directory for lock files + """ + self.lock_dir = lock_dir + self.lock_dir.mkdir(parents=True, exist_ok=True) + self._locks: dict[str, FileLock] = {} + + def _get_lock(self, key: str) -> FileLock: + """Get or create a file lock for a key.""" + if key not in self._locks: + lock_path = self.lock_dir / f"{key}.lock" + self._locks[key] = FileLock(lock_path) + return self._locks[key] + + def acquire( + self, + key: str, + timeout: float = 10.0, + ) -> bool: + """ + Acquire a lock. + + Args: + key: Lock key + timeout: Timeout in seconds + + Returns: + True if acquired + """ + lock = self._get_lock(key) + try: + lock.acquire(timeout=timeout) + return True + except Timeout: + return False + + def release(self, key: str) -> bool: + """ + Release a lock. + + Args: + key: Lock key + + Returns: + True if released + """ + if key in self._locks: + try: + self._locks[key].release() + return True + except Exception: + pass + return False + + def is_locked(self, key: str) -> bool: + """Check if a key is locked.""" + lock = self._get_lock(key) + return lock.is_locked