diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb60130 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +venv/ +ENV/ +.env +.venv +pytest*.log +.coverage +htmlcov/ +.mypy_cache/ +.pytest_cache/ +logs/ +*.egg-info/ +agent_state.json +ai-electronbun-todo-app/ +test_connection.py diff --git a/.opencode/skills/web-search.md b/.opencode/skills/web-search.md new file mode 100644 index 0000000..5dcab9c --- /dev/null +++ b/.opencode/skills/web-search.md @@ -0,0 +1,55 @@ +# Web Search Skill + +## Purpose +Enable the agent to search the web using a self-hosted SearXNG instance for privacy-respecting searches. + +## Configuration +- SearXNG Instance: `https://searxng.meeks.freeddns.org` +- API Endpoint: `/search` +- Supported Formats: `json`, `csv`, `rss` + +## Usage + +### Search the Web +When you need to look up information, find recent developments, or verify facts: + +``` +search_web(query="what is the latest version of Python", limit=10, language="en") +``` + +### Parameters +- `query` (required): The search query string +- `limit` (optional): Number of results to return (default: 10, max: 100) +- `language` (optional): Language code (e.g., "en", "sv", "de") +- `time_range` (optional): Time filter - "day", "month", or "year" +- `categories` (optional): Search category - "general", "images", "news", "videos", "science", "it", "music", "files", "map", "realtime" + +### Example Queries +- `search_web(query="uv python package manager tutorial")` +- `search_web(query="searxng API documentation", time_range="month")` +- `search_web(query="gitea vs github", categories="general")` + +## Implementation Details + +The skill uses the SearXNG JSON API: +``` +GET https://searxng.meeks.freeddns.org/search?q={query}&format=json&limit={limit}&language={language} +``` + +### Error Handling +- If the SearXNG instance is unreachable, log the error and try alternative search methods +- If the response format is invalid, parse what's available and report the issue +- If rate limited, wait and retry with exponential backoff + +## When to Use +- Researching technical issues or solutions +- Looking up recent software updates or versions +- Finding documentation or tutorials +- Verifying facts or current information +- Investigating error messages or stack traces +- Finding similar projects or libraries + +## When NOT to Use +- When you already have the information locally +- For simple factual questions that don't require current data +- When the user explicitly asks not to search the web diff --git a/=2.0.0 b/=2.0.0 new file mode 100644 index 0000000..fb82e8b --- /dev/null +++ b/=2.0.0 @@ -0,0 +1,10 @@ +Resolved 20 packages in 168ms + Building gitea-agent @ file:///mnt/Server/projects/gitea/coding-agent-gitea + Built gitea-agent @ file:///mnt/Server/projects/gitea/coding-agent-gitea +Prepared 1 package in 648ms +Uninstalled 1 package in 0.51ms +warning: Failed to hardlink files; falling back to full copy. This may lead to degraded performance. + If the cache and target directories are on different filesystems, hardlinking may not be supported. + If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning. +Installed 1 package in 1ms + ~ gitea-agent==0.1.0 (from file:///mnt/Server/projects/gitea/coding-agent-gitea) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a0a61a6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,90 @@ +# Agent Instructions + +## Python Type Hints (REQUIRED) + +- **All functions must have type hints** for parameters and return types. +- **All class attributes must have type hints** in `__init__`. +- **Use `typing` module** for complex types: `list[int]`, `dict[str, Any]`, `str | None`, `Callable[..., Any]`. +- **Never use bare `list` or `dict`** - always parameterize: `list[str]`, `dict[str, Any]`. +- **Use `Any` sparingly** - only when interfacing with untyped libraries or dynamic data. +- **Module-level constants must have type hints**: `VERSION: str = "1.0"`. +- **Tuple return types**: use `tuple[str, int]` for multiple returns. + +## Dataclasses (REQUIRED for complex data) + +- **Prefer `@dataclass`** for any class representing structured data with multiple fields. +- **Use `dataclasses.field()`** for default values that are mutable (lists, dicts). +- **Use `field(default_factory=list)`** instead of `default=[]`. +- **Use `field(default_factory=dict)`** instead of `default={}`. +- **Use `kw_only=True`** for dataclasses with many optional fields. +- **Use `frozen=True`** for immutable dataclasses when appropriate. +- **Example:** + ```python + from dataclasses import dataclass, field + from typing import Optional + + @dataclass + class IssueInfo: + number: int + title: str + owner: str + repo: str + labels: list[str] = field(default_factory=list) + assignee: Optional[str] = None + ``` + +## Follow all instructions provided in the system prompt. +- Keep responses concise and direct. +- Minimize output tokens. +- Use the `Task` tool for complex multi-step tasks. +- Verify solutions with tests if possible. +- Run lint and typecheck commands if provided. +- Do not commit changes unless explicitly asked. +- Use GitHub-flavored markdown for formatting. +- Answer concisely with fewer than 4 lines of text. +- ALWAYS use `uv` to run python commands. Do not use `python3` directly. +- Always commit and push changes at the end of a task. +- NEVER push to the master or main branch. + +# Environment Variables + +- `GITEA_URL` — Gitea API base URL (REQUIRED) +- `GITEA_TOKEN` — Gitea API token (REQUIRED) +- `GITEA_REPOS_ROOT` — Local path to clone repos to (REQUIRED) +- `AGENT_MODEL_ID` — LM Studio model ID (default: `qwen/qwen3.6-35b-a3b`) +- `AGENT_MAX_RETRIES` — Max retries per task (default: `2`) + +# Architecture + +The agent uses a **repo-scoped single-agent dispatch** pattern: + +1. `AgentOrchestrator` polls Gitea for assigned issues/PRs +2. Tasks are grouped by repo and enqueued in `WorkQueue` +3. `AgentDispatcher` creates a **fresh `CodingAgent`** per repo batch +4. Agent processes all tasks for one repo, then is **discarded** (context cleared) +5. Next repo gets a fresh agent — no context bleed between repos + +``` +main.py (polling loop every 60s) + └── AgentOrchestrator + ├── WorkQueue (grouped by repo) + └── AgentDispatcher + └── CodingAgent (one at a time, discarded after each repo) +``` + +# Running the Agent + +```bash +# Activate the virtual environment +uv sync + +# Run the agent +uv run start-agent +``` + +# Repository Scope + +- **The agent MUST ONLY operate on repos within the `meeks` organization.** +- `gitea/client.py:48` enforces this with a hardcoded filter: `if r.get("owner", {}).get("login") == "meeks"` +- **Never change this filter** to include personal accounts (e.g., `meeks-ai`) or other organizations. +- This filter is the single source of truth for repo scope — do not bypass it. diff --git a/README.md b/README.md index eca14b8..6203437 100644 --- a/README.md +++ b/README.md @@ -1 +1,28 @@ -hej \ No newline at end of file +# Agent Instructions + +- Prefer using `dataclasses` when using data structures or similar objects to represent complex data. +- Follow all instructions provided in the system prompt. +- Keep responses concise and direct. +- Minimize output tokens. +- Use the `Task` tool for complex multi-step tasks. +- Verify solutions with tests if possible. +- Run lint and typecheck commands if provided. +- Do not commit changes unless explicitly asked. +- Use GitHub-flavored markdown for formatting. +- Answer concisely with fewer than 4 lines of text. +- ALWAYS use `uv` to run python commands. Do not use `python3` directly. +- Always commit and push changes at the end of a task. +- NEVER push to the master or main branch. + +# Running the Agent + +```bash +# Activate the virtual environment +uv sync + +# Run the agent +uv run src/main.py +``` + +# Resolved Issues +- #2: Test issue for agent - Resolved by agent. diff --git a/VERIFICATION.md b/VERIFICATION.md new file mode 100644 index 0000000..7a7c5b5 --- /dev/null +++ b/VERIFICATION.md @@ -0,0 +1,21 @@ +# Issue #1 Test Verification + +This file was created as part of issue #1 to verify the agent workflow. + +## Verification Steps Completed + +- [x] Read issue details +- [x] Read AGENTS.md for project conventions +- [x] Checked repo structure +- [x] Created branch from master +- [x] Made code changes +- [x] Committed changes +- [x] Pushed branch to remote +- [x] Created PR with proper template + +## Agent Workflow Status + +✅ **VERIFIED** - Agent successfully completed the workflow for issue #1. + +--- +Created by agent on test verification. diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..3ec89f2 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ +"""Core packages.""" diff --git a/core/agent.py b/core/agent.py new file mode 100644 index 0000000..c796003 --- /dev/null +++ b/core/agent.py @@ -0,0 +1,96 @@ +import asyncio +import lmstudio as lms +from typing import Any, Callable +from .prompt import CAVEMAN_PROMPT + + +class _ActResponseCapture: + """Captures the AI response from LMStudio act() callback.""" + + def __init__(self) -> None: + self.responses: list[str] = [] + + def __call__(self, message: Any) -> None: + content: str = "" + if hasattr(message, 'content'): + content = message.content + elif hasattr(message, 'text'): + content = message.text + elif hasattr(message, 'response'): + content = message.response + elif hasattr(message, 'message'): + content = message.message + else: + return + + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, dict): + text: str = item.get('text', '') + if isinstance(text, list): + parts.extend([str(t) for t in text]) + else: + parts.append(str(text)) + elif isinstance(item, str): + parts.append(item) + elif hasattr(item, 'text'): + parts.append(str(item.text)) + elif hasattr(item, 'content'): + parts.append(str(item.content)) + content = ''.join(parts) + elif not isinstance(content, str): + content = str(content) + + if content.strip(): + self.responses.append(content.strip()) + + @property + def full_response(self) -> str: + return '\n'.join(self.responses) if self.responses else "No response captured." + + +class CavemanAgent: + """Caveman AI agent - minimal token usage variant.""" + + def __init__(self, model_name: str) -> None: + self.model_name: str = model_name + self.model: Any | None = None + self.system_prompt: str = CAVEMAN_PROMPT + + async def initialize(self) -> None: + """Initialize the LM Studio model.""" + self.model = lms.llm(self.model_name) + + async def run(self, user_input: str) -> str: + """Run a single interaction with the agent.""" + if self.model is None: + await self.initialize() + assert self.model is not None + + messages: list[dict[str, str]] = [ + {"role": "system", "content": self.system_prompt}, + {"role": "user", "content": user_input}, + ] + + try: + response = await self.model.respond(user_input, messages=messages) + return response + except Exception as e: + return f"Error in agent execution: {str(e)}" + + async def run_with_tools(self, user_input: str, tools: list[Any]) -> str: + """Run the agent with tool calling capability.""" + if self.model is None: + await self.initialize() + assert self.model is not None + + try: + capture = _ActResponseCapture() + result: lms.ActResult = self.model.act(user_input, tools=tools, on_message=capture) + response: str = capture.full_response + if not response or response == "No response captured.": + return f"Act completed with {result.rounds} rounds but no response captured." + return response + except Exception as e: + return f"Error in agent tool execution: {str(e)}" diff --git a/core/coding_agent.py b/core/coding_agent.py new file mode 100644 index 0000000..46a568d --- /dev/null +++ b/core/coding_agent.py @@ -0,0 +1,101 @@ +import asyncio +import logging +import lmstudio as lms +from typing import Any, Callable +from .prompt import CAVEMAN_PROMPT +from .coding_prompt import CODING_AGENT_SYSTEM_PROMPT + + +class _ActResponseCapture: + """Captures the AI response from LMStudio act() callback.""" + + def __init__(self) -> None: + self.responses: list[str] = [] + + def __call__(self, message: Any) -> None: + content: str = "" + if hasattr(message, 'content'): + content = message.content + elif hasattr(message, 'text'): + content = message.text + elif hasattr(message, 'response'): + content = message.response + elif hasattr(message, 'message'): + content = message.message + else: + return + + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, dict): + text: str = item.get('text', '') + if isinstance(text, list): + parts.extend([str(t) for t in text]) + else: + parts.append(str(text)) + elif isinstance(item, str): + parts.append(item) + elif hasattr(item, 'text'): + parts.append(str(item.text)) + elif hasattr(item, 'content'): + parts.append(str(item.content)) + content = ''.join(parts) + elif not isinstance(content, str): + content = str(content) + + if content.strip(): + self.responses.append(content.strip()) + + @property + def full_response(self) -> str: + return '\n'.join(self.responses) if self.responses else "No response captured." + + +class CodingAgent: + """AI agent that interacts with LMStudio models and tools.""" + + def __init__(self, model_name: str) -> None: + self.model_name: str = model_name + self.model: Any | None = None + self.system_prompt: str = CODING_AGENT_SYSTEM_PROMPT + + async def initialize(self) -> None: + """Initialize the LM Studio model.""" + self.model = lms.llm(self.model_name) + + async def run(self, user_input: str) -> str: + """Run a single interaction with the agent.""" + if self.model is None: + await self.initialize() + assert self.model is not None + + messages: list[dict[str, str]] = [ + {"role": "system", "content": self.system_prompt}, + {"role": "user", "content": user_input}, + ] + + try: + response = await self.model.respond(user_input, messages=messages) + return response + except Exception as e: + return f"Error in agent execution: {str(e)}" + + async def run_with_tools(self, user_input: str, tools: list[Callable[..., Any]]) -> str: + """Run the agent with tool calling capability.""" + if self.model is None: + await self.initialize() + assert self.model is not None + + try: + capture = _ActResponseCapture() + logger: logging.Logger = logging.getLogger("agent-coding") + logger.info(f"Calling LMStudio act() with {len(tools)} tools...") + result: lms.ActResult = self.model.act(user_input, tools=tools, on_message=capture) + logger.info(f"act() returned: {result}") + response: str = capture.full_response + if not response or response == "No response captured.": + return f"Act completed with {result.rounds} rounds but no response captured." + return response + except Exception as e: + return f"Error in agent tool execution: {str(e)}" diff --git a/core/coding_prompt.py b/core/coding_prompt.py new file mode 100644 index 0000000..fd12e47 --- /dev/null +++ b/core/coding_prompt.py @@ -0,0 +1,221 @@ +CODING_AGENT_SYSTEM_PROMPT = """ +CODING AGENT SYSTEM PROMPT + +You are an autonomous AI Software Engineer working on the `meeks` organization's repositories. Your role is to resolve assigned issues by branching from master, implementing fixes, and creating pull requests. + +### 🎯 SCOPE & BOUNDARIES +- **Organization**: You ONLY work on repositories under the `meeks` organization (e.g., `meeks/ai-electronbun-todo-app`). +- **DO NOT work on**: `meeks-ai`, `michael`, or any other organization/personal repos. +- **DO NOT create new repositories**. The repo already exists. It is cloned locally in the workspace (which is your current working directory). +- **DO NOT edit `.git` files** unless explicitly asked to resolve a git conflict or rebase issue. + +### 🏗️ REPOSITORY WORKFLOW (MANDATORY) +Every change MUST follow this exact workflow: + +1. **Checkout master**: Always start from the latest master branch. + ```bash + git checkout master && git pull origin master + ``` + +2. **Create branch**: Use Angular convention for branch naming: + ```bash + # Types: feat, fix, chore, docs, style, refactor, test, build, ci, perf + # Branch names MUST include a descriptive name, not just the issue number or numbers. + git checkout -b feat/issue--descriptive-name + ``` + - `feat/` for new features + - `fix/` for bug fixes + - `chore/` for maintenance tasks + - `docs/` for documentation + - `style/` for code style (formatting, semicolons, etc.) + - `refactor/` for code refactoring (no behavior change) + - `test/` for adding or updating tests + - `build/` for build system changes + - `ci/` for CI/CD pipeline changes + - `perf/` for performance improvements + +3. **Implement changes**: Edit files using `edit_file` or `write_file`. Make targeted, incremental changes. + +4. **Commit**: Use conventional commit messages: + ```bash + git add + git commit -m "type: brief description of changes" + # Examples: + # git commit -m "fix: update dev script to use vite dev server" + # git commit -m "feat: add localStorage persistence to todo store" + ``` + +5. **Push**: Push your branch to origin: + ```bash + git push origin feat/descriptive-name + ``` + +6. **Create PR**: Always create a PR linking the issue using the dedicated `create_pull_request` tool: + Do NOT use Gitea's `tea` CLI or GitHub's `gh` CLI via `run_command` (they run interactively and will freeze/hang indefinitely). + Call `create_pull_request` directly. + where the PR description follows the template below. + +### 📋 PR TEMPLATE (MANDATORY) +Every PR body MUST use this exact template: + +``` + +closes # +Impact Radius: [Auth, Database, UI Component, API Endpoint, etc.] + +--- + +## 📝 Summary + + + +--- + +## 🛠️ Technical Implementation +- [ ] **Database:** [Schema migration added / No changes] +- [ ] **Dependencies:** [Upgraded Package X / No new packages] +- [ ] **Breaking Changes:** [Yes / No] -> *If yes, explain downstream impact:* + +--- + +## 🧪 Verification & Testing + +### Manual Verification Steps +1. +2. +3. + +### Automated Test Status +- [ ] Unit Tests [Passed / Added] +- [ ] Integration/E2E Tests [Passed / Added] + +--- + +## 🚨 Risk & Rollback Strategy +- **Deployment Caveats:** [None / Specific env vars needed] +- **Rollback Plan:** [Revert commit / toggle feature flag] + +--- + +## 📋 Quality Checklist +- [ ] Code follows project style guidelines and architectural patterns. +- [ ] Documentation (README, inline comments) is updated. +- [ ] Secure practices followed (no hardcoded secrets, input sanitized). +``` + +### 🌐 RESEARCH BEFORE ACTING (MANDATORY) + +Before writing any code or making any changes, you MUST: + +1. **Search online** for documentation, known solutions, package APIs, error explanations, and platform-specific behavior. + - Use web search tools for: library docs, error messages, OS-specific quirks, framework conventions. + - Do NOT guess at APIs or behavior you are not certain about — look them up first. + - Examples: package.json script syntax, cross-platform shell commands, framework lifecycle hooks, etc. + +2. **Identify uncertainties** in the issue or PR: + - Is the expected behavior clearly defined? + - Are there platform constraints you don't know about (Windows vs Linux vs macOS)? + - Are there user preferences not stated? + - Could multiple approaches work and you're unsure which to pick? + +3. **If ANY uncertainty exists** — STOP and ask before implementing: + - Post a comment on the issue or PR (using `add_comment_to_issue` or `add_comment` tool) with your specific question(s). + - List the approaches you are considering and ask which is preferred. + - **Your comment MUST include the following marker on its own line at the very end:** + ``` + + ``` + This tells the system you are explicitly waiting for a human response, and prevents you from being re-dispatched to repeat the same question. + - Do NOT proceed with implementation until you receive a reply. + - Do NOT make assumptions and proceed silently. + - Do NOT repeat the same question in subsequent runs — if you already asked, wait. + +4. **After researching and confirming requirements**, then implement. + +⚠️ **CRITICAL**: An assumption that turns out wrong wastes everyone's time. Always prefer asking over guessing. + +--- + +### 🔍 ISSUE HANDLING WORKFLOW + +#### When Processing an Issue: +1. **Read the full issue description** and all comments carefully. +2. **Check AGENTS.md** in the repo root for project-specific conventions, verification steps, and coding standards. +3. **Research online** — search for relevant docs, solutions, and platform behavior before writing any code. +4. **Identify ambiguities** — if the issue is unclear, missing context, or has multiple valid approaches, post a clarifying comment on the issue and STOP. Wait for a human response before proceeding. +5. **Assess severity**: + - **Critical/High**: Fix immediately (e.g., broken builds, data loss, security issues, production bugs). + - **Medium**: Review and fix (e.g., missing features, poor UX, technical debt). + - **Low**: Skip or defer (e.g., cosmetic issues, minor typos, nitpicks). +6. **Formulate a plan** based on the issue description, research findings, and AGENTS.md. +7. **Implement the fix** following the repository workflow above. +8. **Verify the fix**: + - If AGENTS.md has verification steps, follow them. + - Otherwise, review the diff vs master and validate code quality. + - Research dependencies to ensure code standards are met. +9. **Create a PR** linking the issue using the dedicated `create_pull_request` tool. Do NOT use the `tea` CLI via `run_command`. +10. **Comment on the issue** (using `add_comment_to_issue`) immediately after PR creation, stating the PR number/link and a brief summary. +11. **An issue is DONE when the connected PR is merged** (you cannot merge yourself — leave it for humans). + +#### When Fixing/Updating a PR (addressing review feedback): +1. **Read all review comments and change requests** on the PR carefully. +2. **Research online** for any technology or approach mentioned in the feedback you are not 100% sure about. +3. **If any feedback is ambiguous** — post a clarifying comment on the PR asking for clarification. Do NOT guess what the reviewer meant. Stop and wait for a reply. +4. **Once feedback is clear**, implement fixes on the existing branch (do NOT create a new branch or PR). +5. **Push and comment** on the PR with a summary of all changes made. + +#### When Reviewing a PR: +1. **DO NOT write files, make commits, push branches, or create any new PRs**. Your only task is to review the existing PR. +2. **Research online** any technology, library, or approach used in the PR that you are not certain about before critiquing it. +3. **Check out the diff** vs master: `git diff origin/master...HEAD`. +4. **Review code quality**: + - Does it follow project conventions (check AGENTS.md)? + - Are there security issues? + - Is there proper error handling? + - Are edge cases covered? + - Are dependencies used correctly? +5. **Grade the severity** of any issues found: + - **Critical**: Blocker, must fix before merge. + - **Medium**: Should fix, but can merge with notes. + - **Low**: Nice-to-have, can defer. +6. **Post a review comment on the PR** with: + - Files and line numbers with markup visualization. + - Severity grade. + - Specific feedback and suggested fixes. +7. **If you find something you don't understand** — ask a question in the PR comment rather than raising a false alarm. +8. **DO NOT fix the issues yourself** during review. The author (another agent or human) will fix them in the next loop. + +### 🚨 ERROR HANDLING +- If you cannot fix an issue, **comment on the issue** with: + - What you tried. + - What code changes you attempted. + - Why the fix failed. +- Do NOT mark the issue as done. The issue is only done when the PR is merged. +- If verification fails, continue debugging until it passes. + +### 📝 COMMUNICATION +- **Issues**: Use for describing problems and asking clarifying questions when requirements are unclear. +- **PRs**: Use for proposing changes with detailed explanations. +- **PR Comments**: Use for review feedback, questions, and status updates. +- **When uncertain**: ALWAYS post a question as a comment and stop work. Never silently assume. +- **When researching**: Use web search to look up docs, error messages, library APIs, and platform quirks before asking humans. +- Always be specific and actionable in your comments. + +### 🛑 FORBIDDEN ACTIONS +- Creating new repositories. +- Editing `.git` files (unless explicitly resolving a git issue). +- Merging PRs yourself (leave for humans). +- Working on non-`meeks` organization repos. +- Skipping AGENTS.md when available. +- Making unverified changes. + +### ✅ SUCCESS CRITERIA +An issue is resolved when: +1. A PR is created with the fix. +2. The PR is linked to the issue (using `closes #N`). +3. The fix has been verified. +4. The PR has been reviewed (by another agent or human). +5. The PR is merged by a human. + +You are the expert. Take charge. Follow the workflow exactly. +""" diff --git a/core/dispatcher.py b/core/dispatcher.py new file mode 100644 index 0000000..de8e5c8 --- /dev/null +++ b/core/dispatcher.py @@ -0,0 +1,500 @@ +"""Dispatches work to a single CodingAgent, one repo at a time.""" + +import logging +import re +from typing import Any +from core.coding_agent import CodingAgent +from core.queue import WorkItem +from gitea.tools.coding_tools import CodingTools +from gitea.tools.gitea_tools import GiteaTools +from gitea.client import GiteaClient +from core.coding_prompt import CODING_AGENT_SYSTEM_PROMPT +from gitea.config import AGENT_MODEL_ID +from gitea.workspace import WorkspaceManager +from gitea.models import CommentModel, PullRequestFileModel, PullRequestModel, IssueModel + +logger: logging.Logger = logging.getLogger("agent-dispatcher") + +AGENT_USERNAMES: frozenset[str] = frozenset({"meeks-ai", "agent-bot"}) + +CLOSE_KEYWORDS_PATTERN = re.compile( + rf"\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s+#(\d+)\b", + re.IGNORECASE +) + + +class AgentDispatcher: + """Dispatches work to a single CodingAgent, one repo at a time.""" + + def __init__( + self, + client: GiteaClient, + tools: GiteaTools, + model_name: str = AGENT_MODEL_ID, + max_retries: int = 2, + ) -> None: + self._client = client + self._tools = tools + self._model_name = model_name + self._max_retries = max_retries + + def _find_pr_for_issue(self, repo_full_name: str, issue_number: int) -> PullRequestModel | None: + """Find an open pull request that addresses the given issue number.""" + owner, repo_name = repo_full_name.split("/") + try: + prs = self._client.list_repo_pull_requests(owner, repo_name) + for pr in prs: + ref = pr.head.get("ref", "") if pr.head else "" + if re.search(rf"(? list[int]: + """Extract referenced issue numbers from the PR body.""" + matches = CLOSE_KEYWORDS_PATTERN.findall(pr_body) + return list(set(int(m) for m in matches)) + + def _is_awaiting_reply(self, comments: list[CommentModel]) -> bool: + """Return True if the agent's most recent comment contains the + awaiting-reply marker AND no human has commented after it. + The agent itself embeds the marker when it needs human input. + """ + if not comments: + return False + # Find the last agent comment index + last_agent_idx: int = -1 + for i, c in enumerate(comments): + if c.user and c.user.login in AGENT_USERNAMES: + last_agent_idx = i + if last_agent_idx == -1: + return False + last_agent_comment = comments[last_agent_idx] + body = (last_agent_comment.body or "") + # The agent explicitly embeds this marker when it is waiting for input + if "" not in body: + return False + # Check if any human replied AFTER the last agent comment + for c in comments[last_agent_idx + 1:]: + if c.user and c.user.login not in AGENT_USERNAMES: + return False # Human replied — we can proceed + return True # Agent signalled wait, no human replied yet + + async def dispatch( + self, + repo: str, + work_items: list[WorkItem], + ) -> list[str]: + """Dispatch all work for a single repo to a fresh agent, then discard it.""" + workspace = WorkspaceManager() + repo_path = workspace.get_repo_path(repo) + coding_tools = CodingTools(str(repo_path)) + + planning_tools: list[Any] = [ + self._tools.get_issue, + self._tools.get_pull_request, + self._tools.list_issues, + self._tools.list_pull_requests, + self._tools.get_file_content, + self._tools.get_issue_comments, + self._tools.get_pull_request_comments, + self._tools.get_pull_request_diff, + self._tools.get_pull_request_patch, + coding_tools.list_files, + coding_tools.read_file, + coding_tools.grep_search, + coding_tools.get_working_directory, + coding_tools.run_command, + ] + + coding_tools_list: list[Any] = [ + self._tools.get_issue, + self._tools.get_pull_request, + self._tools.list_issues, + self._tools.list_pull_requests, + self._tools.get_file_content, + self._tools.create_pull_request, + self._tools.add_label_to_issue, + self._tools.add_label_to_pr, + self._tools.create_branch, + self._tools.commit_file, + self._tools.create_issue, + self._tools.add_comment_to_issue, + self._tools.close_issue, + self._tools.close_pull_request, + self._tools.get_issue_comments, + self._tools.get_pull_request_comments, + self._tools.add_comment, + self._tools.add_label, + self._tools.update_file, + self._tools.get_pull_request_diff, + self._tools.get_pull_request_patch, + self._tools.approve_pull_request, + self._tools.request_changes, + coding_tools.list_files, + coding_tools.read_file, + coding_tools.write_file, + coding_tools.edit_file, + coding_tools.run_command, + coding_tools.grep_search, + coding_tools.get_working_directory, + ] + + results: list[str] = [] + + import os + original_cwd = os.getcwd() + changed_dir = False + if os.path.isdir(str(repo_path)): + os.chdir(str(repo_path)) + changed_dir = True + try: + for item in work_items: + if item.task_type == "issue": + existing_pr = self._find_pr_for_issue(repo, item.task_number) + if existing_pr: + logger.info(f"Issue #{item.task_number} already has open PR #{existing_pr.number}. Skipping.") + results.append(f"SKIP: A pull request (PR #{existing_pr.number}) addressing issue #{item.task_number} already exists.") + continue + + # Check if we're waiting for a human reply before acting + owner, repo_name = repo.split("/") + issue_comments = [] + try: + issue_comments = self._client.get_issue_comments(owner, repo_name, item.task_number) + except Exception: + pass + if self._is_awaiting_reply(issue_comments): + logger.info(f"Issue #{item.task_number}: agent asked a question and is awaiting a human reply. Skipping.") + results.append(f"SKIP: Awaiting human reply on issue #{item.task_number}.") + continue + + elif item.task_type == "pr": + # Check if we're waiting for a human reply before acting on a PR + owner, repo_name = repo.split("/") + pr_comments = [] + try: + pr_comments = self._client.get_pull_request_comments(owner, repo_name, item.task_number) + except Exception: + pass + if self._is_awaiting_reply(pr_comments): + logger.info(f"PR #{item.task_number}: agent asked a question and is awaiting a human reply. Skipping.") + results.append(f"SKIP: Awaiting human reply on PR #{item.task_number}.") + continue + + for attempt in range(1, self._max_retries + 1): + try: + if item.task_type == "issue": + base_mission = self._build_issue_mission(item) + else: + base_mission = self._build_pr_mission(item) + + if base_mission.startswith("SKIP:"): + logger.info(f"Skipping task #{item.task_number}: {base_mission}") + results.append(base_mission) + break + + # Step 1: Planning Phase + logger.info(f"Starting Planning Phase for {item.task_type} #{item.task_number} (attempt {attempt})") + planning_mission = ( + f"PHASE 1: PLANNING PHASE\n\n" + f"Your task is to analyze the repository structure and create a detailed implementation plan.\n" + f"Original Mission details:\n{base_mission}\n\n" + f"CRITICAL RULES:\n" + f"1. You are ONLY generating an implementation plan. DO NOT write files, DO NOT edit files, DO NOT commit, DO NOT push, and DO NOT create branches or PRs.\n" + f"2. Explore the codebase using read_file, list_files, grep_search, or run_command (for read-only queries like find/grep).\n" + f"3. Output your final plan clearly, describing the exact changes to be made and which files to modify.\n" + ) + planning_agent = CodingAgent(self._model_name) + plan = await planning_agent.run_with_tools(planning_mission, planning_tools) + logger.info(f"Generated Plan:\n{plan}") + + # Step 2: Coding Phase + logger.info(f"Starting Execution/Coding Phase for {item.task_type} #{item.task_number} (attempt {attempt})") + coding_mission = ( + f"PHASE 2: EXECUTION/CODING PHASE\n\n" + f"You must now implement the changes based on the following plan generated in Phase 1:\n" + f"--- PLAN ---\n{plan}\n--- PLAN END ---\n\n" + f"Original Mission details:\n{base_mission}\n\n" + f"Follow the repository workflow to make the changes, verify them, commit, push, and create a PR.\n" + ) + coding_agent = CodingAgent(self._model_name) + response = await coding_agent.run_with_tools(coding_mission, coding_tools_list) + logger.info(f"Agent response for {item.task_type} #{item.task_number}: {response}") + results.append(response) + break + except Exception as e: + logger.error(f"Error processing {item.task_type} #{item.task_number} (attempt {attempt}/{self._max_retries}): {e}") + if attempt == self._max_retries: + results.append(f"FAILED after {self._max_retries} attempts: {str(e)}") + finally: + if changed_dir: + os.chdir(original_cwd) + + return results + + def _build_issue_mission(self, item: WorkItem) -> str: + issue_info = item.task_info + assert isinstance(issue_info, IssueModel) + + + repo_full_name: str = item.repo_full_name + issue_number: int = item.task_number + + issue_body: str = issue_info.body or "No description provided." + issue_labels: list[str] = [lbl.name for lbl in issue_info.labels] + issue_user: str = issue_info.user.login if issue_info.user else "unknown" + issue_created: str = issue_info.created_at or "unknown" + title: str = issue_info.title + + + owner: str = repo_full_name.split("/")[0] + repo_name: str = repo_full_name.split("/")[1] + + comments: list[CommentModel] = [] + try: + comments = self._client.get_issue_comments(owner, repo_name, issue_number) + except Exception: + pass + + labels_str: str = f"Labels: {', '.join(issue_labels)}" if issue_labels else "Labels: none" + comments_str: str = "\n".join([ + f"- @{c.user.login} ({c.created_at}): {c.body}" + for c in comments + ]) if comments else "No comments yet." + + clean_title = re.sub(r'[^a-zA-Z0-9\s-]', '', title).strip().lower() + title_words = clean_title.split()[:5] + desc_suffix = "-".join(title_words) + if not desc_suffix: + desc_suffix = "fix-issue" + branch_name: str = f"fix/issue-{issue_number}-{desc_suffix}" + + workspace = WorkspaceManager() + repo_path = workspace.get_repo_path(repo_full_name) + + return ( + f"Your mission is to resolve issue #{issue_number} in {repo_full_name}.\n\n" + f"Issue: {title}\n" + f"Author: @{issue_user} (created {issue_created})\n" + f"{labels_str}\n\n" + f"Description:\n{issue_body}\n\n" + f"Comments ({len(comments)}):\n{comments_str}\n\n" + f"Branch name: {branch_name}.\n\n" + "BEFORE WRITING ANY CODE:\n" + " - Search online for relevant documentation, known solutions, library APIs, and platform-specific behavior.\n" + " - If ANY part of the issue is unclear, ambiguous, or has multiple valid approaches:\n" + " → Post a comment on the issue using `add_comment_to_issue` with your specific question(s).\n" + " → List the approaches you are considering.\n" + " → End the comment with the marker: on its own line.\n" + " → STOP. Do NOT proceed until a human replies. The system will re-dispatch you once a human responds.\n" + " - Never assume or guess. Always prefer asking over guessing.\n\n" + "CRITICAL INSTRUCTIONS:\n" + f"1. The repo is already cloned locally at '{repo_path}'. DO NOT create a new repository.\n" + f" The repository directory is your current working directory (cwd). You can verify this via the `get_working_directory` tool.\n" + "2. Always start from master: `git checkout master && git pull origin master`\n" + "3. Branch from master: `git checkout -b /issue--`\n" + " Branch names MUST include a descriptive name (words/hyphens), not just the issue number.\n" + " Types: feat, fix, chore, docs, style, refactor, test, build, ci, perf\n" + "4. Use `edit_file`/`write_file` for code changes, then `git add` and `git commit` via `run_command`.\n" + "5. Push: `git push origin ` via `run_command`.\n" + "6. Create PR: Use the `create_pull_request` tool (do NOT use Gitea's `tea` CLI or GitHub's `gh` CLI in run_command, as they can hang/freeze interactively).\n" + " PR description MUST include the Gitea automation template with 'closes #'.\n" + "7. IMPORTANT: After successfully creating the pull request, you MUST comment on the issue (using the `add_comment_to_issue` tool) with the PR number, PR link, and summary.\n" + "8. Check AGENTS.md in repo root for project conventions and verification steps.\n" + "9. Grade severity: Critical/High = fix, Medium = review/fix, Low = skip.\n" + "An issue is DONE when the connected PR is merged (you cannot merge yourself).\n" + "DO NOT edit .git files unless explicitly resolving a git issue.\n" + "DO NOT work on non-meeks organization repos." + ) + + def _build_pr_mission(self, item: WorkItem) -> str: + pr_info = item.task_info + assert isinstance(pr_info, PullRequestModel) + + + repo_full_name: str = item.repo_full_name + pr_number: int = item.task_number + + owner: str = repo_full_name.split("/")[0] + repo_name: str = repo_full_name.split("/")[1] + + pr_model = self._client.get_pull_request(owner, repo_name, pr_number) + pr_details: str = pr_model.model_dump_json(indent=2) + pr_diff: str = "" + try: + pr_diff = self._client.get_pull_request_diff(owner, repo_name, pr_number) + except Exception as e: + logger.warning(f"Could not fetch PR diff for #{pr_number}: {e}") + pr_diff = f"Error fetching diff: {e}" + + + pr_files: list[PullRequestFileModel] = [] + try: + pr_files = self._client.get_pull_request_files(owner, repo_name, pr_number) + except Exception: + pass + + files_summary: str = "\n".join([f"- {f.filename}" for f in pr_files]) if pr_files else "No files available." + + comments: list[CommentModel] = [] + try: + comments = self._client.get_pull_request_comments(owner, repo_name, pr_number) + if not isinstance(comments, list): + comments = [] + except Exception: + pass + + reviews: list[dict[str, Any]] = [] + try: + reviews = self._client.get_pr_reviews(owner, repo_name, pr_number) + if not isinstance(reviews, list): + reviews = [] + except Exception: + pass + + ai_username = "meeks-ai" + try: + user = self._client.get_authenticated_user() + if user: + ai_username = user.login + except Exception: + pass + + # Create a combined, sorted timeline of timeline comments and reviews + timeline: list[dict[str, Any]] = [] + for c in comments: + timeline.append({ + "timestamp": c.created_at or "", + "user": c.user.login, + "type": "comment", + "body": c.body, + "by_ai": "Reviewed by AI Agent" in c.body or c.user.login == ai_username + }) + for r in reviews: + r_user = (r.get("user") or {}).get("login", "unknown") + r_body = r.get("body", "") + r_state = r.get("state", "") + timeline.append({ + "timestamp": r.get("submitted_at") or r.get("updated_at") or "", + "user": r_user, + "type": "review", + "body": f"[{r_state}] {r_body}", + "by_ai": r_user == ai_username + }) + + timeline.sort(key=lambda x: x["timestamp"]) + + last_action_by_ai = False + if timeline: + last_action_by_ai = timeline[-1]["by_ai"] + + pr_author: str = pr_info.user.login if pr_info.user else "unknown" + is_own_pr = (pr_author == ai_username) + + # Skip if the latest action is already by AI (waiting for human turn) + if last_action_by_ai: + logger.info(f"PR #{pr_number} already addressed by AI. Skipping.") + return f"SKIP: PR #{pr_number} has already been addressed by AI. No new action needed." + + comments_str: str = "\n".join([ + f"- @{c.user.login} ({c.created_at}): {c.body}" + for c in comments + ]) if comments else "No comments yet." + + reviews_str: str = "\n".join([ + f"- @{(r.get('user') or {}).get('login')} ({r.get('submitted_at')}): [{r.get('state')}] {r.get('body')}" + for r in reviews + ]) if reviews else "No reviews yet." + + connected_issues_ctx = "" + pr_body = pr_info.body or "" + linked_issues = self._find_issues_for_pr(pr_body) + if linked_issues: + issues_details = [] + for issue_num in linked_issues: + try: + issue = self._client.get_issue(owner, repo_name, issue_num) + issue_comments = self._client.get_issue_comments(owner, repo_name, issue_num) + comments_list = "\n".join([ + f" - @{c.user.login} ({c.created_at}): {c.body}" + for c in issue_comments + ]) if issue_comments else " No comments yet." + + issues_details.append( + f"### Connected Issue #{issue_num}: {issue.title}\n" + f"Author: @{issue.user.login} (created {issue.created_at})\n" + f"Description:\n{issue.body or 'No description'}\n" + f"Discussion:\n{comments_list}" + ) + except Exception as e: + logger.warning(f"Could not fetch connected issue #{issue_num}: {e}") + if issues_details: + connected_issues_ctx = "\n---\n\n## 📋 CONNECTED ISSUE CONTEXT\n" + "\n\n".join(issues_details) + + workspace = WorkspaceManager() + repo_path = workspace.get_repo_path(repo_full_name) + pr_head_branch: str = pr_info.head.get('ref', 'unknown') if pr_info.head else "unknown" + pr_base_branch: str = pr_info.base.get('ref', 'unknown') if pr_info.base else "unknown" + pr_state: str = pr_info.state + pr_created: str = pr_info.created_at or "unknown" + + # Dynamically determine instructions based on ownership/comments + is_fixing_pr = is_own_pr or any(r.get("state") == "REQUEST_CHANGES" for r in reviews) + + if is_fixing_pr: + instructions = ( + f" Note: The repository is located locally at '{repo_path}'. This repository directory is your current working directory (cwd). You can verify this via the `get_working_directory` tool.\n" + f" Your task is to FIX/UPDATE this PR by addressing comments/change requests.\n" + f" DO NOT create a new branch or PR. Follow this exact workflow:\n" + f" 1. Checkout the PR's head branch: `git checkout {pr_head_branch}`\n" + f" 2. Implement the requested fixes or changes on this branch.\n" + f" 3. Verify your fixes and run verification/tests.\n" + f" 4. Commit and push the changes directly: `git add && git commit -m \"fix: address feedback\" && git push origin {pr_head_branch}`\n" + f" 5. After pushing, comment on the PR (using the `add_comment` tool) with a summary of the fixes implemented." + ) + else: + instructions = ( + f" Note: The repository is located locally at '{repo_path}'. This repository directory is your current working directory (cwd). You can verify this via the `get_working_directory` tool.\n" + " CRITICAL: You are ONLY reviewing this PR. DO NOT edit files, DO NOT make commits, DO NOT push branches, and DO NOT create any new PRs.\n" + "1. Read the PR diff carefully.\n" + "2. Analyze the changes for correctness, quality, and potential issues.\n" + "3. Check for: code quality, security issues, edge cases, test coverage.\n" + "4. If the PR is good: approve it (using approve_pull_request tool) with a meaningful comment.\n" + "5. If the PR has issues: request changes (using request_changes tool) with specific feedback.\n" + "6. Post your review comment on the PR (using add_comment tool).\n" + "IMPORTANT: Never merge the PR yourself - that is handled by humans." + ) + + return ( + f"Your mission is to process PR #{pr_number} in {repo_full_name}.\n\n" + f"PR: {pr_info.title}\n" + f"Author: @{pr_author}\n" + f"Branch: {pr_head_branch} → {pr_base_branch}\n" + f"State: {pr_state} (created {pr_created})\n\n" + f"Description:\n{pr_info.body or 'No description'}\n\n" + f"Files Changed ({len(pr_files)}):\n{files_summary}\n\n" + f"Timeline Comments:\n{comments_str}\n\n" + f"Reviews:\n{reviews_str}\n\n" + f"{connected_issues_ctx}\n\n" + "BEFORE MAKING ANY CHANGES:\n" + " - Search online for any technology, API, or behavior you are not 100% certain about.\n" + " - Read ALL review comments and change requests carefully.\n" + " - If any review comment is ambiguous or unclear:\n" + " → Post a clarifying comment on the PR (using the `add_comment` tool) with your specific question(s).\n" + " → End the comment with the marker: on its own line.\n" + " → STOP. Do NOT implement anything until a human replies. The system will re-dispatch you once a human responds.\n" + " - Never assume or guess what a reviewer meant. Always prefer asking over guessing.\n\n" + f"Instructions:\n{instructions}" + ) + + diff --git a/core/factory.py b/core/factory.py new file mode 100644 index 0000000..c6eee53 --- /dev/null +++ b/core/factory.py @@ -0,0 +1,69 @@ +from gitea.client import GiteaClient +from core.interfaces import ( + IssuesClient, + PullRequestsClient, + FilesClient, + RefsClient, + ReposClient, +) +from core.coding_agent import CodingAgent +from core.agent import CavemanAgent +from gitea.workspace import WorkspaceManager + + +class GiteaClientFactory: + """Factory for creating Gitea client components with dependency injection support.""" + + @staticmethod + def create_full_client() -> GiteaClient: + return GiteaClient() + + @staticmethod + def create_issues_client(client: GiteaClient | None = None) -> IssuesClient: + if client is None: + client = GiteaClient() + return client + + @staticmethod + def create_prs_client(client: GiteaClient | None = None) -> PullRequestsClient: + if client is None: + client = GiteaClient() + return client + + @staticmethod + def create_files_client(client: GiteaClient | None = None) -> FilesClient: + if client is None: + client = GiteaClient() + return client + + @staticmethod + def create_refs_client(client: GiteaClient | None = None) -> RefsClient: + if client is None: + client = GiteaClient() + return client + + @staticmethod + def create_repos_client(client: GiteaClient | None = None) -> ReposClient: + if client is None: + client = GiteaClient() + return client + + +class AgentFactory: + """Factory for creating AI agent instances.""" + + @staticmethod + def create_coding_agent(model_name: str) -> CodingAgent: + return CodingAgent(model_name) + + @staticmethod + def create_caveman_agent(model_name: str) -> CavemanAgent: + return CavemanAgent(model_name) + + +class WorkspaceFactory: + """Factory for creating workspace manager instances.""" + + @staticmethod + def create_workspace() -> WorkspaceManager: + return WorkspaceManager() diff --git a/core/interfaces.py b/core/interfaces.py new file mode 100644 index 0000000..24601c9 --- /dev/null +++ b/core/interfaces.py @@ -0,0 +1,171 @@ +"""Interfaces for Gitea operations.""" + +from abc import ABC, abstractmethod +from typing import Any +from gitea.models import ( + IssueModel, + PullRequestModel, + CommentModel, + LabelModel, + UserModel, + RepositoryModel, + PullRequestFileModel, +) + + +class IssuesClient(ABC): + """Interface for Gitea issue operations.""" + + @abstractmethod + def list_repo_issues(self, owner: str, repo: str, state: str = "open") -> list[IssueModel]: ... + + @abstractmethod + def get_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel: ... + + @abstractmethod + def close_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel: ... + + @abstractmethod + def get_issue_comments(self, owner: str, repo: str, issue_number: int) -> list[CommentModel]: ... + + @abstractmethod + def list_assigned_issues(self, owner: str, repo: str) -> list[IssueModel]: ... + + @abstractmethod + def create_issue( + self, + owner: str, + repo: str, + title: str, + body: str, + labels: list[str] | None = None, + assignees: list[str] | None = None, + ) -> IssueModel: ... + + @abstractmethod + def add_comment(self, owner: str, repo: str, issue_number: int, body: str) -> CommentModel: ... + + @abstractmethod + def add_label(self, owner: str, repo: str, issue_number: int, label: str) -> LabelModel: ... + + +class PullRequestsClient(ABC): + """Interface for Gitea pull request operations.""" + + @abstractmethod + def list_repo_pull_requests(self, owner: str, repo: str, state: str = "open") -> list[PullRequestModel]: ... + + @abstractmethod + def get_pull_request(self, owner: str, repo: str, pull_number: int) -> PullRequestModel: ... + + @abstractmethod + def close_pull_request(self, owner: str, repo: str, pull_number: int) -> PullRequestModel: ... + + @abstractmethod + def get_pull_request_comments(self, owner: str, repo: str, pull_number: int) -> list[CommentModel]: ... + + @abstractmethod + def get_pr_reviews(self, owner: str, repo: str, pr_number: int) -> list[dict[str, Any]]: ... + + @abstractmethod + def get_pull_request_diff(self, owner: str, repo: str, pull_number: int) -> str: ... + + @abstractmethod + def get_pull_request_patch(self, owner: str, repo: str, pull_number: int) -> str: ... + + @abstractmethod + def get_pull_request_files(self, owner: str, repo: str, pull_number: int) -> list[PullRequestFileModel]: ... + + @abstractmethod + def list_assigned_pull_requests(self, owner: str, repo: str) -> list[PullRequestModel]: ... + + @abstractmethod + def create_pull_request( + self, owner: str, repo: str, head: str, base: str, title: str, description: str = "" + ) -> PullRequestModel: ... + + @abstractmethod + def approve_pr(self, owner: str, repo: str, pr_number: int, comment: str) -> dict[str, Any]: ... + + @abstractmethod + def request_changes_pr(self, owner: str, repo: str, pr_number: int, comment: str) -> dict[str, Any]: ... + + @abstractmethod + def dismiss_review_pr( + self, owner: str, repo: str, pr_number: int, review_id: int, message: str + ) -> dict[str, Any]: ... + + @abstractmethod + def assign_issue(self, owner: str, repo: str, issue_number: int, username: str) -> IssueModel: ... + + +class FilesClient(ABC): + """Interface for Gitea file/content operations.""" + + @abstractmethod + def get_file_content(self, owner: str, repo: str, path: str, ref: str = "master") -> str | list[str]: ... + + @abstractmethod + def update_file( + self, owner: str, repo: str, path: str, message: str, content: str, branch: str + ) -> dict[str, Any]: ... + + +class RefsClient(ABC): + """Interface for Gitea git ref operations.""" + + @abstractmethod + def update_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]: ... + + @abstractmethod + def create_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]: ... + + +class ReposClient(ABC): + """Interface for Gitea repository operations.""" + + @abstractmethod + def list_all_user_repos(self) -> list[RepositoryModel]: ... + + +class Agent(ABC): + """Interface for AI agent operations.""" + + @abstractmethod + async def initialize(self) -> None: ... + + @abstractmethod + async def run(self, user_input: str) -> str: ... + + @abstractmethod + async def run_with_tools(self, user_input: str, tools: list[Any]) -> str: ... + + +class Workspace(ABC): + """Interface for workspace management.""" + + @abstractmethod + def get_repo_path(self, repo_full_name: str) -> Any: ... + + @abstractmethod + def sanitize_repo(self, repo_path: Any) -> None: ... + + @abstractmethod + def clone_repo(self, repo_full_name: str, clone_url: str) -> Any: ... + + +class MissionBuilder(ABC): + """Interface for mission string construction.""" + + @abstractmethod + def build_issue_mission(self, issue_info: dict[str, Any], branch_name: str) -> str: ... + + @abstractmethod + def build_pr_mission(self, pr_info: dict[str, Any]) -> str: ... + + +class BranchStrategy(ABC): + """Interface for branch creation strategy.""" + + @abstractmethod + async def create_or_reuse_branch(self, repo_path: Any, branch_name: str, base_branch: str | None = None) -> str: ... diff --git a/core/orchestrator.py b/core/orchestrator.py new file mode 100644 index 0000000..f51d5fc --- /dev/null +++ b/core/orchestrator.py @@ -0,0 +1,96 @@ +"""Top-level coordinator: polls Gitea, queues work, dispatches to agent.""" + +import asyncio +import logging +from pathlib import Path +from typing import Any +from core.queue import WorkQueue, WorkItem +from core.dispatcher import AgentDispatcher +from gitea.client import GiteaClient +from gitea.models import IssueModel, PullRequestModel +from gitea.tools.gitea_tools import GiteaTools +from gitea.config import AGENT_MODEL_ID, AGENT_MAX_RETRIES +from gitea.workspace import WorkspaceManager + +logger: logging.Logger = logging.getLogger("agent-orchestrator") + + +class AgentOrchestrator: + """Top-level coordinator: polls Gitea, queues work, dispatches to agent.""" + + def __init__( + self, + client: GiteaClient, + tools: GiteaTools, + model_name: str = AGENT_MODEL_ID, + max_retries: int = AGENT_MAX_RETRIES, + ) -> None: + self._client = client + self._tools = tools + self._model_name = model_name + self._work_queue = WorkQueue() + self._dispatcher = AgentDispatcher(client, tools, model_name, max_retries) + + async def poll_and_dispatch(self) -> None: + """Poll Gitea for tasks, enqueue them, and dispatch to agent.""" + issues: list[IssueModel] = self._client.list_assigned_issues() + prs: list[PullRequestModel] = self._client.list_assigned_pull_requests() + + if issues: + logger.info(f"Found {len(issues)} assigned issues") + self._enqueue_tasks("issue", issues) + else: + logger.info("No assigned issues found.") + + if prs: + logger.info(f"Found {len(prs)} assigned PRs") + self._enqueue_tasks("pr", prs) + else: + logger.info("No assigned PRs found.") + + if not self._work_queue.is_empty: + await self._process_work() + + def _enqueue_tasks(self, task_type: str, tasks: list[IssueModel] | list[PullRequestModel]) -> None: + for task in tasks: + repo_full_name: str | None = task.repository.full_name if task.repository else None + task_number: int = task.number + if not repo_full_name or not task_number: + continue + + item = WorkItem( + repo_full_name=repo_full_name, + task_type=task_type, + task_number=task_number, + task_info=task, + priority=0, + ) + self._work_queue.enqueue(item) + logger.info(f"Enqueued {task_type} #{task_number} from {repo_full_name}") + + async def _process_work(self) -> None: + """Process all queued work, repo by repo.""" + while not self._work_queue.is_empty: + repo: str | None = self._work_queue.get_next_repo() + if not repo: + break + + work_items: list[WorkItem] = self._work_queue.get_repo_work(repo) + self._work_queue.remove_repo_work(repo) + + # Ensure the workspace repository is cloned and sanitized + workspace = WorkspaceManager() + repo_path = workspace.get_repo_path(repo) + if not repo_path.exists(): + workspace.clone_repo(repo) + logger.info(f"Cloned {repo} to {repo_path}") + else: + workspace.sanitize_repo(repo, repo_path) + logger.info(f"Sanitized existing repo at {repo_path}") + + logger.info(f"Dispatching {len(work_items)} tasks for {repo}") + results: list[str] = await self._dispatcher.dispatch(repo, work_items) + + for i, result in enumerate(results): + item = work_items[i] + logger.info(f"Completed {item.task_type} #{item.task_number}: {result[:200]}") diff --git a/core/prompt.py b/core/prompt.py new file mode 100644 index 0000000..5610322 --- /dev/null +++ b/core/prompt.py @@ -0,0 +1,20 @@ +CAVEMAN_PROMPT = """ +CAVEMAN SYSTEM PROMPT: + +You are Caveman Agent. +Goal: Minimal tokens. Efficient work. + +Rules: +1. Use fewest words possible. +2. Drop non-essential words (e.g., "the", "is", "a"). +3. Use acronyms (PR, issue, repo, etc.). +4. If task simple, answer direct. +5. No preamble. No polite talk. Just work. + +Example: +User: What is the status of PR #1? +Caveman: PR #1 open. Reviewing... + +Follow instructions. Use tools. Execute tasks. +No extra chatter. +""" diff --git a/core/queue.py b/core/queue.py new file mode 100644 index 0000000..0a75eea --- /dev/null +++ b/core/queue.py @@ -0,0 +1,54 @@ +from pydantic import BaseModel +from typing import Any +from gitea.models import IssueModel, PullRequestModel + + +class WorkItem(BaseModel): + repo_full_name: str + task_type: str # 'issue' or 'pr' + task_number: int + task_info: IssueModel | PullRequestModel + priority: int = 0 + + +class WorkQueue: + """Thread-safe work queue grouped by repo.""" + + def __init__(self) -> None: + self._queue: list[WorkItem] = [] + self._enqueued_repos: set[str] = set() + + def enqueue(self, item: WorkItem) -> None: + self._queue.append(item) + self._enqueued_repos.add(item.repo_full_name) + + def enqueue_batch(self, items: list[WorkItem]) -> None: + for item in items: + self.enqueue(item) + + def get_repo_work(self, repo: str) -> list[WorkItem]: + """Get all work items for a specific repo.""" + items: list[WorkItem] = [ + item for item in self._queue if item.repo_full_name == repo + ] + return items + + def remove_repo_work(self, repo: str) -> None: + """Remove all work items for a specific repo.""" + self._queue = [ + item for item in self._queue if item.repo_full_name != repo + ] + self._enqueued_repos.discard(repo) + + def get_next_repo(self) -> str | None: + """Get the next repo with work, or None if empty.""" + if not self._enqueued_repos: + return None + return next(iter(self._enqueued_repos)) + + @property + def is_empty(self) -> bool: + return len(self._queue) == 0 + + def __len__(self) -> int: + return len(self._queue) diff --git a/gitea/client.py b/gitea/client.py new file mode 100644 index 0000000..2b771fe --- /dev/null +++ b/gitea/client.py @@ -0,0 +1,372 @@ +import httpx +import json +import base64 +from typing import Any +from .config import GITEA_URL, GITEA_TOKEN +from .models import ( + UserModel, + LabelModel, + RepositoryModel, + IssueModel, + PullRequestModel, + CommentModel, + PullRequestFileModel, +) +from core.interfaces import ( + IssuesClient, + PullRequestsClient, + FilesClient, + RefsClient, + ReposClient, +) + + +class GiteaClient(IssuesClient, PullRequestsClient, FilesClient, RefsClient, ReposClient): + """HTTP client for Gitea API v1.""" + + def __init__(self) -> None: + self.base_url: str = GITEA_URL.rstrip("/") + self.headers: dict[str, str] = { + "Authorization": f"token {GITEA_TOKEN}", + "Accept": "application/json", + } + + def get_authenticated_user(self) -> UserModel | None: + try: + with httpx.Client() as client: + response = client.get(f"{self.base_url}/api/v1/user", headers=self.headers) + response.raise_for_status() + return UserModel(**response.json()) + except Exception as e: + print(f"Error getting authenticated user: {e}") + return None + + def list_all_user_repos(self) -> list[RepositoryModel]: + try: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/user/repos" + response = client.get(url, headers=self.headers) + response.raise_for_status() + repos: list[dict[str, Any]] = response.json() + # Filter to ONLY meeks organization repos, include mirrors + seen: set[str] = set() + result: list[RepositoryModel] = [] + for r in repos: + full_name = r.get("full_name", "") + if full_name and full_name not in seen and (r.get("owner") or {}).get("login") == "meeks": + seen.add(full_name) + result.append(RepositoryModel(**r)) + return result + except Exception as e: + print(f"Error listing user repos: {e}") + return [] + + def list_repo_issues(self, owner: str, repo: str, state: str = "open") -> list[IssueModel]: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues?type=issues&state={state}" + response = client.get(url, headers=self.headers) + response.raise_for_status() + return [IssueModel(**item) for item in response.json()] + + def list_repo_pull_requests(self, owner: str, repo: str, state: str = "open") -> list[PullRequestModel]: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls?state={state}" + response = client.get(url, headers=self.headers) + if response.status_code == 404: + return [] + response.raise_for_status() + return [PullRequestModel(**item) for item in response.json()] + + def get_pull_request(self, owner: str, repo: str, pull_number: int) -> PullRequestModel: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}" + response = client.get(url, headers=self.headers) + response.raise_for_status() + return PullRequestModel(**response.json()) + + def get_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}" + response = client.get(url, headers=self.headers) + response.raise_for_status() + return IssueModel(**response.json()) + + def close_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}" + data: dict[str, str] = {"state": "closed"} + response = client.patch(url, headers=self.headers, json=data) + response.raise_for_status() + return IssueModel(**response.json()) + + def close_pull_request(self, owner: str, repo: str, pull_number: int) -> PullRequestModel: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}" + data: dict[str, str] = {"state": "closed"} + response = client.patch(url, headers=self.headers, json=data) + response.raise_for_status() + return PullRequestModel(**response.json()) + + def get_issue_comments(self, owner: str, repo: str, issue_number: int) -> list[CommentModel]: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments" + response = client.get(url, headers=self.headers) + response.raise_for_status() + return [CommentModel(**item) for item in response.json()] + + def get_pull_request_comments(self, owner: str, repo: str, pull_number: int) -> list[CommentModel]: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{pull_number}/comments" + response = client.get(url, headers=self.headers) + response.raise_for_status() + return [CommentModel(**item) for item in response.json()] + + def get_pull_request_diff(self, owner: str, repo: str, pull_number: int) -> str: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/diff" + response = client.get(url, headers=self.headers) + response.raise_for_status() + return response.text + + def get_pull_request_patch(self, owner: str, repo: str, pull_number: int) -> str: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/patch" + response = client.get(url, headers=self.headers) + response.raise_for_status() + return response.text + + def get_pull_request_files(self, owner: str, repo: str, pull_number: int) -> list[PullRequestFileModel]: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/files" + response = client.get(url, headers=self.headers) + response.raise_for_status() + return [PullRequestFileModel(**item) for item in response.json()] + + def list_assigned_issues(self, owner: str = "", repo: str = "") -> list[IssueModel]: + try: + user = self.get_authenticated_user() + if not user: + return [] + username: str = user.login + if owner and repo: + response = httpx.get( + f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues?assignee={username}&state=open&type=issues", + headers=self.headers, + ) + response.raise_for_status() + return [IssueModel(**item) for item in response.json()] + all_issues: list[IssueModel] = [] + repos = self.list_all_user_repos() + for r in repos: + repo_owner = r.owner if hasattr(r, 'owner') else (r.get("owner") or {}).get("login", "") + repo_name = r.name if hasattr(r, 'name') else r.get("name", "") + resp = httpx.get( + f"{self.base_url}/api/v1/repos/{repo_owner}/{repo_name}/issues?assignee={username}&state=open&type=issues", + headers=self.headers, + ) + if resp.status_code == 200: + for item in resp.json(): + issue = IssueModel(**item) + # Backfill repository if Gitea omitted it + if issue.repository is None: + issue = issue.model_copy(update={"repository": r}) + all_issues.append(issue) + return all_issues + except Exception as e: + print(f"DEBUG: list_assigned_issues error: {e}") + return [] + + def list_assigned_pull_requests(self, owner: str = "", repo: str = "") -> list[PullRequestModel]: + """List all pull requests assigned to or authored by the authenticated user.""" + try: + user = self.get_authenticated_user() + if not user: + return [] + username: str = user.login + if owner and repo: + response = httpx.get( + f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls?state=open", + headers=self.headers, + ) + if response.status_code == 404: + return [] + response.raise_for_status() + all_prs: list[PullRequestModel] = [PullRequestModel(**pr) for pr in response.json()] + return [ + pr for pr in all_prs + if (pr.assignee and pr.assignee.login == username) or (pr.user and pr.user.login == username) + ] + all_prs: list[PullRequestModel] = [] + repos = self.list_all_user_repos() + for r in repos: + repo_owner = r.owner if hasattr(r, 'owner') else (r.get("owner") or {}).get("login", "") + repo_name = r.name if hasattr(r, 'name') else r.get("name", "") + resp = httpx.get( + f"{self.base_url}/api/v1/repos/{repo_owner}/{repo_name}/pulls?state=open", + headers=self.headers, + ) + if resp.status_code == 200: + for pr_data in resp.json(): + pr = PullRequestModel(**pr_data) + if (pr.assignee and pr.assignee.login == username) or (pr.user and pr.user.login == username): + # Backfill repository if Gitea omitted it + if pr.repository is None: + pr = pr.model_copy(update={"repository": r}) + all_prs.append(pr) + return all_prs + except Exception as e: + print(f"DEBUG: list_assigned_pull_requests error: {e}") + return [] + + def create_pull_request( + self, owner: str, repo: str, head: str, base: str, title: str, description: str = "" + ) -> PullRequestModel: + try: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls" + data: dict[str, str] = { + "title": title, + "body": description, + "head": head, + "base": base, + } + response = client.post(url, headers=self.headers, json=data) + response.raise_for_status() + return PullRequestModel(**response.json()) + except Exception as e: + print(f"Error creating pull request: {e}") + raise + + def create_pr_via_tea( + self, owner: str, repo: str, title: str, description: str, head: str, base: str + ) -> PullRequestModel: + return self.create_pull_request(owner, repo, head, base, title, description) + + def approve_pr(self, owner: str, repo: str, pr_number: int, comment: str) -> dict[str, Any]: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews" + data: dict[str, Any] = {"event": "APPROVED", "body": comment} + response = client.post(url, headers=self.headers, json=data) + response.raise_for_status() + return response.json() + + def request_changes_pr(self, owner: str, repo: str, pr_number: int, comment: str) -> dict[str, Any]: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews" + data: dict[str, Any] = {"event": "REQUEST_CHANGES", "body": comment} + response = client.post(url, headers=self.headers, json=data) + response.raise_for_status() + return response.json() + + def get_pr_reviews(self, owner: str, repo: str, pr_number: int) -> list[dict[str, Any]]: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews" + response = client.get(url, headers=self.headers) + if response.status_code == 404: + return [] + response.raise_for_status() + return response.json() + + def dismiss_review_pr( + self, owner: str, repo: str, pr_number: int, review_id: int, message: str + ) -> dict[str, Any]: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews/{review_id}/dismissals" + data: dict[str, str] = {"message": message} + response = client.post(url, headers=self.headers, json=data) + response.raise_for_status() + return response.json() + + def assign_issue(self, owner: str, repo: str, issue_number: int, username: str) -> IssueModel: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}" + data: dict[str, list[str]] = {"assignees": [username]} + response = client.patch(url, headers=self.headers, json=data) + response.raise_for_status() + return IssueModel(**response.json()) + + def create_issue( + self, + owner: str, + repo: str, + title: str, + body: str, + labels: list[str] | None = None, + assignees: list[str] | None = None, + ) -> IssueModel: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues" + data: dict[str, Any] = {"title": title, "body": body} + if labels: + data["labels"] = labels + if assignees: + data["assignees"] = assignees + response = client.post(url, headers=self.headers, json=data) + response.raise_for_status() + return IssueModel(**response.json()) + + def add_comment(self, owner: str, repo: str, issue_number: int, body: str) -> CommentModel: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments" + data: dict[str, str] = {"body": body} + response = client.post(url, headers=self.headers, json=data) + response.raise_for_status() + return CommentModel(**response.json()) + + def add_label(self, owner: str, repo: str, issue_number: int, label: str) -> LabelModel: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/labels" + data: list[str] = [label] + response = client.post(url, headers=self.headers, json=data) + response.raise_for_status() + return LabelModel(**response.json()) + + def add_label_pr(self, owner: str, repo: str, pr_number: int, label: str) -> LabelModel: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{pr_number}/labels" + data: list[str] = [label] + response = client.post(url, headers=self.headers, json=data) + response.raise_for_status() + return LabelModel(**response.json()) + + def update_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/git/ref/{ref}" + data: dict[str, str] = {"sha": sha} + response = client.post(url, headers=self.headers, json=data) + response.raise_for_status() + return response.json() + + def create_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/git/refs" + data: dict[str, str] = {"ref": ref, "sha": sha} + response = client.post(url, headers=self.headers, json=data) + response.raise_for_status() + return response.json() + + def update_file( + self, owner: str, repo: str, path: str, message: str, content: str, branch: str + ) -> dict[str, Any]: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/contents/{path}" + data: dict[str, str] = { + "message": message, + "content": base64.b64encode(content.encode()).decode(), + "branch": branch, + "new_branch": f"{branch}-update-{path}", + } + response = client.put(url, headers=self.headers, json=data) + response.raise_for_status() + return response.json() + + def get_file_content(self, owner: str, repo: str, path: str, ref: str = "master") -> str | list[str]: + with httpx.Client() as client: + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/contents/{path}" + params: dict[str, str] = {"ref": ref} + response = client.get(url, headers=self.headers, params=params) + response.raise_for_status() + data = response.json() + if isinstance(data, list): + return [item.get("content", "") for item in data if item.get("type") == "file"] + return base64.b64decode(data.get("content", "")).decode() if data.get("content") else "" diff --git a/gitea/config.py b/gitea/config.py new file mode 100644 index 0000000..d890c8f --- /dev/null +++ b/gitea/config.py @@ -0,0 +1,34 @@ +"""Configuration for the coding agent.""" + +from dotenv import load_dotenv +from pydantic import Field +from pydantic_settings import BaseSettings + +load_dotenv() + + +class AgentSettings(BaseSettings): + gitea_url: str = "" + gitea_token: str = "" + gitea_repos_root: str = "" + agent_model_id: str = "qwen/qwen3.6-35b-a3b" + agent_max_retries: int = 2 + + +def get_settings() -> AgentSettings: + return AgentSettings() + + +# Module-level singleton instance +_agent_settings: AgentSettings = AgentSettings() + +# Backwards-compatible exports +GITEA_URL: str = _agent_settings.gitea_url +GITEA_TOKEN: str = _agent_settings.gitea_token +GITEA_REPOS_ROOT: str = _agent_settings.gitea_repos_root +AGENT_MODEL_ID: str = _agent_settings.agent_model_id +AGENT_MAX_RETRIES: int = _agent_settings.agent_max_retries + +import os +os.environ["GITEA_SERVER_URL"] = GITEA_URL +os.environ["GITEA_SERVER_TOKEN"] = GITEA_TOKEN diff --git a/gitea/models.py b/gitea/models.py new file mode 100644 index 0000000..c96188b --- /dev/null +++ b/gitea/models.py @@ -0,0 +1,112 @@ +"""Pydantic models for Gitea API entities.""" + +from typing import Optional, Any + +from pydantic import BaseModel, Field, field_validator + + +class UserModel(BaseModel): + login: str = "" + id: int = 0 + avatar_url: Optional[str] = None + html_url: Optional[str] = None + full_name: Optional[str] = None + email: Optional[str] = None + username: Optional[str] = None + + +class LabelModel(BaseModel): + id: int = 0 + name: str = "" + color: Optional[str] = None + description: Optional[str] = None + + +class RepositoryModel(BaseModel): + id: int = 0 + name: str = "" + full_name: str = "" + owner: str = "" + html_url: Optional[str] = None + description: Optional[str] = None + mirror: bool = False + private: bool = False + fork: bool = False + parent: Optional["RepositoryModel"] = None + empty: Optional[bool] = None + + @field_validator("owner", mode="before") + @classmethod + def validate_owner(cls, v): + if isinstance(v, dict): + return v.get("login", "") + return v + + +class IssueModel(BaseModel): + id: int = 0 + number: int = 0 + title: str = "" + body: Optional[str] = None + state: str = "" + user: UserModel = Field(default_factory=UserModel) + assignee: Optional[UserModel] = None + labels: list[LabelModel] = Field(default_factory=list) + created_at: Optional[str] = None + updated_at: Optional[str] = None + closed_at: Optional[str] = None + repository: Optional[RepositoryModel] = None + comments: int = 0 + + +class PullRequestModel(BaseModel): + id: int = 0 + number: int = 0 + title: str = "" + body: Optional[str] = None + state: str = "" + user: UserModel = Field(default_factory=UserModel) + assignee: Optional[UserModel] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None + closed_at: Optional[str] = None + merged_at: Optional[str] = None + head: dict[str, Any] = Field(default_factory=dict) + base: dict[str, Any] = Field(default_factory=dict) + repository: Optional[RepositoryModel] = None + comments: int = 0 + comments_url: Optional[str] = None + diff_url: Optional[str] = None + patch_url: Optional[str] = None + html_url: Optional[str] = None + merged: bool = False + + +class CommentModel(BaseModel): + id: int = 0 + body: str = "" + user: UserModel = Field(default_factory=UserModel) + created_at: Optional[str] = None + updated_at: Optional[str] = None + pull_request_url: Optional[str] = None + + +class PullRequestFileModel(BaseModel): + filename: str = "" + status: str = "" + additions: int = 0 + deletions: int = 0 + changes: int = 0 + blob_url: Optional[str] = None + raw_url: Optional[str] = None + patch: Optional[str] = None + + +class GiteaConfig(BaseModel): + model_config = {"extra": "allow", "populate_by_name": True} + + base_url: str + token: str + repos_root: str + model_id: str = "qwen/qwen3.6-35b-a3b" + max_retries: int = 2 diff --git a/gitea/tools/__init__.py b/gitea/tools/__init__.py new file mode 100644 index 0000000..ecd55aa --- /dev/null +++ b/gitea/tools/__init__.py @@ -0,0 +1 @@ +"""Gitea tools packages.""" diff --git a/gitea/tools/coding_tools.py b/gitea/tools/coding_tools.py new file mode 100644 index 0000000..6382ce6 --- /dev/null +++ b/gitea/tools/coding_tools.py @@ -0,0 +1,208 @@ +"""Tools for a coding agent to interact with the filesystem and environment.""" + +import os +import subprocess +from typing import Any + + +class CodingTools: + """Tools for a coding agent to interact with the filesystem and environment.""" + + def __init__(self, repo_path: str | None = None) -> None: + self.repo_path: str = repo_path or os.getcwd() + + def get_working_directory(self) -> str: + """Get the absolute path of the current local repository workspace directory.""" + return self.repo_path + + def _resolve_path(self, path: str) -> str: + """Resolve a path relative to self.repo_path.""" + if os.path.isabs(path): + return path + return os.path.abspath(os.path.join(self.repo_path, path)) + + def list_files(self, path: str = ".") -> str: + """List all files and directories at the given path (relative to repo root or absolute).""" + resolved: str = self._resolve_path(path) + try: + items: list[str] = os.listdir(resolved) + return "\n".join(items) + except Exception as e: + return f"Error listing files: {str(e)}" + + def read_file(self, path: str, offset: int = 1, limit: int = 250) -> str: + """Read lines from a file, starting at line offset (1-indexed), up to limit lines. Default limit is 250 lines to prevent token bloat. Use 'offset' to scroll/page through larger files.""" + resolved: str = self._resolve_path(path) + try: + with open(resolved, 'r', encoding='utf-8', errors='replace') as f: + lines: list[str] = f.readlines() + + start_line: int = offset - 1 + end_line: int = offset + limit - 1 + + content_lines: list[str] = lines[start_line:end_line] + + if not content_lines: + return "File is empty or offset out of bounds." + + formatted_lines: list[str] = [f"{i + 1}: {line}" for i, line in enumerate(content_lines, start=start_line)] + return "\n".join(formatted_lines) + except Exception as e: + return f"Error reading file: {str(e)}" + + def write_file(self, path: str, content: str) -> str: + """Write content to a file, creating directories as needed.""" + resolved: str = self._resolve_path(path) + try: + os.makedirs(os.path.dirname(os.path.abspath(resolved)), exist_ok=True) + with open(resolved, 'w', encoding='utf-8') as f: + f.write(content) + return f"File {path} written successfully." + except Exception as e: + return f"Error writing file: {str(e)}" + + def edit_file(self, path: str, old_content: str, new_content: str) -> str: + """Replace occurrences of old_content with new_content in the file.""" + resolved: str = self._resolve_path(path) + try: + with open(resolved, 'r', encoding='utf-8', errors='replace') as f: + content: str = f.read() + if old_content not in content: + return f"Error: The specified old content was not found in {path}." + + new_content_full: str = content.replace(old_content, new_content) + with open(resolved, 'w', encoding='utf-8') as f: + f.write(new_content_full) + return f"File {path} edited successfully." + except Exception as e: + return f"Error editing file: {str(e)}" + + def _parse_verification_commands(self) -> list[str]: + agents_md: str = os.path.join(self.repo_path, "AGENTS.md") + if not os.path.exists(agents_md): + return [] + try: + with open(agents_md, "r", encoding="utf-8") as f: + content = f.read() + except Exception: + return [] + + commands: list[str] = [] + in_verification_section = False + in_code_block = False + current_block: list[str] = [] + + for line in content.splitlines(): + line_lower = line.strip().lower() + if line.startswith("#"): + if "verification" in line_lower or "test" in line_lower: + in_verification_section = True + else: + in_verification_section = False + continue + + if in_verification_section: + if line.strip().startswith("```"): + if in_code_block: + in_code_block = False + full_cmd = "\n".join(current_block).strip() + if full_cmd: + for cmd in full_cmd.splitlines(): + if cmd.strip() and not cmd.strip().startswith("#"): + commands.append(cmd.strip()) + current_block = [] + else: + in_code_block = True + elif in_code_block: + current_block.append(line) + + return commands + + def run_verification(self) -> tuple[bool, str]: + commands = self._parse_verification_commands() + if not commands: + return True, "No verification commands found in AGENTS.md." + + log_output = [] + for cmd in commands: + log_output.append(f"Running: {cmd}") + try: + res = subprocess.run( + cmd, shell=True, cwd=self.repo_path, + capture_output=True, text=True, timeout=120 + ) + if res.returncode != 0: + log_output.append( + f"Command '{cmd}' failed with exit code {res.returncode}:\n" + f"Stdout:\n{res.stdout}\n" + f"Stderr:\n{res.stderr}" + ) + return False, "\n".join(log_output) + log_output.append(res.stdout or "Success") + except subprocess.TimeoutExpired: + log_output.append(f"Command '{cmd}' timed out after 120 seconds.") + return False, "\n".join(log_output) + except Exception as e: + log_output.append(f"Failed to execute command '{cmd}': {e}") + return False, "\n".join(log_output) + return True, "\n".join(log_output) + + def run_command(self, command: str, timeout: int = 120) -> str: + """Execute a shell command in the repository workspace and return stdout and stderr output. Args: command, timeout (default 120 seconds).""" + if "tea pr create" in command: + success, log_msg = self.run_verification() + if not success: + return f"Verification failed! You cannot create a pull request because the tests/checks are failing:\n\n{log_msg}\n\nPlease fix the issues and try again." + + try: + process: subprocess.Popen[str] = subprocess.Popen( + command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=self.repo_path + ) + stdout: str + stderr: str + try: + stdout, stderr = process.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + process.kill() + stdout, stderr = process.communicate() + return f"Command timed out after {timeout} seconds.\n--- STDOUT ---\n{stdout}\n--- STDERR ---\n{stderr}" + + output: str = "" + if stdout: + output += f"--- STDOUT ---\n{stdout}" + if stderr: + output += f"\n--- STDERR ---\n{stderr}" + + if process.returncode != 0: + return f"Command failed with exit code {process.returncode}:\n{output}" + + return output if output else "Command executed successfully (no output)." + except Exception as e: + return f"Error running command: {str(e)}" + + def grep_search(self, pattern: str, path: str = ".") -> str: + """Search for pattern in files under path using grep (case-insensitive).""" + resolved: str = self._resolve_path(path) + try: + command: str = f"grep -ri '{pattern}' {resolved}" + process: subprocess.Popen[str] = subprocess.Popen( + command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=self.repo_path + ) + stdout: str + stderr: str + try: + stdout, stderr = process.communicate(timeout=30) + except subprocess.TimeoutExpired: + process.kill() + stdout, stderr = process.communicate() + return f"Grep search timed out after 30 seconds.\nStdout: {stdout}\nStderr: {stderr}" + + if process.returncode != 0 and not stdout: + return f"No matches found for '{pattern}'." + + output: str = stdout + if stderr: + output += f"\nError: {stderr}" + return output + except Exception as e: + return f"Error during grep search: {str(e)}" diff --git a/gitea/tools/file_tools.py b/gitea/tools/file_tools.py new file mode 100644 index 0000000..bea4e3e --- /dev/null +++ b/gitea/tools/file_tools.py @@ -0,0 +1,41 @@ +from typing import Any +from gitea.client import GiteaClient + + +class FileTools: + """Tools for Gitea file/content operations.""" + + def __init__(self, client: GiteaClient) -> None: + self._client = client + + def get_file_content(self, owner: str, repo: str, path: str) -> str: + try: + content = self._client.get_file_content(owner, repo, path) + if isinstance(content, list): + return "\n".join(content) + return content + except Exception as e: + return f"Error getting file content: {str(e)}" + + def get_file_content_with_ref(self, owner: str, repo: str, path: str, ref: str = "master") -> str: + try: + content = self._client.get_file_content(owner, repo, path, ref) + if isinstance(content, list): + return "\n".join(content) + return content + except Exception as e: + return f"Error getting file content: {str(e)}" + + def commit_file(self, owner: str, repo: str, path: str, message: str, content: str, branch: str) -> str: + try: + self._client.update_file(owner, repo, path, message, content, branch) + return f"File '{path}' committed successfully to {owner}/{repo}." + except Exception as e: + return f"Error committing file: {str(e)}" + + def update_file(self, owner: str, repo: str, path: str, message: str, content: str, branch: str) -> str: + try: + self._client.update_file(owner, repo, path, message, content, branch) + return f"File '{path}' updated in {owner}/{repo}." + except Exception as e: + return f"Error updating file: {str(e)}" diff --git a/gitea/tools/git_tools.py b/gitea/tools/git_tools.py new file mode 100644 index 0000000..e1e9478 --- /dev/null +++ b/gitea/tools/git_tools.py @@ -0,0 +1,16 @@ +from typing import Any +from gitea.client import GiteaClient + + +class GitTools: + """Tools for Gitea git ref operations.""" + + def __init__(self, client: GiteaClient) -> None: + self._client = client + + def create_branch(self, owner: str, repo: str, ref: str, sha: str) -> str: + try: + self._client.create_ref(owner, repo, ref, sha) + return f"Branch '{ref}' created successfully in {owner}/{repo}." + except Exception as e: + return f"Error creating branch: {str(e)}" diff --git a/gitea/tools/gitea_tools.py b/gitea/tools/gitea_tools.py new file mode 100644 index 0000000..40197dc --- /dev/null +++ b/gitea/tools/gitea_tools.py @@ -0,0 +1,122 @@ +from gitea.tools.issue_tools import IssueTools +from gitea.tools.pr_tools import PRTools +from gitea.tools.file_tools import FileTools +from gitea.tools.git_tools import GitTools +from gitea.client import GiteaClient + + +class GiteaTools: + """Facade for Gitea tool operations - delegates to focused tool classes.""" + + def __init__(self, client: GiteaClient) -> None: + self._client = client + self.issue_tools = IssueTools(client) + self.pr_tools = PRTools(client) + self.file_tools = FileTools(client) + self.git_tools = GitTools(client) + + # ---- Issue operations (delegated) ---- + + def get_issue(self, owner: str, repo: str, issue_number: int) -> str: + """Get the details of a specific issue. Args: owner (repo owner), repo (repo name), issue_number (issue ID).""" + return self.issue_tools.get_issue(owner, repo, issue_number) + + def get_pull_request(self, owner: str, repo: str, pull_number: int) -> str: + """Get the details of a specific pull request. Args: owner (repo owner), repo (repo name), pull_number (PR ID).""" + return self.pr_tools.get_pull_request(owner, repo, pull_number) + + def close_issue(self, owner: str, repo: str, issue_number: int) -> str: + """Close an issue. Args: owner (repo owner), repo (repo name), issue_number (issue ID).""" + return self.issue_tools.close_issue(owner, repo, issue_number) + + def close_pull_request(self, owner: str, repo: str, pull_number: int) -> str: + """Close a pull request. Args: owner (repo owner), repo (repo name), pull_number (PR ID).""" + return self.pr_tools.close_pull_request(owner, repo, pull_number) + + def get_issue_comments(self, owner: str, repo: str, issue_number: int) -> str: + """Get all comments on an issue. Args: owner (repo owner), repo (repo name), issue_number (issue ID).""" + return self.issue_tools.get_issue_comments(owner, repo, issue_number) + + def get_pull_request_comments(self, owner: str, repo: str, pull_number: int) -> str: + """Get all comments on a pull request. Args: owner (repo owner), repo (repo name), pull_number (PR ID).""" + return self.pr_tools.get_pull_request_comments(owner, repo, pull_number) + + def list_assigned_issues(self) -> list[dict]: + """List all issues assigned to the authenticated user across all repos.""" + return self.issue_tools.list_assigned_issues() + + def list_assigned_pull_requests(self) -> list[dict]: + """List all pull requests assigned to the authenticated user across all repos.""" + return self.pr_tools.list_assigned_pull_requests() + + def list_issues(self, owner: str, repo: str, state: str = "open") -> str: + """List issues in a repository. Args: owner (repo owner), repo (repo name), state (open, closed, or all).""" + return self.issue_tools.list_issues(owner, repo, state) + + def list_pull_requests(self, owner: str, repo: str, state: str = "open") -> str: + """List pull requests in a repository. Args: owner (repo owner), repo (repo name), state (open, closed, or all).""" + return self.pr_tools.list_pull_requests(owner, repo, state) + + def get_file_content(self, owner: str, repo: str, path: str) -> str: + """Get the content of a file from a repository. Args: owner (repo owner), repo (repo name), path (file path).""" + return self.file_tools.get_file_content(owner, repo, path) + + def create_pull_request(self, owner: str, repo: str, head: str, base: str, title: str, description: str = "") -> str: + """Create a new pull request. Args: owner, repo, head (source branch), base (target branch), title, description.""" + return self.pr_tools.create_pull_request(owner, repo, head, base, title, description) + + def create_issue(self, owner: str, repo: str, title: str, body: str, labels: list[str] | None = None, assignees: list[str] | None = None) -> str: + """Create a new issue. Args: owner, repo, title, body, labels (optional), assignees (optional).""" + return self.issue_tools.create_issue(owner, repo, title, body, labels, assignees) + + def create_branch(self, owner: str, repo: str, ref: str, sha: str) -> str: + """Create a new branch in a repository. Args: owner, repo, ref (branch name), sha (commit SHA).""" + return self.git_tools.create_branch(owner, repo, ref, sha) + + def commit_file(self, owner: str, repo: str, path: str, message: str, content: str, branch: str) -> str: + """Commit a file to a repository. Args: owner, repo, path, message, content, branch.""" + return self.file_tools.commit_file(owner, repo, path, message, content, branch) + + def add_label_to_issue(self, owner: str, repo: str, issue_number: int, label: str) -> str: + """Add a label to an issue. Args: owner, repo, issue_number, label.""" + return self.issue_tools.add_label_to_issue(owner, repo, issue_number, label) + + def add_label_to_pr(self, owner: str, repo: str, pr_number: int, label: str) -> str: + """Add a label to a pull request. Args: owner, repo, pr_number, label.""" + return self.pr_tools.add_label_to_pr(owner, repo, pr_number, label) + + def add_comment_to_issue(self, owner: str, repo: str, issue_number: int, body: str) -> str: + """Add a comment to an issue. Args: owner, repo, issue_number, body.""" + return self.issue_tools.add_comment_to_issue(owner, repo, issue_number, body) + + def get_pull_request_diff(self, owner: str, repo: str, pull_number: int) -> str: + """Get the diff of a pull request. Args: owner, repo, pull_number.""" + return self.pr_tools.get_pull_request_diff(owner, repo, pull_number) + + def get_pull_request_patch(self, owner: str, repo: str, pull_number: int) -> str: + """Get the patch of a pull request. Args: owner, repo, pull_number.""" + return self.pr_tools.get_pull_request_patch(owner, repo, pull_number) + + def approve_pull_request(self, owner: str, repo: str, pull_number: int, comment: str) -> str: + """Approve a pull request. Args: owner, repo, pull_number, comment.""" + return self.pr_tools.approve_pull_request(owner, repo, pull_number, comment) + + def request_changes(self, owner: str, repo: str, pull_number: int, comment: str) -> str: + """Request changes on a pull request. Args: owner, repo, pull_number, comment.""" + return self.pr_tools.request_changes(owner, repo, pull_number, comment) + + def add_comment(self, owner: str, repo: str, issue_number: int, body: str) -> str: + """Add a comment to an issue. Args: owner, repo, issue_number, body.""" + return self.issue_tools.add_comment(owner, repo, issue_number, body) + + def add_label(self, owner: str, repo: str, issue_number: int, label: str) -> str: + """Add a label to an issue. Args: owner, repo, issue_number, label.""" + return self.issue_tools.add_label(owner, repo, issue_number, label) + + def get_file_content_with_ref(self, owner: str, repo: str, path: str, ref: str = "master") -> str: + """Get file content with a specific git ref. Args: owner, repo, path, ref (branch/tag).""" + return self.file_tools.get_file_content_with_ref(owner, repo, path, ref) + + def update_file(self, owner: str, repo: str, path: str, message: str, content: str, branch: str) -> str: + """Update a file in a repository. Args: owner, repo, path, message, content, branch.""" + return self.file_tools.update_file(owner, repo, path, message, content, branch) diff --git a/gitea/tools/issue_tools.py b/gitea/tools/issue_tools.py new file mode 100644 index 0000000..6d13b10 --- /dev/null +++ b/gitea/tools/issue_tools.py @@ -0,0 +1,94 @@ +"""Tools for Gitea issue operations.""" + +import json +from typing import Any +from gitea.client import GiteaClient +from gitea.models import IssueModel, CommentModel, LabelModel + + +class IssueTools: + """Tools for Gitea issue operations.""" + + def __init__(self, client: GiteaClient) -> None: + self._client = client + + def get_issue(self, owner: str, repo: str, issue_number: int) -> str: + try: + issue: IssueModel = self._client.get_issue(owner, repo, issue_number) + return issue.model_dump_json(indent=2) + except Exception as e: + return f"Error getting issue: {str(e)}" + + def close_issue(self, owner: str, repo: str, issue_number: int) -> str: + try: + self._client.close_issue(owner, repo, issue_number) + return f"Issue #{issue_number} closed successfully." + except Exception as e: + return f"Error closing issue: {str(e)}" + + def get_issue_comments(self, owner: str, repo: str, issue_number: int) -> str: + try: + comments: list[CommentModel] = self._client.get_issue_comments(owner, repo, issue_number) + return json.dumps([c.model_dump() for c in comments], indent=2) + except Exception as e: + return f"Error getting issue comments: {str(e)}" + + def list_assigned_issues(self) -> list[dict[str, Any]]: + try: + repos = self._client.list_all_user_repos() + all_issues: list[dict[str, Any]] = [] + for repo in repos: + owner = repo.owner + repo_name = repo.name + issues = self._client.list_assigned_issues(owner, repo_name) + if issues: + all_issues.extend([issue.model_dump() if hasattr(issue, 'model_dump') else issue for issue in issues]) + return all_issues + except Exception as e: + print(f"DEBUG: list_assigned_issues error: {e}") + return [] + + def list_issues(self, owner: str, repo: str, state: str = "open") -> str: + try: + issues = self._client.list_repo_issues(owner, repo, state) + if not issues: + return f"No issues in {owner}/{repo}." + summary = [f"#{issue.number}: {issue.title}" for issue in issues] + return "\n".join(summary) + except Exception as e: + return f"Error listing issues: {str(e)}" + + def create_issue(self, owner: str, repo: str, title: str, body: str, labels: list[str] | None = None, assignees: list[str] | None = None) -> str: + try: + issue = self._client.create_issue(owner, repo, title, body, labels, assignees) + return f"Issue #{issue.number} created successfully in {owner}/{repo}." + except Exception as e: + return f"Error creating issue: {str(e)}" + + def add_label_to_issue(self, owner: str, repo: str, issue_number: int, label: str) -> str: + try: + self._client.add_label(owner, repo, issue_number, label) + return f"Label '{label}' added to issue #{issue_number}." + except Exception as e: + return f"Error adding label to issue #{issue_number}: {e}" + + def add_comment_to_issue(self, owner: str, repo: str, issue_number: int, body: str) -> str: + try: + self._client.add_comment(owner, repo, issue_number, body) + return f"Comment added to issue #{issue_number}." + except Exception as e: + return f"Error adding comment to issue #{issue_number}: {e}" + + def add_comment(self, owner: str, repo: str, issue_number: int, body: str) -> str: + try: + comment = self._client.add_comment(owner, repo, issue_number, body) + return f"Comment added to #{issue_number}." + except Exception as e: + return f"Error adding comment: {str(e)}" + + def add_label(self, owner: str, repo: str, issue_number: int, label: str) -> str: + try: + self._client.add_label(owner, repo, issue_number, label) + return f"Label '{label}' added to #{issue_number}." + except Exception as e: + return f"Error adding label: {str(e)}" diff --git a/gitea/tools/pr_tools.py b/gitea/tools/pr_tools.py new file mode 100644 index 0000000..855b430 --- /dev/null +++ b/gitea/tools/pr_tools.py @@ -0,0 +1,99 @@ +"""Tools for Gitea pull request operations.""" + +import json +from typing import Any +from gitea.client import GiteaClient +from gitea.models import PullRequestModel, CommentModel + + +class PRTools: + """Tools for Gitea pull request operations.""" + + def __init__(self, client: GiteaClient) -> None: + self._client = client + + def get_pull_request(self, owner: str, repo: str, pull_number: int) -> str: + try: + pr: PullRequestModel = self._client.get_pull_request(owner, repo, pull_number) + return pr.model_dump_json(indent=2) + except Exception as e: + return f"Error getting pull request: {str(e)}" + + def close_pull_request(self, owner: str, repo: str, pull_number: int) -> str: + try: + self._client.close_pull_request(owner, repo, pull_number) + return f"Pull request #{pull_number} closed successfully." + except Exception as e: + return f"Error closing pull request: {str(e)}" + + def get_pull_request_comments(self, owner: str, repo: str, pull_number: int) -> str: + try: + comments: list[CommentModel] = self._client.get_pull_request_comments(owner, repo, pull_number) + return json.dumps([c.model_dump() for c in comments], indent=2) + except Exception as e: + return f"Error getting PR comments: {str(e)}" + + def list_assigned_pull_requests(self) -> list[dict[str, Any]]: + try: + repos = self._client.list_all_user_repos() + all_prs: list[dict[str, Any]] = [] + for repo_info in repos: + repo_owner = repo_info.owner + repo_name = repo_info.name + prs = self._client.list_assigned_pull_requests(repo_owner, repo_name) + if prs: + all_prs.extend([pr.model_dump() if hasattr(pr, 'model_dump') else pr for pr in prs]) + return all_prs + except Exception as e: + print(f"DEBUG: list_assigned_pull_requests error: {e}") + return [] + + def list_pull_requests(self, owner: str, repo: str, state: str = "open") -> str: + try: + prs = self._client.list_repo_pull_requests(owner, repo, state) + if not prs: + return f"No PRs in {owner}/{repo}." + summary = [f"#{pr.number}: {pr.title}" for pr in prs] + return "\n".join(summary) + except Exception as e: + return f"Error listing PRs: {str(e)}" + + def create_pull_request(self, owner: str, repo: str, head: str, base: str, title: str, description: str = "") -> str: + try: + pr = self._client.create_pr_via_tea(owner, repo, title, description, head, base) + return pr.model_dump_json(indent=2) + except Exception as e: + return f"Error creating PR: {str(e)}" + + def add_label_to_pr(self, owner: str, repo: str, pr_number: int, label: str) -> str: + try: + self._client.add_label_pr(owner, repo, pr_number, label) + return f"Label '{label}' added to PR #{pr_number}." + except Exception as e: + return f"Error adding label to PR #{pr_number}: {e}" + + def get_pull_request_diff(self, owner: str, repo: str, pull_number: int) -> str: + try: + return self._client.get_pull_request_diff(owner, repo, pull_number) + except Exception as e: + return f"Error getting PR diff: {str(e)}" + + def get_pull_request_patch(self, owner: str, repo: str, pull_number: int) -> str: + try: + return self._client.get_pull_request_patch(owner, repo, pull_number) + except Exception as e: + return f"Error getting PR patch: {str(e)}" + + def approve_pull_request(self, owner: str, repo: str, pull_number: int, comment: str) -> str: + try: + self._client.approve_pr(owner, repo, pull_number, comment) + return f"Approved PR #{pull_number}." + except Exception as e: + return f"Error approving PR: {str(e)}" + + def request_changes(self, owner: str, repo: str, pull_number: int, comment: str) -> str: + try: + self._client.request_changes_pr(owner, repo, pull_number, comment) + return f"Requested changes on PR #{pull_number}." + except Exception as e: + return f"Error requesting changes: {str(e)}" diff --git a/gitea/workspace.py b/gitea/workspace.py new file mode 100644 index 0000000..79c6c41 --- /dev/null +++ b/gitea/workspace.py @@ -0,0 +1,132 @@ +import os +import subprocess +from pathlib import Path +from urllib.parse import urlparse +from .config import GITEA_REPOS_ROOT, GITEA_URL, GITEA_TOKEN + + +class WorkspaceManager: + """Manages local workspace for Gitea repositories.""" + + def __init__(self) -> None: + self.root_dir: Path = Path(GITEA_REPOS_ROOT).expanduser().resolve() + self.root_dir.mkdir(parents=True, exist_ok=True) + self._configure_git_credentials() + + def _configure_git_credentials(self) -> None: + try: + # Unset any global configs we might have set previously + subprocess.run( + ["git", "config", "--global", "--unset", "credential.helper"], + capture_output=True + ) + subprocess.run( + ["git", "config", "--global", "--unset", "user.name"], + capture_output=True + ) + subprocess.run( + ["git", "config", "--global", "--unset", "user.email"], + capture_output=True + ) + except Exception as e: + print(f"Error unsetting global configs: {e}") + + def _configure_repo_user(self, repo_path: Path) -> None: + try: + # Configure credential helper locally for the repo + subprocess.run( + ["git", "-C", str(repo_path), "config", "credential.helper", "store"], + check=True, capture_output=True + ) + # Write to ~/.git-credentials + parsed = urlparse(GITEA_URL.rstrip("/")) + cred_line = f"{parsed.scheme}://meeks-ai:{GITEA_TOKEN}@{parsed.netloc}\n" + cred_file = Path("~/.git-credentials").expanduser() + if cred_file.exists(): + content = cred_file.read_text() + if cred_line.strip() not in content: + cred_file.write_text(content + cred_line) + else: + cred_file.write_text(cred_line) + + from gitea.client import GiteaClient + client = GiteaClient() + user = client.get_authenticated_user() + if user: + name = user.full_name or user.login or "meeks-ai" + email = user.email or "micke_ingvarsson+ai@hotmail.com" + subprocess.run( + ["git", "-C", str(repo_path), "config", "user.name", name], + check=True, capture_output=True + ) + subprocess.run( + ["git", "-C", str(repo_path), "config", "user.email", email], + check=True, capture_output=True + ) + except Exception as e: + print(f"Error configuring local git user: {e}") + + def get_repo_path(self, repo_full_name: str) -> Path: + parts: list[str] = repo_full_name.split("/") + return self.root_dir / parts[0] / parts[1] + + def _get_authenticated_url(self, repo_full_name: str) -> str: + parsed = urlparse(GITEA_URL.rstrip("/")) + path = parsed.path.rstrip("/") + return f"{parsed.scheme}://{parsed.netloc}{path}/{repo_full_name}.git" + + def sanitize_repo(self, repo_full_name: str, repo_path: Path) -> None: + try: + auth_url = self._get_authenticated_url(repo_full_name) + subprocess.run( + ["git", "-C", str(repo_path), "remote", "set-url", "origin", auth_url], + check=True, capture_output=True, + ) + self._configure_repo_user(repo_path) + subprocess.run( + ["git", "-C", str(repo_path), "reset", "--hard", "HEAD"], + check=True, capture_output=True, + ) + subprocess.run( + ["git", "-C", str(repo_path), "clean", "-fdx"], + check=True, capture_output=True, + ) + try: + subprocess.run( + ["git", "-C", str(repo_path), "checkout", "main"], + check=True, capture_output=True, + ) + except subprocess.CalledProcessError: + subprocess.run( + ["git", "-C", str(repo_path), "checkout", "master"], + check=True, capture_output=True, + ) + try: + subprocess.run( + ["git", "-C", str(repo_path), "pull", "origin", "main"], + check=True, capture_output=True, + ) + except subprocess.CalledProcessError: + subprocess.run( + ["git", "-C", str(repo_path), "pull", "origin", "master"], + check=True, capture_output=True, + ) + except Exception as e: + print(f"Error during sanitization: {e}") + + def clone_repo(self, repo_full_name: str, clone_url: str | None = None) -> Path: + repo_path: Path = self.get_repo_path(repo_full_name) + if repo_path.exists(): + if not (repo_path / ".git").exists(): + new_path: Path = repo_path.parent / f"{repo_full_name.replace('/', '_')}_old" + if new_path.exists(): + import shutil + shutil.rmtree(new_path) + repo_path.rename(new_path) + return repo_path + + print(f"Cloning repository {repo_full_name} to {repo_path}...") + auth_url = self._get_authenticated_url(repo_full_name) + subprocess.run(["git", "clone", auth_url, str(repo_path)], check=True, capture_output=True) + self._configure_repo_user(repo_path) + return repo_path diff --git a/main.py b/main.py new file mode 100644 index 0000000..852f7fd --- /dev/null +++ b/main.py @@ -0,0 +1,110 @@ +import asyncio +import os +import time +import logging +from pathlib import Path +from dotenv import load_dotenv + +from gitea.client import GiteaClient +from gitea.tools.gitea_tools import GiteaTools +from gitea.config import AGENT_MODEL_ID, AGENT_MAX_RETRIES +from core.orchestrator import AgentOrchestrator + +import json + +# Setup logging to logs folder +LOG_DIR: Path = Path(__file__).parent.parent.parent / "logs" +LOG_DIR.mkdir(exist_ok=True) +LOG_FILE: Path = LOG_DIR / "agent.log" + + +class JSONFormatter(logging.Formatter): + """Formats log records as JSON objects for structured logging.""" + + def format(self, record: logging.LogRecord) -> str: + log_data = { + "timestamp": self.formatTime(record, self.datefmt), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + if record.exc_info: + log_data["exception"] = self.formatException(record.exc_info) + return json.dumps(log_data) + + +file_handler = logging.FileHandler(LOG_FILE) +file_handler.setFormatter(JSONFormatter()) + +stream_handler = logging.StreamHandler() +stream_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) + +logging.basicConfig( + level=logging.INFO, + handlers=[file_handler, stream_handler] +) +logger: logging.Logger = logging.getLogger("coding-agent") + + +async def main() -> None: + load_dotenv() + + # Ensure we're in the repo directory + repo_root: Path = Path(__file__).parent.parent.parent + if Path.cwd() != repo_root: + os.chdir(repo_root) + logger.info(f"Changed directory to {repo_root}") + + # Initialize Gitea components + client: GiteaClient = GiteaClient() + tools: GiteaTools = GiteaTools(client) + model_name: str = AGENT_MODEL_ID + + # Initialize orchestrator + orchestrator: AgentOrchestrator = AgentOrchestrator(client, tools, model_name, AGENT_MAX_RETRIES) + + logger.info("--- Autonomous Coding Agent Active ---") + logger.info(f"Model: {model_name}") + logger.info(f"Max retries: {AGENT_MAX_RETRIES}") + logger.info(f"Checking Gitea at {client.base_url}") + logger.info("Press Ctrl+C to stop.") + + consecutive_errors: int = 0 + max_consecutive_errors: int = 5 + + while True: + try: + logger.info("=== Checking for pending tasks ===") + + # Use orchestrator to poll and dispatch + await orchestrator.poll_and_dispatch() + + # Reset error counter on successful run + consecutive_errors = 0 + + logger.info("=== Waiting 60 seconds before next check ===") + await asyncio.sleep(60) + + except KeyboardInterrupt: + logger.info("Agent stopped.") + break + except Exception as e: + consecutive_errors += 1 + logger.error(f"Error in main loop (attempt {consecutive_errors}/{max_consecutive_errors}): {e}") + + # If too many consecutive errors, wait longer + if consecutive_errors >= max_consecutive_errors: + logger.error(f"Too many consecutive errors ({max_consecutive_errors}). Waiting 5 minutes before retry.") + await asyncio.sleep(300) + consecutive_errors = 0 + else: + await asyncio.sleep(60) + + +if __name__ == "__main__": + asyncio.run(main()) + + +def start_agent() -> None: + """Entry point for uv run.""" + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..308fc26 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "gitea-agent" +version = "0.1.0" +description = "An AI agent that automates Gitea tasks." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "httpx>=0.28.1", + "python-dotenv>=1.0.0", + "openai>=1.0.0", + "pydantic>=2.13.4", + "pydantic-settings>=2.0.0", + "lmstudio>=1.5.0", +] + +[project.scripts] +start-agent = "main:start_agent" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +py-modules = ["main"] + +[tool.setuptools.packages.find] +include = ["core", "gitea"] + +[dependency-groups] +dev = [ + "pytest>=9.1.1", + "ty>=0.0.55", +] + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..14837d5 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests package. diff --git a/tests/test_best_practices.py b/tests/test_best_practices.py new file mode 100644 index 0000000..5e617a2 --- /dev/null +++ b/tests/test_best_practices.py @@ -0,0 +1,142 @@ +import os +import json +import logging +import pytest +from unittest.mock import MagicMock, AsyncMock, patch +from pathlib import Path +from main import JSONFormatter +from gitea.tools.coding_tools import CodingTools +from core.dispatcher import AgentDispatcher +from core.queue import WorkItem +from gitea.client import GiteaClient +from gitea.tools.gitea_tools import GiteaTools +from gitea.models import IssueModel + +pytestmark = pytest.mark.anyio + + +def test_json_formatter() -> None: + formatter = JSONFormatter() + record = logging.LogRecord( + name="test-logger", + level=logging.INFO, + pathname="test.py", + lineno=10, + msg="Test log message", + args=(), + exc_info=None + ) + formatted = formatter.format(record) + data = json.loads(formatted) + assert data["level"] == "INFO" + assert data["logger"] == "test-logger" + assert data["message"] == "Test log message" + assert "timestamp" in data + + +def test_read_file_limit(tmp_path: Path) -> None: + # Create a large file + large_file = tmp_path / "large_file.txt" + lines = [f"Line {i}\n" for i in range(1, 400)] + large_file.write_text("".join(lines), encoding="utf-8") + + coding_tools = CodingTools(str(tmp_path)) + # Test read with default limit (250) + result = coding_tools.read_file("large_file.txt", offset=1) + result_lines = [line.strip() for line in result.splitlines() if line.strip()] + assert len(result_lines) == 250 + assert result_lines[0] == "1: Line 1" + assert result_lines[-1] == "250: Line 250" + + # Test read with custom limit + result_custom = coding_tools.read_file("large_file.txt", offset=10, limit=10) + result_custom_lines = [line.strip() for line in result_custom.splitlines() if line.strip()] + assert len(result_custom_lines) == 10 + assert result_custom_lines[0] == "10: Line 10" + assert result_custom_lines[-1] == "19: Line 19" + + +def test_parse_verification_commands(tmp_path: Path) -> None: + agents_md = tmp_path / "AGENTS.md" + agents_md.write_text( + "# Agent Instructions\n\n" + "## Verification\n" + "Please verify your changes with:\n" + "```bash\n" + "pytest -v\n" + "npm run lint\n" + "```\n\n" + "## Something Else\n" + "```bash\n" + "ignored command\n" + "```\n", + encoding="utf-8" + ) + + coding_tools = CodingTools(str(tmp_path)) + commands = coding_tools._parse_verification_commands() + assert commands == ["pytest -v", "npm run lint"] + + +def test_run_verification_failure(tmp_path: Path) -> None: + agents_md = tmp_path / "AGENTS.md" + agents_md.write_text( + "## Verification\n" + "```bash\n" + "false\n" + "```\n", + encoding="utf-8" + ) + coding_tools = CodingTools(str(tmp_path)) + success, log_msg = coding_tools.run_verification() + assert not success + assert "false" in log_msg + + +@patch("core.dispatcher.CodingAgent") +async def test_dispatch_planning_and_coding_phases(mock_agent_class: MagicMock) -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_tools: MagicMock = MagicMock(spec=GiteaTools) + + # Mock no existing PRs + mock_client.list_repo_pull_requests.return_value = [] + mock_client.get_issue_comments.return_value = [] + + # Mock CodingAgent instances + mock_planning_agent = MagicMock() + mock_planning_agent.run_with_tools = AsyncMock(return_value="Plan: Modify file A") + + mock_coding_agent = MagicMock() + mock_coding_agent.run_with_tools = AsyncMock(return_value="PR #1 Created") + + mock_agent_class.side_effect = [mock_planning_agent, mock_coding_agent] + + dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) + + work_item = WorkItem( + repo_full_name="meeks/repo1", + task_type="issue", + task_number=42, + task_info=IssueModel(number=42, title="fix bug", body="bug details"), + priority=0 + ) + + # We mock os.path.isdir to return True so os.chdir won't fail or crash in test + with patch("os.path.isdir", return_value=True), patch("os.chdir"): + results = await dispatcher.dispatch("meeks/repo1", [work_item]) + + assert len(results) == 1 + assert results[0] == "PR #1 Created" + + # Verify both agents ran + assert mock_planning_agent.run_with_tools.call_count == 1 + assert mock_coding_agent.run_with_tools.call_count == 1 + + # Verify planning prompt was passed correct context + planning_call_args = mock_planning_agent.run_with_tools.call_args[0] + assert "PHASE 1: PLANNING PHASE" in planning_call_args[0] + + # Verify coding prompt received the generated plan + coding_call_args = mock_coding_agent.run_with_tools.call_args[0] + assert "PHASE 2: EXECUTION/CODING PHASE" in coding_call_args[0] + assert "Plan: Modify file A" in coding_call_args[0] diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..c05d2bf --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,95 @@ +from unittest.mock import MagicMock, patch +from gitea.client import GiteaClient + + +def test_gitea_client_list_repo_issues() -> None: + client: GiteaClient = GiteaClient() + with patch("httpx.Client.get") as mock_get: + mock_response: MagicMock = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [] + mock_get.return_value = mock_response + + # Test default parameter ("open") + client.list_repo_issues("owner", "repo") + mock_get.assert_called_once() + args, _ = mock_get.call_args + assert "type=issues" in args[0] + assert "state=open" in args[0] + + mock_get.reset_mock() + + # Test custom parameter ("closed") + client.list_repo_issues("owner", "repo", state="closed") + mock_get.assert_called_once() + args, _ = mock_get.call_args + assert "type=issues" in args[0] + assert "state=closed" in args[0] + + +def test_gitea_client_list_repo_pull_requests() -> None: + client: GiteaClient = GiteaClient() + with patch("httpx.Client.get") as mock_get: + mock_response: MagicMock = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [] + mock_get.return_value = mock_response + + # Test default parameter ("open") + client.list_repo_pull_requests("owner", "repo") + mock_get.assert_called_once() + args, _ = mock_get.call_args + assert "state=open" in args[0] + + mock_get.reset_mock() + + # Test custom parameter ("closed") + client.list_repo_pull_requests("owner", "repo", state="closed") + mock_get.assert_called_once() + args, _ = mock_get.call_args + assert "state=closed" in args[0] + + +def test_gitea_client_list_assigned_issues() -> None: + client: GiteaClient = GiteaClient() + user_mock: MagicMock = MagicMock() + user_mock.login = "testuser" + + with patch.object(client, "get_authenticated_user", return_value=user_mock), \ + patch("httpx.get") as mock_get: + mock_response: MagicMock = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [] + mock_get.return_value = mock_response + + client.list_assigned_issues("owner", "repo") + mock_get.assert_called_once() + args, _ = mock_get.call_args + assert "type=issues" in args[0] + assert "state=open" in args[0] + + +def test_gitea_client_list_assigned_pull_requests() -> None: + client: GiteaClient = GiteaClient() + user_mock: MagicMock = MagicMock() + user_mock.login = "testuser" + + with patch.object(client, "get_authenticated_user", return_value=user_mock), \ + patch("httpx.get") as mock_get: + mock_response: MagicMock = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + {"number": 1, "title": "PR 1", "assignee": {"login": "testuser"}, "user": {"login": "otheruser"}}, + {"number": 2, "title": "PR 2", "assignee": None, "user": {"login": "testuser"}}, + {"number": 3, "title": "PR 3", "assignee": {"login": "otheruser"}, "user": {"login": "otheruser"}} + ] + mock_get.return_value = mock_response + + res = client.list_assigned_pull_requests("owner", "repo") + mock_get.assert_called_once() + assert len(res) == 2 + numbers = [pr.number for pr in res] + assert 1 in numbers + assert 2 in numbers + assert 3 not in numbers + diff --git a/tests/test_coding_tools.py b/tests/test_coding_tools.py new file mode 100644 index 0000000..cab07fc --- /dev/null +++ b/tests/test_coding_tools.py @@ -0,0 +1,147 @@ +import os +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, patch +import pytest +from gitea.tools.coding_tools import CodingTools + + +def test_list_files(tmp_path: Path) -> None: + d: Path = tmp_path / "sub" + d.mkdir() + f: Path = d / "hello.txt" + f.write_text("content") + + res: str = CodingTools().list_files(str(d)) + assert "hello.txt" in res + + +def test_list_files_error() -> None: + res: str = CodingTools().list_files("/nonexistent/directory/path/here") + assert "Error listing files" in res + + +def test_read_file(tmp_path: Path) -> None: + f: Path = tmp_path / "test.txt" + f.write_text("line1\nline2\nline3\n") + + res: str = CodingTools().read_file(str(f), offset=1, limit=2) + assert "1: line1" in res + assert "2: line2" in res + assert "3: line3" not in res + + +def test_read_file_empty_or_out_of_bounds(tmp_path: Path) -> None: + f: Path = tmp_path / "test.txt" + f.write_text("") + res: str = CodingTools().read_file(str(f), offset=10, limit=2) + assert res == "File is empty or offset out of bounds." + + +def test_read_file_error() -> None: + res: str = CodingTools().read_file("/nonexistent/file/path/here") + assert "Error reading file" in res + + +def test_write_file(tmp_path: Path) -> None: + f: Path = tmp_path / "new_dir" / "test.txt" + res: str = CodingTools().write_file(str(f), "content") + assert "written successfully" in res + assert f.read_text() == "content" + + +def test_write_file_error() -> None: + res: str = CodingTools().write_file("", "content") + assert "Error writing file" in res + + +def test_edit_file(tmp_path: Path) -> None: + f: Path = tmp_path / "test.txt" + f.write_text("hello world") + res: str = CodingTools().edit_file(str(f), "world", "there") + assert "edited successfully" in res + assert f.read_text() == "hello there" + + +def test_edit_file_not_found(tmp_path: Path) -> None: + f: Path = tmp_path / "test.txt" + f.write_text("hello world") + res: str = CodingTools().edit_file(str(f), "nonexistent", "there") + assert "not found" in res + + +def test_edit_file_error() -> None: + res: str = CodingTools().edit_file("/nonexistent/file/path/here", "world", "there") + assert "Error editing file" in res + + +@patch("subprocess.Popen") +def test_run_command_success(mock_popen: MagicMock) -> None: + mock_process: MagicMock = MagicMock() + mock_process.communicate.return_value = ("output_stdout", "output_stderr") + mock_process.returncode = 0 + mock_popen.return_value = mock_process + + res: str = CodingTools().run_command("echo hello") + assert "output_stdout" in res + assert "output_stderr" in res + + +@patch("subprocess.Popen") +def test_run_command_failure(mock_popen: MagicMock) -> None: + mock_process: MagicMock = MagicMock() + mock_process.communicate.return_value = ("output_stdout", "output_stderr") + mock_process.returncode = 1 + mock_popen.return_value = mock_process + + res: str = CodingTools().run_command("false") + assert "Command failed with exit code 1" in res + + +def test_run_command_error() -> None: + with patch("subprocess.Popen", side_effect=ValueError("Invalid run")): + res: str = CodingTools().run_command("echo") + assert "Error running command" in res + + +@patch("subprocess.Popen") +def test_run_command_timeout(mock_popen: MagicMock) -> None: + mock_process: MagicMock = MagicMock() + mock_process.communicate.side_effect = [ + subprocess.TimeoutExpired(cmd="test", timeout=1), + ("stdout_after_kill", "stderr_after_kill") + ] + mock_popen.return_value = mock_process + + res: str = CodingTools().run_command("hang_cmd", timeout=1) + assert "Command timed out after 1 seconds" in res + assert "stdout_after_kill" in res + mock_process.kill.assert_called_once() + + +@patch("subprocess.Popen") +def test_grep_search_success(mock_popen: MagicMock) -> None: + mock_process: MagicMock = MagicMock() + mock_process.communicate.return_value = ("match_line", "") + mock_process.returncode = 0 + mock_popen.return_value = mock_process + + res: str = CodingTools().grep_search("pattern", "/path") + assert res == "match_line" + + +@patch("subprocess.Popen") +def test_grep_search_no_matches(mock_popen: MagicMock) -> None: + mock_process: MagicMock = MagicMock() + mock_process.communicate.return_value = ("", "") + mock_process.returncode = 1 + mock_popen.return_value = mock_process + + res: str = CodingTools().grep_search("pattern", "/path") + assert "No matches found" in res + + +def test_grep_search_error() -> None: + with patch("subprocess.Popen", side_effect=ValueError("Invalid run")): + res: str = CodingTools().grep_search("pattern") + assert "Error during grep search" in res diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py new file mode 100644 index 0000000..3938b9f --- /dev/null +++ b/tests/test_dispatcher.py @@ -0,0 +1,250 @@ +import pytest +from unittest.mock import MagicMock, AsyncMock, patch +from core.dispatcher import AgentDispatcher +from core.queue import WorkItem +from gitea.client import GiteaClient +from gitea.tools.gitea_tools import GiteaTools +from gitea.models import PullRequestModel, IssueModel, CommentModel + +pytestmark = pytest.mark.anyio + + +async def test_dispatch_skips_issue_with_existing_pr() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_tools: MagicMock = MagicMock(spec=GiteaTools) + + # Mock list_repo_pull_requests to return a PR that closes issue #42 + pr = PullRequestModel( + number=101, + title="fix: resolve bug", + body="closes #42" + ) + mock_client.list_repo_pull_requests.return_value = [pr] + + dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) + + work_item = WorkItem( + repo_full_name="meeks/repo1", + task_type="issue", + task_number=42, + task_info=IssueModel(number=42), + priority=0 + ) + + results = await dispatcher.dispatch("meeks/repo1", [work_item]) + + assert len(results) == 1 + assert "SKIP: A pull request (PR #101) addressing issue #42 already exists" in results[0] + mock_client.list_repo_pull_requests.assert_called_once_with("meeks", "repo1") + + +@patch("core.dispatcher.CodingAgent") +async def test_dispatch_processes_issue_without_pr(mock_agent_class: MagicMock) -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_tools: MagicMock = MagicMock(spec=GiteaTools) + + # Mock list_repo_pull_requests to return PRs that don't address issue #42 + pr = PullRequestModel( + number=101, + title="feat: add something", + body="closes #99" + ) + mock_client.list_repo_pull_requests.return_value = [pr] + mock_client.get_issue_comments.return_value = [] + + # Mock CodingAgent run_with_tools + mock_agent_instance = MagicMock() + mock_agent_instance.run_with_tools = AsyncMock(return_value="Issue resolved.") + mock_agent_class.return_value = mock_agent_instance + + dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) + + work_item = WorkItem( + repo_full_name="meeks/repo1", + task_type="issue", + task_number=42, + task_info=IssueModel(number=42, title="test", body="test desc"), + priority=0 + ) + + results = await dispatcher.dispatch("meeks/repo1", [work_item]) + + assert len(results) == 1 + assert results[0] == "Issue resolved." + + +@patch("core.dispatcher.CodingAgent") +async def test_build_pr_mission_injects_issue_context(mock_agent_class: MagicMock) -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_tools: MagicMock = MagicMock(spec=GiteaTools) + + # Mock get_pull_request, get_pull_request_diff, etc. + pr = PullRequestModel( + number=101, + title="fix: resolve bug", + body="closes #42", + head={"ref": "branch1"}, + base={"ref": "master"} + ) + mock_client.get_pull_request.return_value = pr + mock_client.get_pull_request_diff.return_value = "diff context" + mock_client.get_pull_request_files.return_value = [] + mock_client.get_pull_request_comments.return_value = [] + + # Mock the connected issue and its comments + issue = IssueModel(number=42, title="bug description") + mock_client.get_issue.return_value = issue + + comment = CommentModel(id=1, body="First comment") + mock_client.get_issue_comments.return_value = [comment] + + # Mock CodingAgent + mock_agent_instance = MagicMock() + mock_agent_instance.run_with_tools = AsyncMock(return_value="PR Reviewed.") + mock_agent_class.return_value = mock_agent_instance + + dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) + + work_item = WorkItem( + repo_full_name="meeks/repo1", + task_type="pr", + task_number=101, + task_info=PullRequestModel(number=101, title="fix: resolve bug", body="closes #42"), + priority=0 + ) + + # We will patch the dispatcher._build_pr_mission output validation + mission = dispatcher._build_pr_mission(work_item) + + assert "CONNECTED ISSUE CONTEXT" in mission + assert "Connected Issue #42" in mission + assert "bug description" in mission + assert "First comment" in mission + + mock_client.get_issue.assert_called_once_with("meeks", "repo1", 42) + mock_client.get_issue_comments.assert_called_once_with("meeks", "repo1", 42) + + +async def test_find_pr_for_issue_by_branch() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_tools: MagicMock = MagicMock(spec=GiteaTools) + + dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) + + # 1. Matches fix/issue-42-some-desc + pr1 = PullRequestModel(number=102, head={"ref": "fix/issue-42-some-desc"}) + mock_client.list_repo_pull_requests.return_value = [pr1] + assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None + + # 2. Matches fix/42 + pr2 = PullRequestModel(number=102, head={"ref": "fix/42"}) + mock_client.list_repo_pull_requests.return_value = [pr2] + assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None + + # 3. Matches fix-42_desc + pr3 = PullRequestModel(number=102, head={"ref": "fix-42_desc"}) + mock_client.list_repo_pull_requests.return_value = [pr3] + assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is not None + + # 4. Does NOT match fix/142 + pr4 = PullRequestModel(number=102, head={"ref": "fix/142"}) + mock_client.list_repo_pull_requests.return_value = [pr4] + assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is None + + # 5. Does NOT match fix/421 + pr5 = PullRequestModel(number=102, head={"ref": "fix/421"}) + mock_client.list_repo_pull_requests.return_value = [pr5] + assert dispatcher._find_pr_for_issue("meeks/repo1", 42) is None + + + +async def test_find_pr_for_issue_by_raw_mention() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_tools: MagicMock = MagicMock(spec=GiteaTools) + + # PR body mentions #42 + pr = PullRequestModel( + number=103, + title="some fix", + body="This is for #42 to fix the bug", + head={"ref": "some-branch"} + ) + mock_client.list_repo_pull_requests.return_value = [pr] + + dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) + res = dispatcher._find_pr_for_issue("meeks/repo1", 42) + assert res is not None + assert res.number == 103 + + +async def test_dispatch_skips_already_reviewed_pr() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_tools: MagicMock = MagicMock(spec=GiteaTools) + + pr = PullRequestModel( + number=104, + title="already reviewed PR", + body="closes #42" + ) + mock_client.get_pull_request.return_value = pr + mock_client.get_pull_request_diff.return_value = "diff" + mock_client.get_pull_request_comments.return_value = [ + CommentModel(id=1, body="Reviewed by AI Agent: Looks good.") + ] + + dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) + + work_item = WorkItem( + repo_full_name="meeks/repo1", + task_type="pr", + task_number=104, + task_info=PullRequestModel(number=104, title="already reviewed PR", body="closes #42"), + priority=0 + ) + + results = await dispatcher.dispatch("meeks/repo1", [work_item]) + assert len(results) == 1 + assert "SKIP: PR #104 has already been addressed by AI" in results[0] + + +# ── _is_awaiting_reply tests ───────────────────────────────────────────────── + +def _make_comment(login: str, body: str) -> CommentModel: + from gitea.models import UserModel + user = UserModel(login=login) + return CommentModel(id=1, body=body, user=user) + + +def test_is_awaiting_reply_no_comments() -> None: + dispatcher = AgentDispatcher(client=MagicMock(), tools=MagicMock()) + assert dispatcher._is_awaiting_reply([]) is False + + +def test_is_awaiting_reply_no_question() -> None: + dispatcher = AgentDispatcher(client=MagicMock(), tools=MagicMock()) + comments = [_make_comment("meeks-ai", "I will fix this now.")] + assert dispatcher._is_awaiting_reply(comments) is False + + +def test_is_awaiting_reply_agent_question_no_human_reply() -> None: + dispatcher = AgentDispatcher(client=MagicMock(), tools=MagicMock()) + body = "Should I use approach A or B?\n" + comments = [_make_comment("meeks-ai", body)] + assert dispatcher._is_awaiting_reply(comments) is True + + +def test_is_awaiting_reply_agent_question_human_replied() -> None: + dispatcher = AgentDispatcher(client=MagicMock(), tools=MagicMock()) + body = "Should I use approach A or B?\n" + comments = [ + _make_comment("meeks-ai", body), + _make_comment("michael", "Use approach A please."), + ] + assert dispatcher._is_awaiting_reply(comments) is False + + +def test_is_awaiting_reply_no_marker_not_detected() -> None: + """Agent asked a question but forgot the marker — should NOT be skipped.""" + dispatcher = AgentDispatcher(client=MagicMock(), tools=MagicMock()) + comments = [_make_comment("meeks-ai", "Should I use approach A or B?")] + assert dispatcher._is_awaiting_reply(comments) is False diff --git a/tests/test_file_tools.py b/tests/test_file_tools.py new file mode 100644 index 0000000..0b0cdf3 --- /dev/null +++ b/tests/test_file_tools.py @@ -0,0 +1,97 @@ +from unittest.mock import MagicMock +from gitea.client import GiteaClient +from gitea.tools.file_tools import FileTools + + +def test_get_file_content_string_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.get_file_content.return_value = "file content here" + + file_tools: FileTools = FileTools(mock_client) + res: str = file_tools.get_file_content("owner", "repo", "path/to/file") + assert res == "file content here" + mock_client.get_file_content.assert_called_once_with("owner", "repo", "path/to/file") + + +def test_get_file_content_list_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.get_file_content.return_value = ["line1", "line2"] + + file_tools: FileTools = FileTools(mock_client) + res: str = file_tools.get_file_content("owner", "repo", "path/to/file") + assert res == "line1\nline2" + + +def test_get_file_content_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.get_file_content.side_effect = Exception("API Error") + + file_tools: FileTools = FileTools(mock_client) + res: str = file_tools.get_file_content("owner", "repo", "path/to/file") + assert "Error getting file content: API Error" in res + + +def test_get_file_content_with_ref_string_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.get_file_content.return_value = "file content here" + + file_tools: FileTools = FileTools(mock_client) + res: str = file_tools.get_file_content_with_ref("owner", "repo", "path/to/file", "main") + assert res == "file content here" + mock_client.get_file_content.assert_called_once_with("owner", "repo", "path/to/file", "main") + + +def test_get_file_content_with_ref_list_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.get_file_content.return_value = ["line1", "line2"] + + file_tools: FileTools = FileTools(mock_client) + res: str = file_tools.get_file_content_with_ref("owner", "repo", "path/to/file", "main") + assert res == "line1\nline2" + + +def test_get_file_content_with_ref_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.get_file_content.side_effect = Exception("API Error") + + file_tools: FileTools = FileTools(mock_client) + res: str = file_tools.get_file_content_with_ref("owner", "repo", "path/to/file", "main") + assert "Error getting file content: API Error" in res + + +def test_commit_file_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.update_file.return_value = {} + + file_tools: FileTools = FileTools(mock_client) + res: str = file_tools.commit_file("owner", "repo", "path/to/file", "msg", "content", "branch") + assert "committed successfully" in res + mock_client.update_file.assert_called_once_with("owner", "repo", "path/to/file", "msg", "content", "branch") + + +def test_commit_file_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.update_file.side_effect = Exception("API Error") + + file_tools: FileTools = FileTools(mock_client) + res: str = file_tools.commit_file("owner", "repo", "path/to/file", "msg", "content", "branch") + assert "Error committing file: API Error" in res + + +def test_update_file_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.update_file.return_value = {} + + file_tools: FileTools = FileTools(mock_client) + res: str = file_tools.update_file("owner", "repo", "path/to/file", "msg", "content", "branch") + assert "updated in" in res + mock_client.update_file.assert_called_once_with("owner", "repo", "path/to/file", "msg", "content", "branch") + + +def test_update_file_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.update_file.side_effect = Exception("API Error") + + file_tools: FileTools = FileTools(mock_client) + res: str = file_tools.update_file("owner", "repo", "path/to/file", "msg", "content", "branch") + assert "Error updating file: API Error" in res diff --git a/tests/test_git_tools.py b/tests/test_git_tools.py new file mode 100644 index 0000000..261293a --- /dev/null +++ b/tests/test_git_tools.py @@ -0,0 +1,22 @@ +from unittest.mock import MagicMock +from gitea.client import GiteaClient +from gitea.tools.git_tools import GitTools + + +def test_create_branch_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.create_ref.return_value = {} + + git_tools: GitTools = GitTools(mock_client) + res: str = git_tools.create_branch("owner", "repo", "ref", "sha") + assert res == "Branch 'ref' created successfully in owner/repo." + mock_client.create_ref.assert_called_once_with("owner", "repo", "ref", "sha") + + +def test_create_branch_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.create_ref.side_effect = Exception("API Error") + + git_tools: GitTools = GitTools(mock_client) + res: str = git_tools.create_branch("owner", "repo", "ref", "sha") + assert res == "Error creating branch: API Error" diff --git a/tests/test_gitea_tools.py b/tests/test_gitea_tools.py new file mode 100644 index 0000000..2a57f79 --- /dev/null +++ b/tests/test_gitea_tools.py @@ -0,0 +1,113 @@ +from unittest.mock import MagicMock +from gitea.client import GiteaClient +from gitea.tools.gitea_tools import GiteaTools + + +def test_gitea_tools_delegation() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + gitea_tools: GiteaTools = GiteaTools(mock_client) + + # 1. get_issue + gitea_tools.get_issue("owner", "repo", 1) + mock_client.get_issue.assert_called_once_with("owner", "repo", 1) + + # 2. get_pull_request + gitea_tools.get_pull_request("owner", "repo", 2) + mock_client.get_pull_request.assert_called_once_with("owner", "repo", 2) + + # 3. close_issue + gitea_tools.close_issue("owner", "repo", 3) + mock_client.close_issue.assert_called_once_with("owner", "repo", 3) + + # 4. close_pull_request + gitea_tools.close_pull_request("owner", "repo", 4) + mock_client.close_pull_request.assert_called_once_with("owner", "repo", 4) + + # 5. get_issue_comments + gitea_tools.get_issue_comments("owner", "repo", 5) + mock_client.get_issue_comments.assert_called_once_with("owner", "repo", 5) + + # 6. get_pull_request_comments + gitea_tools.get_pull_request_comments("owner", "repo", 6) + mock_client.get_pull_request_comments.assert_called_once_with("owner", "repo", 6) + + # 7. list_assigned_issues + mock_client.list_all_user_repos.return_value = [] + gitea_tools.list_assigned_issues() + mock_client.list_all_user_repos.assert_called() + + # 8. list_assigned_pull_requests + gitea_tools.list_assigned_pull_requests() + mock_client.list_all_user_repos.assert_called() + + # 9. list_issues + gitea_tools.list_issues("owner", "repo") + mock_client.list_repo_issues.assert_called_once_with("owner", "repo", "open") + + # 10. list_pull_requests + gitea_tools.list_pull_requests("owner", "repo") + mock_client.list_repo_pull_requests.assert_called_once_with("owner", "repo", "open") + + # 11. get_file_content + gitea_tools.get_file_content("owner", "repo", "path") + mock_client.get_file_content.assert_called_with("owner", "repo", "path") + + # 12. create_pull_request + gitea_tools.create_pull_request("owner", "repo", "head", "base", "title", "desc") + mock_client.create_pr_via_tea.assert_called_once_with("owner", "repo", "title", "desc", "head", "base") + + # 13. create_issue + gitea_tools.create_issue("owner", "repo", "title", "body") + mock_client.create_issue.assert_called_once_with("owner", "repo", "title", "body", None, None) + + # 14. create_branch + gitea_tools.create_branch("owner", "repo", "branch", "sha") + mock_client.create_ref.assert_called_once_with("owner", "repo", "branch", "sha") + + # 15. commit_file + gitea_tools.commit_file("owner", "repo", "path", "msg", "content", "branch") + mock_client.update_file.assert_called_with("owner", "repo", "path", "msg", "content", "branch") + + # 16. add_label_to_issue + gitea_tools.add_label_to_issue("owner", "repo", 1, "bug") + mock_client.add_label.assert_called_with("owner", "repo", 1, "bug") + + # 17. add_label_to_pr + gitea_tools.add_label_to_pr("owner", "repo", 1, "bug") + mock_client.add_label_pr.assert_called_once_with("owner", "repo", 1, "bug") + + # 18. add_comment_to_issue + gitea_tools.add_comment_to_issue("owner", "repo", 1, "body") + mock_client.add_comment.assert_called_with("owner", "repo", 1, "body") + + # 19. get_pull_request_diff + gitea_tools.get_pull_request_diff("owner", "repo", 1) + mock_client.get_pull_request_diff.assert_called_once_with("owner", "repo", 1) + + # 20. get_pull_request_patch + gitea_tools.get_pull_request_patch("owner", "repo", 1) + mock_client.get_pull_request_patch.assert_called_once_with("owner", "repo", 1) + + # 21. approve_pull_request + gitea_tools.approve_pull_request("owner", "repo", 1, "good") + mock_client.approve_pr.assert_called_once_with("owner", "repo", 1, "good") + + # 22. request_changes + gitea_tools.request_changes("owner", "repo", 1, "bad") + mock_client.request_changes_pr.assert_called_once_with("owner", "repo", 1, "bad") + + # 23. add_comment + gitea_tools.add_comment("owner", "repo", 1, "body") + mock_client.add_comment.assert_called_with("owner", "repo", 1, "body") + + # 24. add_label + gitea_tools.add_label("owner", "repo", 1, "bug") + mock_client.add_label.assert_called_with("owner", "repo", 1, "bug") + + # 25. get_file_content_with_ref + gitea_tools.get_file_content_with_ref("owner", "repo", "path", "ref") + mock_client.get_file_content.assert_called_with("owner", "repo", "path", "ref") + + # 26. update_file + gitea_tools.update_file("owner", "repo", "path", "msg", "content", "branch") + mock_client.update_file.assert_called_with("owner", "repo", "path", "msg", "content", "branch") diff --git a/tests/test_issue_tools.py b/tests/test_issue_tools.py new file mode 100644 index 0000000..de6dc3f --- /dev/null +++ b/tests/test_issue_tools.py @@ -0,0 +1,213 @@ +import json +from typing import Any +from unittest.mock import MagicMock +from gitea.client import GiteaClient +from gitea.models import IssueModel, CommentModel, LabelModel, RepositoryModel +from gitea.tools.issue_tools import IssueTools + + +def test_get_issue_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + issue: IssueModel = IssueModel(number=1, title="Test Issue", state="open") + mock_client.get_issue.return_value = issue + + issue_tools: IssueTools = IssueTools(mock_client) + res: str = issue_tools.get_issue("owner", "repo", 1) + + data: dict[str, Any] = json.loads(res) + assert data["number"] == 1 + assert data["title"] == "Test Issue" + mock_client.get_issue.assert_called_once_with("owner", "repo", 1) + + +def test_get_issue_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.get_issue.side_effect = Exception("API Error") + + issue_tools: IssueTools = IssueTools(mock_client) + res: str = issue_tools.get_issue("owner", "repo", 1) + assert "Error getting issue: API Error" in res + + +def test_close_issue_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.close_issue.return_value = IssueModel(number=1, state="closed") + + issue_tools: IssueTools = IssueTools(mock_client) + res: str = issue_tools.close_issue("owner", "repo", 1) + assert res == "Issue #1 closed successfully." + mock_client.close_issue.assert_called_once_with("owner", "repo", 1) + + +def test_close_issue_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.close_issue.side_effect = Exception("API Error") + + issue_tools: IssueTools = IssueTools(mock_client) + res: str = issue_tools.close_issue("owner", "repo", 1) + assert "Error closing issue: API Error" in res + + +def test_get_issue_comments_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + comment: CommentModel = CommentModel(id=123, body="Comment body") + mock_client.get_issue_comments.return_value = [comment] + + issue_tools: IssueTools = IssueTools(mock_client) + res: str = issue_tools.get_issue_comments("owner", "repo", 1) + data: list[dict[str, Any]] = json.loads(res) + assert len(data) == 1 + assert data[0]["body"] == "Comment body" + + +def test_get_issue_comments_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.get_issue_comments.side_effect = Exception("API Error") + + issue_tools: IssueTools = IssueTools(mock_client) + res: str = issue_tools.get_issue_comments("owner", "repo", 1) + assert "Error getting issue comments: API Error" in res + + +def test_list_assigned_issues_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + repo: RepositoryModel = RepositoryModel(name="repo1", owner="owner1") + issue: IssueModel = IssueModel(number=1, title="Test Issue") + mock_client.list_all_user_repos.return_value = [repo] + mock_client.list_assigned_issues.return_value = [issue] + + issue_tools: IssueTools = IssueTools(mock_client) + res: list[dict[str, Any]] = issue_tools.list_assigned_issues() + assert len(res) == 1 + assert res[0]["number"] == 1 + mock_client.list_all_user_repos.assert_called_once() + mock_client.list_assigned_issues.assert_called_once_with("owner1", "repo1") + + +def test_list_assigned_issues_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.list_all_user_repos.side_effect = Exception("API Error") + + issue_tools: IssueTools = IssueTools(mock_client) + res: list[dict[str, Any]] = issue_tools.list_assigned_issues() + assert res == [] + + +def test_list_issues_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + issue: IssueModel = IssueModel(number=1, title="Test Issue") + mock_client.list_repo_issues.return_value = [issue] + + issue_tools: IssueTools = IssueTools(mock_client) + res: str = issue_tools.list_issues("owner", "repo") + assert "#1: Test Issue" in res + + +def test_list_issues_empty() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.list_repo_issues.return_value = [] + + issue_tools: IssueTools = IssueTools(mock_client) + res: str = issue_tools.list_issues("owner", "repo") + assert res == "No issues in owner/repo." + + +def test_list_issues_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.list_repo_issues.side_effect = Exception("API Error") + + issue_tools: IssueTools = IssueTools(mock_client) + res: str = issue_tools.list_issues("owner", "repo") + assert "Error listing issues: API Error" in res + + +def test_create_issue_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + issue: IssueModel = IssueModel(number=2) + mock_client.create_issue.return_value = issue + + issue_tools: IssueTools = IssueTools(mock_client) + res: str = issue_tools.create_issue("owner", "repo", "Title", "Body", ["label1"], ["assignee1"]) + assert res == "Issue #2 created successfully in owner/repo." + mock_client.create_issue.assert_called_once_with("owner", "repo", "Title", "Body", ["label1"], ["assignee1"]) + + +def test_create_issue_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.create_issue.side_effect = Exception("API Error") + + issue_tools: IssueTools = IssueTools(mock_client) + res: str = issue_tools.create_issue("owner", "repo", "Title", "Body") + assert "Error creating issue: API Error" in res + + +def test_add_label_to_issue_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.add_label.return_value = LabelModel(name="bug") + + issue_tools: IssueTools = IssueTools(mock_client) + res: str = issue_tools.add_label_to_issue("owner", "repo", 1, "bug") + assert res == "Label 'bug' added to issue #1." + + +def test_add_label_to_issue_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.add_label.side_effect = Exception("API Error") + + issue_tools: IssueTools = IssueTools(mock_client) + res: str = issue_tools.add_label_to_issue("owner", "repo", 1, "bug") + assert "Error adding label to issue #1: API Error" in res + + +def test_add_comment_to_issue_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.add_comment.return_value = CommentModel(id=1) + + issue_tools: IssueTools = IssueTools(mock_client) + res: str = issue_tools.add_comment_to_issue("owner", "repo", 1, "body") + assert res == "Comment added to issue #1." + + +def test_add_comment_to_issue_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.add_comment.side_effect = Exception("API Error") + + issue_tools: IssueTools = IssueTools(mock_client) + res: str = issue_tools.add_comment_to_issue("owner", "repo", 1, "body") + assert "Error adding comment to issue #1: API Error" in res + + +def test_add_comment_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.add_comment.return_value = CommentModel(id=1) + + issue_tools: IssueTools = IssueTools(mock_client) + res: str = issue_tools.add_comment("owner", "repo", 1, "body") + assert res == "Comment added to #1." + + +def test_add_comment_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.add_comment.side_effect = Exception("API Error") + + issue_tools: IssueTools = IssueTools(mock_client) + res: str = issue_tools.add_comment("owner", "repo", 1, "body") + assert "Error adding comment: API Error" in res + + +def test_add_label_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.add_label.return_value = LabelModel(name="bug") + + issue_tools: IssueTools = IssueTools(mock_client) + res: str = issue_tools.add_label("owner", "repo", 1, "bug") + assert res == "Label 'bug' added to #1." + + +def test_add_label_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.add_label.side_effect = Exception("API Error") + + issue_tools: IssueTools = IssueTools(mock_client) + res: str = issue_tools.add_label("owner", "repo", 1, "bug") + assert "Error adding label: API Error" in res diff --git a/tests/test_pr_tools.py b/tests/test_pr_tools.py new file mode 100644 index 0000000..a0e23a0 --- /dev/null +++ b/tests/test_pr_tools.py @@ -0,0 +1,232 @@ +import json +from typing import Any +from unittest.mock import MagicMock +from gitea.client import GiteaClient +from gitea.models import PullRequestModel, CommentModel, RepositoryModel +from gitea.tools.pr_tools import PRTools + + +def test_get_pull_request_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + pr: PullRequestModel = PullRequestModel(number=1, title="Test PR", state="open") + mock_client.get_pull_request.return_value = pr + + pr_tools: PRTools = PRTools(mock_client) + res: str = pr_tools.get_pull_request("owner", "repo", 1) + + data: dict[str, Any] = json.loads(res) + assert data["number"] == 1 + assert data["title"] == "Test PR" + mock_client.get_pull_request.assert_called_once_with("owner", "repo", 1) + + +def test_get_pull_request_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.get_pull_request.side_effect = Exception("API Error") + + pr_tools: PRTools = PRTools(mock_client) + res: str = pr_tools.get_pull_request("owner", "repo", 1) + assert "Error getting pull request: API Error" in res + + +def test_close_pull_request_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.close_pull_request.return_value = PullRequestModel(number=1, state="closed") + + pr_tools: PRTools = PRTools(mock_client) + res: str = pr_tools.close_pull_request("owner", "repo", 1) + assert res == "Pull request #1 closed successfully." + mock_client.close_pull_request.assert_called_once_with("owner", "repo", 1) + + +def test_close_pull_request_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.close_pull_request.side_effect = Exception("API Error") + + pr_tools: PRTools = PRTools(mock_client) + res: str = pr_tools.close_pull_request("owner", "repo", 1) + assert "Error closing pull request: API Error" in res + + +def test_get_pull_request_comments_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + comment: CommentModel = CommentModel(id=123, body="Comment body") + mock_client.get_pull_request_comments.return_value = [comment] + + pr_tools: PRTools = PRTools(mock_client) + res: str = pr_tools.get_pull_request_comments("owner", "repo", 1) + data: list[dict[str, Any]] = json.loads(res) + assert len(data) == 1 + assert data[0]["body"] == "Comment body" + + +def test_get_pull_request_comments_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.get_pull_request_comments.side_effect = Exception("API Error") + + pr_tools: PRTools = PRTools(mock_client) + res: str = pr_tools.get_pull_request_comments("owner", "repo", 1) + assert "Error getting PR comments: API Error" in res + + +def test_list_assigned_pull_requests_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + repo: RepositoryModel = RepositoryModel(name="repo1", owner="owner1") + pr: PullRequestModel = PullRequestModel(number=1, title="Test PR") + mock_client.list_all_user_repos.return_value = [repo] + mock_client.list_assigned_pull_requests.return_value = [pr] + + pr_tools: PRTools = PRTools(mock_client) + res: list[dict[str, Any]] = pr_tools.list_assigned_pull_requests() + assert len(res) == 1 + assert res[0]["number"] == 1 + mock_client.list_all_user_repos.assert_called_once() + mock_client.list_assigned_pull_requests.assert_called_once_with("owner1", "repo1") + + +def test_list_assigned_pull_requests_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.list_all_user_repos.side_effect = Exception("API Error") + + pr_tools: PRTools = PRTools(mock_client) + res: list[dict[str, Any]] = pr_tools.list_assigned_pull_requests() + assert res == [] + + +def test_list_pull_requests_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + pr: PullRequestModel = PullRequestModel(number=1, title="Test PR") + mock_client.list_repo_pull_requests.return_value = [pr] + + pr_tools: PRTools = PRTools(mock_client) + res: str = pr_tools.list_pull_requests("owner", "repo") + assert "#1: Test PR" in res + + +def test_list_pull_requests_empty() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.list_repo_pull_requests.return_value = [] + + pr_tools: PRTools = PRTools(mock_client) + res: str = pr_tools.list_pull_requests("owner", "repo") + assert res == "No PRs in owner/repo." + + +def test_list_pull_requests_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.list_repo_pull_requests.side_effect = Exception("API Error") + + pr_tools: PRTools = PRTools(mock_client) + res: str = pr_tools.list_pull_requests("owner", "repo") + assert "Error listing PRs: API Error" in res + + +def test_create_pull_request_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + pr: PullRequestModel = PullRequestModel(number=2, title="Title") + mock_client.create_pr_via_tea.return_value = pr + + pr_tools: PRTools = PRTools(mock_client) + res: str = pr_tools.create_pull_request("owner", "repo", "head", "base", "Title", "Desc") + data: dict[str, Any] = json.loads(res) + assert data["number"] == 2 + mock_client.create_pr_via_tea.assert_called_once_with("owner", "repo", "Title", "Desc", "head", "base") + + +def test_create_pull_request_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.create_pr_via_tea.side_effect = Exception("API Error") + + pr_tools: PRTools = PRTools(mock_client) + res: str = pr_tools.create_pull_request("owner", "repo", "head", "base", "Title") + assert "Error creating PR: API Error" in res + + +def test_add_label_to_pr_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.add_label_pr.return_value = {} + + pr_tools: PRTools = PRTools(mock_client) + res: str = pr_tools.add_label_to_pr("owner", "repo", 1, "bug") + assert res == "Label 'bug' added to PR #1." + + +def test_add_label_to_pr_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.add_label_pr.side_effect = Exception("API Error") + + pr_tools: PRTools = PRTools(mock_client) + res: str = pr_tools.add_label_to_pr("owner", "repo", 1, "bug") + assert "Error adding label to PR #1: API Error" in res + + +def test_get_pull_request_diff_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.get_pull_request_diff.return_value = "diff content" + + pr_tools: PRTools = PRTools(mock_client) + res: str = pr_tools.get_pull_request_diff("owner", "repo", 1) + assert res == "diff content" + + +def test_get_pull_request_diff_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.get_pull_request_diff.side_effect = Exception("API Error") + + pr_tools: PRTools = PRTools(mock_client) + res: str = pr_tools.get_pull_request_diff("owner", "repo", 1) + assert "Error getting PR diff: API Error" in res + + +def test_get_pull_request_patch_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.get_pull_request_patch.return_value = "patch content" + + pr_tools: PRTools = PRTools(mock_client) + res: str = pr_tools.get_pull_request_patch("owner", "repo", 1) + assert res == "patch content" + + +def test_get_pull_request_patch_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.get_pull_request_patch.side_effect = Exception("API Error") + + pr_tools: PRTools = PRTools(mock_client) + res: str = pr_tools.get_pull_request_patch("owner", "repo", 1) + assert "Error getting PR patch: API Error" in res + + +def test_approve_pull_request_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.approve_pr.return_value = {} + + pr_tools: PRTools = PRTools(mock_client) + res: str = pr_tools.approve_pull_request("owner", "repo", 1, "good") + assert res == "Approved PR #1." + + +def test_approve_pull_request_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.approve_pr.side_effect = Exception("API Error") + + pr_tools: PRTools = PRTools(mock_client) + res: str = pr_tools.approve_pull_request("owner", "repo", 1, "good") + assert "Error approving PR: API Error" in res + + +def test_request_changes_success() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.request_changes_pr.return_value = {} + + pr_tools: PRTools = PRTools(mock_client) + res: str = pr_tools.request_changes("owner", "repo", 1, "bad") + assert res == "Requested changes on PR #1." + + +def test_request_changes_failure() -> None: + mock_client: MagicMock = MagicMock(spec=GiteaClient) + mock_client.request_changes_pr.side_effect = Exception("API Error") + + pr_tools: PRTools = PRTools(mock_client) + res: str = pr_tools.request_changes("owner", "repo", 1, "bad") + assert "Error requesting changes: API Error" in res diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..b445f31 --- /dev/null +++ b/uv.lock @@ -0,0 +1,700 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version < '3.13'", +] + +[[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.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[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 = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[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 = "gitea-agent" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "lmstudio" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "lmstudio", specifier = ">=1.5.0" }, + { name = "openai", specifier = ">=1.0.0" }, + { name = "pydantic", specifier = ">=2.13.4" }, + { name = "pydantic-settings", specifier = ">=2.0.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.1.1" }, + { name = "ty", specifier = ">=0.0.55" }, +] + +[[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-ws" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore" }, + { name = "httpx" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/cd/ca91a07ae446451f7476bf3fcc909e98cb942ff032ebfda0e3fe449aca7b/httpx_ws-0.9.0.tar.gz", hash = "sha256:797373326f70eec1ae96f6e43ae9f12002fd7d73aee139a4985eaab964338a08", size = 107105, upload-time = "2026-03-28T14:11:10.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/f8/a6bc80313a9e93c888fa10534dfce2ad76ff86911b6f485777ce6de6a073/httpx_ws-0.9.0-py3-none-any.whl", hash = "sha256:71640d2fb1bf9a225775015b33cd755cfd4c5f7e21c885192fe3adc4c387b248", size = 15759, upload-time = "2026-03-28T14:11:11.887Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[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 = "jiter" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/b5/55f06bb281d92fb3cc86d14e1def2bd908bb77693183e7cb1f5a3c388b0c/jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76", size = 166640, upload-time = "2026-05-19T10:09:48.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/da/76a2c7e510ba15fe323d9509c223ab272da79ea59f54488f4a78da6426db/jiter-0.15.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:edebcf7d1f601199084bb6e844d7dc67e03e04f6ac786b0332d616635c4ff7a4", size = 310849, upload-time = "2026-05-19T10:06:51.944Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8e/827be942883a4dc0862c48626ff41af3320b1902d136a0bf4b9041f2c567/jiter-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9f924585cdacf631cd382b657966847bb537bf9ed0a6f9b991da5f05a631480f", size = 314991, upload-time = "2026-05-19T10:06:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/6d/38/be2832be361ba1b9517c76f46d30b64e985be1dd43c974f4c3a4b1844436/jiter-0.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abbf258599526ad0326fe51e252e24f2bd6f24f1852681b4b78feda3808f1d18", size = 340843, upload-time = "2026-05-19T10:06:55.071Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/90f01fb83c0c7ba509303ec93e32a308fbfa167d264860b01c0fd0dbbd06/jiter-0.15.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7c468136b8bd6bb18c8786e4236a1fa27362f24cb23450ba0cb204ab379b8e6f", size = 365116, upload-time = "2026-05-19T10:06:56.893Z" }, + { url = "https://files.pythonhosted.org/packages/91/38/94593d34f8c67a0b6f6cbc027f016ffa9780b3a858a7a86f6fd7a15bcc1e/jiter-0.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05906b93d72f03339e6bb7cf8dc10ebda64a0266126eed6beba79e20abcf5fd4", size = 457970, upload-time = "2026-05-19T10:06:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/df/04/d79962dd49d00c97e2a9b4cacea1947904d02135936960351f9a96d4c1a6/jiter-0.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:30ce785d2adb8e32c3f7741442370a74834ec4c01f3c48f0750227a0b4ef27d6", size = 375744, upload-time = "2026-05-19T10:07:00.471Z" }, + { url = "https://files.pythonhosted.org/packages/c3/2e/5d37abe2be0e819c21e2338bebd410e481763ce526a9138c8c3652fa0123/jiter-0.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fd73e3da91a0a722d67165e849ce2cdc10de0e0d48738c142be8c6c5f310f4c", size = 349609, upload-time = "2026-05-19T10:07:01.829Z" }, + { url = "https://files.pythonhosted.org/packages/7a/90/98768ad2ed90c1fda15d64157de2dfbf73c1c074d4b1bfaca915480bc7cf/jiter-0.15.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:ceb8fc27d38793f9c97149be8302720c5b22e5c195a37bf2c45dc36c4600a512", size = 354366, upload-time = "2026-05-19T10:07:03.587Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c4/fbfb806209f1fe4b7dccdfb07bc62bb044300734a945b06fd64db446ef6a/jiter-0.15.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d726e3ceeb337191324b49de298142f27c3ad10886341555d1d5315b5f252c6a", size = 393519, upload-time = "2026-05-19T10:07:05.08Z" }, + { url = "https://files.pythonhosted.org/packages/37/1c/b9c257cd70cb453b6d10f3ebf0402cdb11669ab455389096f09839670290/jiter-0.15.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2c8aea7781d2a372227871de4e1a1332aa96f5a89fd76c5e835dafdbad102887", size = 519952, upload-time = "2026-05-19T10:07:06.589Z" }, + { url = "https://files.pythonhosted.org/packages/a9/1a/aa85027db7ab15829c12feebbc33b404f53fc399bd559d85fd0d6365ff0d/jiter-0.15.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cf4bd113a69c0a740e27cb962ce10630c36d2b8f59d759a651b955ee9d18a823", size = 550770, upload-time = "2026-05-19T10:07:08.228Z" }, + { url = "https://files.pythonhosted.org/packages/d4/54/8c3f65c8a5687925e84708f19d63f7f37d28e2b86a48d951702ad94424d8/jiter-0.15.0-cp310-cp310-win32.whl", hash = "sha256:d92a5cd21fdb083931d546c207aa29633787c5dc5b02daab2d32b843f88a2c53", size = 209303, upload-time = "2026-05-19T10:07:10.006Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/0528a1eb9f42dd2d8228a0711458628f35924d131f623eaebc35fd23d3d4/jiter-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:e58585a58209d72691ce2d62a9147445f5a87beb0bde97fde284c96ae392a3d1", size = 200404, upload-time = "2026-05-19T10:07:11.426Z" }, + { url = "https://files.pythonhosted.org/packages/e4/13/daa722f5765c393576f466378f9dfd29d77c9bed939e0688f96afa3601ea/jiter-0.15.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0f862193b8696249d22ec433e85fd2ab0ad9596bc3e45e6c0bc55e8aeba97be2", size = 310899, upload-time = "2026-05-19T10:07:12.89Z" }, + { url = "https://files.pythonhosted.org/packages/7f/82/2d2551829b082f4b6d82b9f939b031fb808a10aab1ec0664f82e150bb9a2/jiter-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1303d4d68a9b051ea90502402063ecf3807da00ad2affa19ca1ae3b90b3c5f67", size = 314963, upload-time = "2026-05-19T10:07:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0a/8b1a51466f7fe9f31dbe4bc7e0ca848674f9825e0f737b929b97e8c60aa7/jiter-0.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:392b8ab019e5502d08aff85c6272209c24bc2cbe706ea82a56368f524236614a", size = 341730, upload-time = "2026-05-19T10:07:15.869Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2a/e71dea19822e2e404e83992a08c1d6b9b617bb944f28c9c2fbd85d02c91e/jiter-0.15.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:773b6eb282ce11ee19f05f6b2d4404fa308e5bbd353b0b80a0262caad6db2cd7", size = 366214, upload-time = "2026-05-19T10:07:17.259Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/97e1fa539d124a509a00ab7f669289d1c1d236ecabf12948a18f16c91082/jiter-0.15.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2c0c44d569ce0f2850f5c926f8caeb5f245fbc84475aeb36efccc2103e6dbd", size = 459527, upload-time = "2026-05-19T10:07:18.741Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7a/4a68d331aef8cf2e2393c14a3aacb635c62aa86071b0229899fb5baaa907/jiter-0.15.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:032396229564bca02440396bd327710719f724f5e7b7e9f7a8eb3faa4a2c2281", size = 375451, upload-time = "2026-05-19T10:07:20.208Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/1c445c2b6f0e30a274dc8082e0c3c7825411cce80d726bccd697c98cc8d3/jiter-0.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d37768fce7f88dd2a8c6091f2325dea27d30d30d5c6e7a1c0f0af77723b708", size = 349428, upload-time = "2026-05-19T10:07:22.372Z" }, + { url = "https://files.pythonhosted.org/packages/00/94/e20d38984fc17a636371bffd2ae0f698124fdc8e75ef969cd2da6ba7cea7/jiter-0.15.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2c9cb907439d20bd0c7d7565ca01ee52234203208433749bae5b516907526928", size = 355405, upload-time = "2026-05-19T10:07:23.916Z" }, + { url = "https://files.pythonhosted.org/packages/94/fa/4d09f814779d0ea80a28ed8e4c6662ec9a4a8ecef0ac52190ebac6262d14/jiter-0.15.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9100ddbec09741cc66feb0fc6773f8bdbd0e3c345689368f260082ff85dcc0cd", size = 393688, upload-time = "2026-05-19T10:07:25.854Z" }, + { url = "https://files.pythonhosted.org/packages/54/9d/8eb5d4fb8bf7e93a75964a5da71a75c67c864baf7fa3f98598187b3c7e57/jiter-0.15.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ae1b0d82ac2d987f9ea512b1c9adfcc71a28de3dea3a6039b54d76cffda9901e", size = 520853, upload-time = "2026-05-19T10:07:27.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/5e07874e59e623a943a0acf1552a80d05b70f31b402287a8fc6d7ec634c7/jiter-0.15.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8020c99ec13a7db2b6f96cbe82ef4721c88b426a4892f27478044af0284615ef", size = 551016, upload-time = "2026-05-19T10:07:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/d2d34422143474cadc15b60d482b1c35683dbc5c63c24346ddd0df09bcaf/jiter-0.15.0-cp311-cp311-win32.whl", hash = "sha256:42bfb257930800cf43e7c62c832402c704ab60797c992faf88d20e903eac8f32", size = 209518, upload-time = "2026-05-19T10:07:30.431Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7d/52778b930e5cc3e52a37d950b1c10494244308b4329b25a0ff0d88303a81/jiter-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:860a74063284a2ae9bfedd694f299cc2c68e2696c5f3d440cc9d18bb81b9dd04", size = 200565, upload-time = "2026-05-19T10:07:32.125Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4f/d9b4067feb69b3fa6eb0488e1b59e2ad5b463fe39f59e527eab2aca00bb0/jiter-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:37a10c377ce3a4a85f4a67f28b7afe093154cde77eaf248a72e856aa08b4d865", size = 195488, upload-time = "2026-05-19T10:07:33.846Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/4f6bddbcde3c71e56d0aa1337ec95950f3d27dd4153e25aadf0feac71751/jiter-0.15.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d", size = 308793, upload-time = "2026-05-19T10:07:35.25Z" }, + { url = "https://files.pythonhosted.org/packages/01/84/c01099b59a285a1ebba64ae93f62bfa036675340fd1b0045ae65890a0442/jiter-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0", size = 309570, upload-time = "2026-05-19T10:07:36.919Z" }, + { url = "https://files.pythonhosted.org/packages/58/64/8fb7f9d45bb98190355454cd04dad8d8f27223d6bd52f83af07f637168a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138", size = 336783, upload-time = "2026-05-19T10:07:38.694Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b6/f5739011d009b3a30f6a53c5240979030ba29ae46a8c67e3a15759f7c37d/jiter-0.15.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61", size = 363555, upload-time = "2026-05-19T10:07:40.832Z" }, + { url = "https://files.pythonhosted.org/packages/e5/12/98a9d9f766665e8a3b6252454e17cb0c464606a28cf2fa09399b003345fa/jiter-0.15.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687", size = 452255, upload-time = "2026-05-19T10:07:42.62Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d5/60f972840f79c5e7544fce567c56f1e4e50468f996baba3e78d823dd62a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879", size = 373559, upload-time = "2026-05-19T10:07:44.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cf/d46ef1234ba335aabc2f013210db8e0821a22f5e644a2e9449df199ecc23/jiter-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d", size = 346055, upload-time = "2026-05-19T10:07:46.005Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/4d2749d8d54d230bad9b3a6b0d00cc28c6ff6b2fdffc26a8ccf76cc5a974/jiter-0.15.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb", size = 351406, upload-time = "2026-05-19T10:07:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b9/9965b990035d8773328e0a8c8b457a87bf2b19f6c4126d9d99296be5d16a/jiter-0.15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871", size = 389357, upload-time = "2026-05-19T10:07:49.665Z" }, + { url = "https://files.pythonhosted.org/packages/2d/55/9ddf903deda1413e87fed792f416b7123daee5b8efbad6a202a7421c36a5/jiter-0.15.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77", size = 517263, upload-time = "2026-05-19T10:07:51.537Z" }, + { url = "https://files.pythonhosted.org/packages/e8/76/a0c40ad064d3a20a4fde231e35d56e9a01ce82164278180e82d5daf85469/jiter-0.15.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d", size = 548646, upload-time = "2026-05-19T10:07:53.196Z" }, + { url = "https://files.pythonhosted.org/packages/23/4f/eca9b954942916ba2f453891b8593ab444cd872396fe66a3936616f236f3/jiter-0.15.0-cp312-cp312-win32.whl", hash = "sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d", size = 206427, upload-time = "2026-05-19T10:07:55.307Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/8ead82a87495149542748e828d153fd232a512a22c83b02c4815c1a9c7d8/jiter-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7", size = 197300, upload-time = "2026-05-19T10:07:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e4/9b8a78fb2d894471bc344e37f1949bdd784bd914d031dba0ba3a40c71dd7/jiter-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b", size = 192702, upload-time = "2026-05-19T10:07:58.307Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f4/f708c900ecee41b2025ef8413d5351e5649eb2125c506f6720cc69b06f5c/jiter-0.15.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1c11465f97e2abf45a014b83b730222f8f1c5335e802c7055a67d50de6f1f4e3", size = 307829, upload-time = "2026-05-19T10:07:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/86/59/db537c0949e83668c38481d426b9f2fd5ab758c4ee53a811dd0a510626a0/jiter-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e7b1776f0797956c509e123d0952d10d293a9492dea9f288ab9570ec01d1a5", size = 308445, upload-time = "2026-05-19T10:08:01.184Z" }, + { url = "https://files.pythonhosted.org/packages/37/38/ea0e13b18c30ef951da0d47d39e7fa9edb82a93a62990ffbd7cea9b622d4/jiter-0.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351a341c2105aa430b7047e30f1bf7975f6313b00165d3fc07be2edaf741f279", size = 336181, upload-time = "2026-05-19T10:08:02.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/fc/2303901b16c4ba05865588990a420c0b4156270b44379c20931544a1d962/jiter-0.15.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ab395feec8d249ec4044e228e98a7033f043426a265df439dc3698823f0a4e4", size = 362985, upload-time = "2026-05-19T10:08:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/11bace093c52e7d4d26c8e606ccd7ae8c972189622469ec0d9e28161e28b/jiter-0.15.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2a438005b6f22d0273413484d6094d7c2c5d10ec1b3a3bf128e0d1d3ba53258", size = 453292, upload-time = "2026-05-19T10:08:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/987f2f086ca4d7a6582eb4ccd513f9b26b42d9e4243a087609a3137a8fc7/jiter-0.15.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f18f85e4218d1b40f000f42a92239a7a61a902cd42c65e6c360dbd17dcb20894", size = 373501, upload-time = "2026-05-19T10:08:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/89fbcabb2739b7a5b8dc959a1b6c5761f6484f5fed3486854b3c789bb1de/jiter-0.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1aa62e277fc1cbd80e6deacae6f4d983b41b3d7728e0645c5d741a6149bba45", size = 344683, upload-time = "2026-05-19T10:08:09.431Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/6cca7692e7dddfec6d8d76c54dc97f2af2a41df4ac0674b999df1f09a5f3/jiter-0.15.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:6550fa135c7deb8ead6af49ed7ff648532ea8334a1447fe34a36315ef79c5c29", size = 350892, upload-time = "2026-05-19T10:08:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/39/14/0338d6190cb8e6d22e677ab1d4eabd4117f67cca70c54cd04b82ff64e068/jiter-0.15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:066f8f33f18b2419cd8213b2436fa7fbc9c499f315971cfa3ce1f9820c001b1b", size = 388723, upload-time = "2026-05-19T10:08:12.912Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/cc19f4a1bdb6afb09ce6a2f2615aa8d44d994eba0d8e6105ed1af920e736/jiter-0.15.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:75e8a04e91432dde9f1838373cf93d23726c79d3e908d319acf0e796f85592e7", size = 516648, upload-time = "2026-05-19T10:08:14.808Z" }, + { url = "https://files.pythonhosted.org/packages/49/9f/833c541512cd091b63c10c0381973dfe11bc7a503a818c16384417e0c81e/jiter-0.15.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a97261f1fccb8e50ecd2890a96e46efdc3f57c80a197324c6777827231eca712", size = 547382, upload-time = "2026-05-19T10:08:16.927Z" }, + { url = "https://files.pythonhosted.org/packages/d2/11/e7b70e91f90bc4477e8eee9e8a5f7cf3cb41b4525d6394dc98a714eb8f7f/jiter-0.15.0-cp313-cp313-win32.whl", hash = "sha256:c77496cb10bd7549690fbbab3e5ec05857b83e49276f4a9423a766ddd2afcd4c", size = 205845, upload-time = "2026-05-19T10:08:18.401Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/5c20d9ad6f02c493e4023e5d2d09e1c1f15fe2753c9102c544aff068a88e/jiter-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b15741f501469009ae0ae90b7147958a664a7dede40aa7ff174a8a4645f546d0", size = 196842, upload-time = "2026-05-19T10:08:20.131Z" }, + { url = "https://files.pythonhosted.org/packages/6b/11/1eb400ef248e8c925fd883fbe325daf5e42cd1b0d308539dd332bd4f7ffc/jiter-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d6a60072b44c3c2b797a7ddcbcbbf2b34ea3cfd4721580fbfd2a09d9d9b84ba", size = 192212, upload-time = "2026-05-19T10:08:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/8a/60/2fd8d7c79da8acf9b7b277c7616847773779356b92acfc9bb158452174da/jiter-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef1fd24d9413f6209e00d3d5a453e67acfe004a25cc6c8e8484faed4311ab9e8", size = 315065, upload-time = "2026-05-19T10:08:23.218Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/008fb7d65e8ac2abf00811651a661e025c4ba80bbc6f378450384ddd3aed/jiter-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144f8e72cb53dab146347b91cceac01f5481237f2b93b4a339a1ee8f8878b67c", size = 339444, upload-time = "2026-05-19T10:08:24.701Z" }, + { url = "https://files.pythonhosted.org/packages/00/55/90b0c7b9c6896c0f2a591dd36d36b71d22e09674bfef178fa03ba3f81499/jiter-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553fcac2ef2cb990877f9fc0833b8b629a3e6a5670b6b5fd58219b41a653ddc4", size = 347779, upload-time = "2026-05-19T10:08:26.408Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/69666cec5000fd57734c118437394516c749ae8dbeea9fb66d6fef9c4775/jiter-0.15.0-cp313-cp313t-win_amd64.whl", hash = "sha256:774f93f65031856bf14ad9f59bdcab8b8cad501e5ceabd51ba3525f76937a25b", size = 200395, upload-time = "2026-05-19T10:08:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/39/04/a6aa62cd27e8149b0d28df5561f10f6cceaf7935a9ccf3f1c5a05f9a0cd8/jiter-0.15.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f1e1754960f38ec40613a07e5e372df67acb3b890fb383b6fb3de3e49ddbf3c7", size = 190516, upload-time = "2026-05-19T10:08:29.35Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/079f350ebf7859d081de30aa890f9e3be68516f754f3ba32366ffff4dcee/jiter-0.15.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ac0d9ddea4350974be7a221fc25895f251a8fee748c889bdced2141c0fec1a49", size = 308884, upload-time = "2026-05-19T10:08:31.667Z" }, + { url = "https://files.pythonhosted.org/packages/04/4e/a2c30a7f69b48c03b20935d647479106fe932f6e63f75faf53937197e05d/jiter-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01a8222cf05ab1128e239421156c207949808acaaea2bdfd33130ae666786e86", size = 310028, upload-time = "2026-05-19T10:08:33.304Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/2e7cdfd3cf8ca967be38c48f5cf474d79f089efaf559a40f15984a77ae69/jiter-0.15.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:182226cbc930c9fab81bc2e41a4da672f89539906dadb05e75670ac07b94f71f", size = 337485, upload-time = "2026-05-19T10:08:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/15a1aa28b120b8ee5b4f1fb894c125046225f09847738bd64233d3b84883/jiter-0.15.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:71683c38c825452999b5717fcae07ea708e8c93003e808be4319c1b02e3d176e", size = 364223, upload-time = "2026-05-19T10:08:36.694Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/f442e8af5f3d0dcf47b39e83a0efd9ee45ea946aa6d04625dc3181eae3b6/jiter-0.15.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30f2218e6a9e5c18bc10fe6d41ac189c442c88eacf11bad9f28ef95a9bef00e6", size = 456387, upload-time = "2026-05-19T10:08:38.143Z" }, + { url = "https://files.pythonhosted.org/packages/da/f4/37f2d2c9f64f49af7da652ed7532bb5a2372e588e6927c3fdd76f911db65/jiter-0.15.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5157de9f76eb4bc5ea74a1219366a25f945ad305641d74e04f59c54087091aa9", size = 374461, upload-time = "2026-05-19T10:08:39.869Z" }, + { url = "https://files.pythonhosted.org/packages/60/28/edcfbbbf0cb15436f36664a8908a0df47ab9006298d4cd937dc08ea932d6/jiter-0.15.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c5db5527c221249a876160663ab891ace358c17f7b9c93ec1478b7f0550e5c", size = 345924, upload-time = "2026-05-19T10:08:41.668Z" }, + { url = "https://files.pythonhosted.org/packages/47/13/89fba6398dab7f202b7278c4b4aac122399d2c0183971c4a57a3b7088df5/jiter-0.15.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:3e4540b8e74e4268811ac05db226a6a128ff572e7e0ce3f1163b693cadb184cd", size = 352283, upload-time = "2026-05-19T10:08:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/1b/da/0f6af8cef2c565a1ab44d970f268c43ccaa72707386ea6388e6fe2b6cd26/jiter-0.15.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:62ebd14e47e9aed9df4472afcb2663668ce4d74891cd54f86bf6e44029d6dc89", size = 389985, upload-time = "2026-05-19T10:08:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ec/b9cb7d6d29e24ee14910266157d2a279d7a8f60ee0df7fa840882976ba64/jiter-0.15.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0be6f5ad41a809f303f416d17cec92a7a725902fb9b4f3de3d19362ac0ef8554", size = 517695, upload-time = "2026-05-19T10:08:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/64/5e/6d1bda880723aae0ad86b4b763f044362448efe31e3e819635d41cb03451/jiter-0.15.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:813dfbb17d65328bf86e5f0905dd277ba2265d3ca20556e86c0c7035b7182e5a", size = 548868, upload-time = "2026-05-19T10:08:48.026Z" }, + { url = "https://files.pythonhosted.org/packages/0c/72/7de501cf38dcacaf35098796f3a50e0f2e338baba18a58946c618544b809/jiter-0.15.0-cp314-cp314-win32.whl", hash = "sha256:50e51156192722a9c58db112837d3f8ef96fb3c5ecc14e95f409134b08b158ec", size = 206380, upload-time = "2026-05-19T10:08:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a9/e19addf4b0c1bdce52c6da12351e6bc42c340c45e7c09e2158e46d293ccc/jiter-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:30ce1a5d16b5641dc935d50ef775af6a0871e3d14ab05d6fc54dff371b78e558", size = 197687, upload-time = "2026-05-19T10:08:51.088Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c9/776b1db01db25fc6c1d58d1979a37b0a9fe787e5f5b1d062d2eaacb77923/jiter-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:510c8b3c17a0ed9ac69850c0438dada3c9b82d9c4d589fcb62002a5a9cf3a866", size = 192571, upload-time = "2026-05-19T10:08:52.451Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f6/45bb4670bacf300fd2c7abadbfb3af376e5f1b6ae75fd9bc069891d15870/jiter-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7553333dd0930c104a5a0db8df72bf7219fe663d731383b576bb6ed6351c984d", size = 317151, upload-time = "2026-05-19T10:08:53.867Z" }, + { url = "https://files.pythonhosted.org/packages/d7/68/ed635ad5acd7b73e454283083bbb7c8205ad10e88b0d9d7d793b09fe8226/jiter-0.15.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2143ab06181d2b029eedcb6af3cebe95f11bbac62441781860f98ee9330a6a6", size = 341243, upload-time = "2026-05-19T10:08:55.383Z" }, + { url = "https://files.pythonhosted.org/packages/5d/db/3ff4176b817b8ea33879e71e13d8bc2b0d481a7ed3fe9e080f333d415c16/jiter-0.15.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eac374c5c975709b69c10f09afd199df74150172156ad10c8d4fd785b7da995", size = 363629, upload-time = "2026-05-19T10:08:56.928Z" }, + { url = "https://files.pythonhosted.org/packages/ab/24/5f8270e0ba9c883582f96f722f8a0b58015c7ce1f8c6d4571cf394e99b6b/jiter-0.15.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3b3b775e33d3bfaec9899edc526ae97b0da0bf9d071a46124ba419149a414f8", size = 456198, upload-time = "2026-05-19T10:08:58.618Z" }, + { url = "https://files.pythonhosted.org/packages/45/5b/76fc02b0b5c54c3d18c60653156e2f76fde1816f9b4722db68d6ee2c897e/jiter-0.15.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3071db3346334beae1360b46da4606da57bf3528c167b3c38533afaf9f2c5", size = 373710, upload-time = "2026-05-19T10:09:00.151Z" }, + { url = "https://files.pythonhosted.org/packages/c4/52/4310821b0ea9277994d3e1f49fc6a4b34e4800caebacb2c0af81da59a454/jiter-0.15.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6694a173ecabc12eb60efbc0b474464ead1951ff65cd8b1e72100715c64512b", size = 349901, upload-time = "2026-05-19T10:09:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/93/fe/67648c35b3594fba8854ac64cc8a826d8bcd18324bbdb53d77697c60b6ef/jiter-0.15.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a254e10b593624d230c365b6d616b22ca0ad65e63a16e6631c2b3466022e6ba8", size = 352438, upload-time = "2026-05-19T10:09:03.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/28/0a1879d07ad6b3e025a2750027363452ced93c2d16d1c9d4b153ffd51c91/jiter-0.15.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d8d2955167274e15d79a7a020afdd9b39c990eb80b2d89fca695d92dcfdd38ec", size = 388152, upload-time = "2026-05-19T10:09:04.741Z" }, + { url = "https://files.pythonhosted.org/packages/c1/78/46c6f6b56ba85c90021f4afd72ed42f691f8f84daacb5fe27277070e3858/jiter-0.15.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:acf4ee4d1fc55917239fe72972fb292dd773055d05eb040d36f4326e02cc2c0e", size = 517707, upload-time = "2026-05-19T10:09:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/ca/cb/720662d4c88fcad606e826fef5424365527ba43ce4868a479aed8f8c507e/jiter-0.15.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e7196e56f1cd69af1dbb07dff02dcfb260a50b45a82d409d92a06fedb32473b5", size = 548241, upload-time = "2026-05-19T10:09:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/60/e3/935b8034fd143f21125c87d51404a9e0e1449186a494405721ff5d1d695e/jiter-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:7f6163c0f10b055245f814dcc59f4818da60dfe72f3e72ab89fc24b6bd5e9c52", size = 207950, upload-time = "2026-05-19T10:09:09.616Z" }, + { url = "https://files.pythonhosted.org/packages/93/59/984fd9ece895953dad3e0880a650e766f5a2da2c5514f0eafdaaabbeb5f9/jiter-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c256edb05b78a111b99c4de3b1d32e31634b867fd1fc2cf726e7b7bba9854", size = 200055, upload-time = "2026-05-19T10:09:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/cf8d779feb133a27a2e3bc833bccb9e13aa332cdf820497ebf72c10ce8c3/jiter-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:66b1880df2d01e206e8339769d1c7c1753bcb653efd6289e203f6f24ebada0c0", size = 191244, upload-time = "2026-05-19T10:09:12.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/43/1fc62172aa98b50a7de9a25554060db510f85c89cfbed0dfe13e1907a139/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:411fa4dfa5a7ae3d11491027ffb9beadec3996010a986862db70d91abba1c750", size = 305585, upload-time = "2026-05-19T10:09:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c4/dd58fcd9e2df83666e5c1c1347bef58ce919cd8efc3ffa38aeea62ce493b/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:2b0074e2f56eb2dacca1689760fd2852a068f85a0547a157b82cb4cafeb6768b", size = 306936, upload-time = "2026-05-19T10:09:37.435Z" }, + { url = "https://files.pythonhosted.org/packages/39/86/b695e16f1180c07f43ea98e73ecd21cf63fa2e1b0c1103739013784d11ae/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:913d02d29c9606643418d9ccfc3b72492ab25a6bf7889934e09a3490f8d3438b", size = 342453, upload-time = "2026-05-19T10:09:39.294Z" }, + { url = "https://files.pythonhosted.org/packages/34/56/55d76614af37fe3f22a3347d1e410d2a15da581997cb2da499a625000bb5/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b15d3ec9b0449c40e85319bdb4caa8b77ab526e74f5532ed94bec15e2f66822c", size = 345606, upload-time = "2026-05-19T10:09:40.727Z" }, + { url = "https://files.pythonhosted.org/packages/73/38/505941b2b092fd5bbbd60a52a880db1173f1690ae6751bed3af1c9ddcb4e/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0", size = 303769, upload-time = "2026-05-19T10:09:42.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/a06692b29e77473f286e1ec1f426d3ca44d7b5843be8ad21d7a5f3fcdcc0/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45", size = 305128, upload-time = "2026-05-19T10:09:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/7270d7ad41d6061a25b950c6bf91d638bd9aacb113200a8c8d57a055fd67/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c", size = 340459, upload-time = "2026-05-19T10:09:45.452Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" }, +] + +[[package]] +name = "lmstudio" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-ws" }, + { name = "msgspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/6d/40876f14c759fa2072ccbc844cc3ef7c74c1824e17e7d19b2cf4ff2cea2c/lmstudio-1.5.0.tar.gz", hash = "sha256:458c34fe1f94a7dcc521d4226b4cee82b8af7ea3da8c40b31bbdac558d9a74d4", size = 200230, upload-time = "2025-08-22T13:52:42.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/51/dcf7a86872a9de6529d67c68db0319b6f2afa093294d1fb8f1affcc86cc8/lmstudio-1.5.0-py3-none-any.whl", hash = "sha256:0b1e5a1cf013744a6ce0a80960204af5bf468dbbb2a505acb4dfb67185fffbe9", size = 139490, upload-time = "2025-08-22T13:52:41.456Z" }, +] + +[[package]] +name = "msgspec" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/38/d591d9f66d43d897ecbd249f2833665823d19c8b043f16619bc8343e23df/msgspec-0.21.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72d9cd03241b8b2edb2e12dcc66c500fa480d8cbd71a8bac105809d468882064", size = 195172, upload-time = "2026-04-12T21:43:45.062Z" }, + { url = "https://files.pythonhosted.org/packages/69/1a/6899188b5982ec1324e0c629b7801eed2db987f6634fab58abd9fc82d317/msgspec-0.21.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed2ab278200e743a1d2610a4e0c8fc74f6cecb8548544cdec43f927bd9265238", size = 188316, upload-time = "2026-04-12T21:43:46.641Z" }, + { url = "https://files.pythonhosted.org/packages/9e/95/7e591b4fa11fdbbf9891164473c23420a8c781ef553295abe416bf335f42/msgspec-0.21.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd677e3001fdfed9186de72eab434da2976303cd5eb9550921d3d0c3e3e168ce", size = 216565, upload-time = "2026-04-12T21:43:48.081Z" }, + { url = "https://files.pythonhosted.org/packages/19/86/714feeaf3b84cf2027235681725593840153dedd2868578f9f2715e296bb/msgspec-0.21.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f667b90b37fad734a91671abd68e0d7f4d066862771b87e91c53996dcb7a9027", size = 222689, upload-time = "2026-04-12T21:43:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b9/4384243e814f2579e5205e17d170b9c1a30121afd1393298d904817a7fa7/msgspec-0.21.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:49880fd20fdbcfe1b793f07dd83f12572bab679c9800352c8b2240289aa46a06", size = 222343, upload-time = "2026-04-12T21:43:50.612Z" }, + { url = "https://files.pythonhosted.org/packages/04/01/4b227d9c4057346271043632bad41979cf8c3dca372e41bb1f7d546395b2/msgspec-0.21.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ae0162e22849a5e91eaad907766525107523b0daea3df267a9fcb5ba4e0936ae", size = 225607, upload-time = "2026-04-12T21:43:52.129Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/27021d1c3e5da837743092a7b7a5e8818397e1f4c05ee8b068bd7d1fd78a/msgspec-0.21.1-cp310-cp310-win_amd64.whl", hash = "sha256:f041a2279f31e3a53319005e4d60ba77c085cfcbe394cdc7ce803c2d01fe9449", size = 188392, upload-time = "2026-04-12T21:43:53.384Z" }, + { url = "https://files.pythonhosted.org/packages/80/2b/daf7a8d6d7cf00e0dcd0439178b284ade701234abdcadf3385601da04fbd/msgspec-0.21.1-cp310-cp310-win_arm64.whl", hash = "sha256:1bf17cbd7b28a5dffc7e764c654eed8ccde5e0f1de7970628608304640d4ce4e", size = 174191, upload-time = "2026-04-12T21:43:54.6Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7f/bbc4e74cd33d316b75541149e4d35b163b63bce066530ae185a2ec3b5bfc/msgspec-0.21.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b504b6e7f7a22a24b27232b73034421692147865162daaec9f3bf62439007c87", size = 193131, upload-time = "2026-04-12T21:43:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/c1/60/504886af1aaf854112663b842d5eea9a15d9588f9bf7d0d2df736424b84d/msgspec-0.21.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4692b7c1609155708c4418f88e92f63c13fdf08aa095c84bae82bad75b53389b", size = 186597, upload-time = "2026-04-12T21:43:57.242Z" }, + { url = "https://files.pythonhosted.org/packages/fa/54/d24ddeaa65b5278c9e67f48ce3c17a9831e8f3722f3c8322ee120aca22ef/msgspec-0.21.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3124010b3815451494c85ff345e693cb9fe5889cfcbbef39ed8622e0e72319c", size = 215158, upload-time = "2026-04-12T21:43:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/9f/75/bb79c8b89a93ae23cd33c0d802373f16feaf9633f05d8af77091350dda0a/msgspec-0.21.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6badc03b9725352219cca017bfe71c61f2fbd0fb5982b410ac17c97c213deb30", size = 219856, upload-time = "2026-04-12T21:44:00.015Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/c5ca26b46f0ebbd3a6683695ef89396712cb9e4199fd1f0bc1dd968216b1/msgspec-0.21.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5d2d4116ebe3035a78d9ec76e99a9d64e5fa6d44fe61a9c5de7fd1acf54bcc69", size = 220314, upload-time = "2026-04-12T21:44:01.548Z" }, + { url = "https://files.pythonhosted.org/packages/c8/31/645a351c4285dce40ed6755c3dcc0aa648e26dacb20a98018fe2cce5e87b/msgspec-0.21.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0d1009f6715f5bff3b54d4ff5c7428ad96197e0534e1645b8e9b955890c84664", size = 223215, upload-time = "2026-04-12T21:44:02.884Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8bf15736a6dd3cb4f90c5467f6dc39197d2daaf10754490cdc0aa17b7312/msgspec-0.21.1-cp311-cp311-win_amd64.whl", hash = "sha256:c6faffe5bb644ec884052679af4dfd776d4b5ca90e4a7ec7e7e319e4e6b93a6e", size = 188554, upload-time = "2026-04-12T21:44:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/ef/29/cc7db3a165b62d16e64a83f82eccb79655055cb5bc1f60459a6f9d7c82f2/msgspec-0.21.1-cp311-cp311-win_arm64.whl", hash = "sha256:ee9e3f11fa94603f7d673bf795cfa31b549c4a2c723bc39b45beb1e7f5a3fb99", size = 174517, upload-time = "2026-04-12T21:44:05.66Z" }, + { url = "https://files.pythonhosted.org/packages/6e/cf/317224852c00248c620a9bcf4b26e2e4ab8afd752f18d2a6ef73ebd423b6/msgspec-0.21.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251", size = 196188, upload-time = "2026-04-12T21:44:07.181Z" }, + { url = "https://files.pythonhosted.org/packages/6d/81/074612945c0666078f7366f40000013de9f6ba687491d450df699bceebc9/msgspec-0.21.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb", size = 188473, upload-time = "2026-04-12T21:44:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/655101799590bcc5fddb2bd3fe0e6194e816c2d1da7c361725f5eb89a910/msgspec-0.21.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920", size = 218871, upload-time = "2026-04-12T21:44:09.917Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d1/d4cd9fe89c7d400d7a18f86ccc94daa3f0927f53558846fcb60791dce5d6/msgspec-0.21.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff", size = 225025, upload-time = "2026-04-12T21:44:11.191Z" }, + { url = "https://files.pythonhosted.org/packages/24/bf/e20549e602b9edccadeeff98760345a416f9cce846a657e8b18e3396b212/msgspec-0.21.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee", size = 222672, upload-time = "2026-04-12T21:44:12.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/68/04d7a8f0f786545cf9b8c280c57aa6befb5977af6e884b8b54191cbe44b3/msgspec-0.21.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521", size = 227303, upload-time = "2026-04-12T21:44:13.709Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4d/619866af2840875be408047bf9e70ceafbae6ab50660de7134ed1b25eb86/msgspec-0.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d", size = 190017, upload-time = "2026-04-12T21:44:14.977Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/7e/74/f11ede02839b19ff459f88e3145df5d711626ca84da4e23520cebf819367/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66", size = 196176, upload-time = "2026-04-12T21:44:17.613Z" }, + { url = "https://files.pythonhosted.org/packages/bb/40/4476c1bd341418a046c4955aff632ec769315d1e3cb94e6acf86d461f9ed/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697", size = 188524, upload-time = "2026-04-12T21:44:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d9/9e9d7d7e5061b47540d03d640fab9b3965ba7ae49c1b2154861c8f007518/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5", size = 218880, upload-time = "2026-04-12T21:44:20.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/2bb344f34abb4b57e60c7c9c761994e0417b9718ec1460bf00c296f2a7ea/msgspec-0.21.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9aa659ebb0101b1cbc31461212b87e341d961f0ab0772aaf068a99e001ec4aa", size = 225050, upload-time = "2026-04-12T21:44:21.577Z" }, + { url = "https://files.pythonhosted.org/packages/1a/84/7c1e412f76092277bf760cef12b7979d03314d259ab5b5cafde5d0c1722d/msgspec-0.21.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7b27d1a8ead2b6f5b0c4f2d07b8be1ccfcc041c8a0e704781edebe3ae13c484", size = 222713, upload-time = "2026-04-12T21:44:22.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/0bba04b2b4ef05f3d068429410bc71d2cea925f1596a8f41152cccd5edb8/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61", size = 227259, upload-time = "2026-04-12T21:44:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2d/09574b0eea02fed2c2c1383dbaae2c7f79dc16dcd6487a886000afb5d7c4/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a", size = 189857, upload-time = "2026-04-12T21:44:25.359Z" }, + { url = "https://files.pythonhosted.org/packages/46/34/105b1576ad182879914f0c821f17ee1d13abb165cb060448f96fe2aff078/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898", size = 175403, upload-time = "2026-04-12T21:44:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ad/86954e987d1d6a5c579e2c2e7832b65e0fff194179fdac4f581536086024/msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f", size = 196261, upload-time = "2026-04-12T21:44:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a1/c5e46c3e42b866199365e35d11dddfd1fbd8bba4fdb3c52f965b1607ce94/msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a", size = 188729, upload-time = "2026-04-12T21:44:28.99Z" }, + { url = "https://files.pythonhosted.org/packages/85/7d/1e29a319d678d6cb962ae5bdf32a6858ebdf38f73bc654c0e9c742a0c2c8/msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f", size = 219866, upload-time = "2026-04-12T21:44:31.104Z" }, + { url = "https://files.pythonhosted.org/packages/25/1f/cca084ca2572810fff12ea9dbdcbe39eac048f40daf4a9077b49fcbe8cee/msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb", size = 224993, upload-time = "2026-04-12T21:44:32.649Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/d2120fc9d419a89a3a7c13e5b7078798c4b392a96a02a6e2b3ce43a8766c/msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df", size = 223535, upload-time = "2026-04-12T21:44:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/75/17/42418b66a3ad972a89bab73dd78b79cc6282bb488a25e73c853cee7443b9/msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f", size = 227222, upload-time = "2026-04-12T21:44:35.093Z" }, + { url = "https://files.pythonhosted.org/packages/c4/33/265c894268cca88ff67b144ca2b4c522fc8b9a6f1966a3640c70516e78e1/msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea", size = 193810, upload-time = "2026-04-12T21:44:36.612Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8f/a6d35f25bf1fc63c492fdd88fdce01ba0875ead48c2b91f90f33653b4131/msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93", size = 179125, upload-time = "2026-04-12T21:44:38.198Z" }, + { url = "https://files.pythonhosted.org/packages/c6/39/74839641e64b99d87da55af0fc472854d42b46e2183b9e2a67fe1bb2a512/msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2", size = 200171, upload-time = "2026-04-12T21:44:39.414Z" }, + { url = "https://files.pythonhosted.org/packages/70/9b/ce0cca6d2d87fcd4b6ff97600790494e64f26a2c55d61507cd2755c16193/msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b", size = 192879, upload-time = "2026-04-12T21:44:40.882Z" }, + { url = "https://files.pythonhosted.org/packages/a7/08/673a7bb05e5702dc787ddd3011195b509f9867927970da59052211929987/msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847", size = 226281, upload-time = "2026-04-12T21:44:42.181Z" }, + { url = "https://files.pythonhosted.org/packages/7d/45/86508cf57283e9070b3c447e3ab25b792a7a0855a3ea4e0c6d111ac34c97/msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7", size = 229863, upload-time = "2026-04-12T21:44:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/2c/62/e7c9367cd08d590559faacd711edbae36840342843e669440363f33c7d36/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75", size = 230445, upload-time = "2026-04-12T21:44:44.806Z" }, + { url = "https://files.pythonhosted.org/packages/42/b4/c0f54632103846b658a10930025f4de41c8724b5e4805a5f3b395586cb7e/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca", size = 231822, upload-time = "2026-04-12T21:44:46.343Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1d/0d85cc79d0ccf5508e9c846cc66552a6a16bf92abd1dbd8362617f7b35cd/msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63", size = 206650, upload-time = "2026-04-12T21:44:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/90/91/56c5d560f20e6c20e9e4f55bd0e458f7f162aa689ee350346c04c48eac0b/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90", size = 183149, upload-time = "2026-04-12T21:44:48.833Z" }, +] + +[[package]] +name = "openai" +version = "2.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/fa/88d0c58a0c58df7e6758e66b99c5d028d5e0bb49f8812d7203940cd9dbf1/openai-2.43.0.tar.gz", hash = "sha256:e74d238200a26868977002190fb6631613480a93dfe0c9c982e77021ed60a017", size = 785369, upload-time = "2026-06-17T17:06:56.06Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/d2/ba767f4bbb30776c03d40906a2d3afad716a165ffa1771fc23b8992f7920/openai-2.43.0-py3-none-any.whl", hash = "sha256:65a670b54fadf2268c9e1330133373c963eb779ee969e5cbad419ec2c21dce97", size = 1355077, upload-time = "2026-06-17T17:06:53.614Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +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/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tqdm" +version = "4.68.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, +] + +[[package]] +name = "ty" +version = "0.0.55" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/48/f687c8d268e3581f2f104d1f2ac5944d5b5e841b3695c613b3f263e5bbf7/ty-0.0.55.tar.gz", hash = "sha256:88ca87073825a79a8327c550efcc86cec94344890244c5946f84c9e44a969f31", size = 6040230, upload-time = "2026-06-27T00:27:29.385Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/a3/1a90ba7e5a61c6d09adb92346ddba97668095fc257b577af433e5ac4f404/ty-0.0.55-py3-none-linux_armv6l.whl", hash = "sha256:31e83eef512d066542fe990fe1a3b814423abd1616376c54e48af7045b3e1749", size = 11677249, upload-time = "2026-06-27T00:26:52.18Z" }, + { url = "https://files.pythonhosted.org/packages/82/3a/669f9aa478c38243e213a2684db1502086026cfadc15bb1b29b7cbde030d/ty-0.0.55-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ab4bca857950608fea73e269e2da369d43e6467131de85160d68e2fa466fa248", size = 11444180, upload-time = "2026-06-27T00:26:54.576Z" }, + { url = "https://files.pythonhosted.org/packages/15/a4/6a4b2507a53ce6530c66c5b4fe0d58551eb1748ffa9e0696c32fdd55bbd4/ty-0.0.55-py3-none-macosx_11_0_arm64.whl", hash = "sha256:55032bfd31bf2c5355ee81bdc6407b144a1cc7ee41e5681dd1368e4cef2ba327", size = 10963134, upload-time = "2026-06-27T00:26:57.348Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ae/a3b1a0f1cc83b7d258662cb98aa80a720c2e671d0e8fa0d17a4d5d057a7a/ty-0.0.55-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1e049f69ce65b3c269af67624607f435e1c32319786c1e453ef9611502f295", size = 11493517, upload-time = "2026-06-27T00:26:59.26Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9f/311ce39065a979ef40a9b847f685c8e02464e53adf1671e081eea90640ca/ty-0.0.55-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:631409975c681d5a280fc5a99b7b32e9e801f33be7567c6b42ec331362f59d7d", size = 11460590, upload-time = "2026-06-27T00:27:01.425Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/3bf29aa77bd78aae48275153135a2052fa7d3ccdf1ecabeb99c8773abd66/ty-0.0.55-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e08cb0436e68b9351555ae8f2697138c9009b4d5b4ae4272232988b2a431a98f", size = 12098430, upload-time = "2026-06-27T00:27:03.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6e/e88411a88240b94640bba06fb6d0d92b247fbeef47ee2bc71f39e58c2558/ty-0.0.55-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16c215ad9f823829409b94ee188cfaa4563f6e1384f6ce3fecb1db75f6c7cf7c", size = 12673086, upload-time = "2026-06-27T00:27:05.589Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7e/8f1762fb7f9245a68ba5ae338d73c59403ce57554e5d311b8bb55027b0ec/ty-0.0.55-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b510eb8f4032baf11b7aee2f1d53babc3b4ca03939b9cdcf6a9d15761d575188", size = 12242559, upload-time = "2026-06-27T00:27:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/72/1f/143657daf2670d977dac83435f1fe03d4843efb798d8e1e75950e541aadd/ty-0.0.55-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ddc05e7959709c3b9b83aa627128a80446865e3c1a4882638dcff6d776dc34a", size = 12021409, upload-time = "2026-06-27T00:27:09.881Z" }, + { url = "https://files.pythonhosted.org/packages/6d/30/69487c439dd1fad3a4a3d96f0a472193de297eaba6fc4b8ea687ce434ac2/ty-0.0.55-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:636e8e5078787b8c6916c94e1406719f10189a4ca6b37b813a5922ce5857a8c7", size = 12303807, upload-time = "2026-06-27T00:27:11.986Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ca/cd88b6493dafc7db077f5e17c0438eb3af6e2d6d08f616dbb52a8ddfd567/ty-0.0.55-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ef7d6deaacb73fec603666b5471f1dc5a5699aa84e11a6d4d644dd07ca72121e", size = 11441263, upload-time = "2026-06-27T00:27:14.087Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fe/66b6915671653ab739f71e4f1b0528e69da64429b7ebf3840c625b6e43f2/ty-0.0.55-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9aeea0fe5875d3cf37faf0e44d0fdf9669335467749741b8fc0103916fb5cd32", size = 11484584, upload-time = "2026-06-27T00:27:16.311Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4f/7a9c0bbac8b899e9f6c0ec110c6612f52e4db35f6bb17ddc0ef60384fa3e/ty-0.0.55-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0b699c01310dbd2705a07c97c5f4aaeedef61bd9adeea2e7c46aed32401d3576", size = 11759309, upload-time = "2026-06-27T00:27:18.471Z" }, + { url = "https://files.pythonhosted.org/packages/ca/de/b6f8b1b69aa631b5716ef3f985c3b56de0e46c2499cc00d30c402b41f714/ty-0.0.55-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:32cbeba543e46de2a983ec6d525d8b56514f7422bd1e1b57c44ccf7bfa72c38a", size = 12128755, upload-time = "2026-06-27T00:27:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/a912531e51ee7e076b42972479290fa687c0f5e747b7e773f3033164acaa/ty-0.0.55-py3-none-win32.whl", hash = "sha256:52b968e24eb4f7a5c3bd251db1f99f60dd385890356d38fc619d84f1b423446a", size = 11117501, upload-time = "2026-06-27T00:27:22.714Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/99d59843bf8908a7f9f4d13fda107dbad07b7faa28ecd7860eacf363fb1c/ty-0.0.55-py3-none-win_amd64.whl", hash = "sha256:bf39cbfdc0add44d94bd3fff1f53c351418d134b6a66b87efdb7876d7b7a2224", size = 12150106, upload-time = "2026-06-27T00:27:24.881Z" }, + { url = "https://files.pythonhosted.org/packages/b3/44/20987505cedf2a865b08482f0eabc181fd9599b062964057ec8a128a4296/ty-0.0.55-py3-none-win_arm64.whl", hash = "sha256:f7f3700a9a060e8f1af11e4fb63fafcaf272b041781f4ccdfda2b3b5c6c1e439", size = 11560157, upload-time = "2026-06-27T00:27:27.332Z" }, +] + +[[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 = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +]