diff --git a/.env.template b/.env.template index b5047c95..2966af0d 100644 --- a/.env.template +++ b/.env.template @@ -27,3 +27,33 @@ GEMINI_API_KEY= # Optional Uvicorn bind settings used by start.sh / make run-* HOST=0.0.0.0 PORT=5000 + +# --------------------------------------------------------------------------- +# Benchmark runner (bench/runners/mini_runner.py) credentials. +# Picked up automatically from .env at repo root. +# +# Pick ONE of these provider configs based on your model choice: +# +# 1) Anthropic API (direct): +# ANTHROPIC_API_KEY=sk-ant-... +# # ANTHROPIC_API_BASE is unset → uses api.anthropic.com +# +# 2) Azure AI Foundry's Anthropic-compatible passthrough +# (path /anthropic/v1/messages, x-api-key auth): +# ANTHROPIC_API_KEY= +# ANTHROPIC_API_BASE=https://.services.ai.azure.com/anthropic +# Then: --model anthropic/claude-sonnet-4-5 +# +# 3) GitHub Models (free tier, 8K-16K context cap on personal plans): +# GITHUB_API_KEY=$(gh auth token) +# GITHUB_API_BASE=https://models.github.ai/inference +# Then: --model github/openai/gpt-4o-mini +# +# 4) Azure OpenAI (chat completions API, not Anthropic): +# AZURE_API_KEY= +# AZURE_API_BASE=https://.openai.azure.com +# AZURE_API_VERSION=2024-10-21 +# Then: --model azure/ +# --------------------------------------------------------------------------- +# ANTHROPIC_API_KEY= +# ANTHROPIC_API_BASE= diff --git a/.github/workflows/mcp-tests.yml b/.github/workflows/mcp-tests.yml new file mode 100644 index 00000000..f0ae1292 --- /dev/null +++ b/.github/workflows/mcp-tests.yml @@ -0,0 +1,76 @@ +name: MCP tests + +on: + push: + branches: ["main", "staging", "mcp/**"] + paths: + - "api/mcp/**" + - "tests/mcp/**" + - "api/llm.py" + - "api/graph.py" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/mcp-tests.yml" + pull_request: + paths: + - "api/mcp/**" + - "tests/mcp/**" + - "api/llm.py" + - "api/graph.py" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/mcp-tests.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + mcp-tests: + runs-on: ubuntu-latest + + services: + falkordb: + image: falkordb/falkordb:latest + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 12 + + env: + FALKORDB_HOST: localhost + FALKORDB_PORT: "6379" + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Setup Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + with: + version: "latest" + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Install backend dependencies + run: uv sync --all-extras + + - name: Verify FalkorDB reachable + run: | + sudo apt-get update -qq && sudo apt-get install -y redis-tools + redis-cli -h localhost -p 6379 ping + + - name: Run MCP test suite + run: uv run pytest tests/mcp/ -v diff --git a/.gitignore b/.gitignore index b7476d0a..49aaee68 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,8 @@ htmlcov/ pytest_cache/ *.log repositories/ + +# bench: SWE-bench harness output (regrade reports + per-instance logs) +bench/cache/verify/ +logs/run_evaluation/ +code-graph-bench.*.json diff --git a/AGENTS.md b/AGENTS.md index 1b2679b9..608680c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,3 +154,26 @@ cgraph info [--repo ] # Repo stats + metadata ``` `--repo` defaults to the current directory name. Claude Code skill in `skills/code-graph/`. + +## MCP server (for agents) + +`cgraph-mcp` exposes the code graph over MCP stdio. Eight tools: +`index_repo`, `search_code`, `get_callers`, `get_callees`, +`get_dependencies`, `impact_analysis`, `find_path`, `ask`. + +Drop the canonical agent guidance into any repo: + +```bash +cgraph init-agent # writes CLAUDE.md + .cursorrules +cgraph init-agent --force # overwrite existing files +``` + +See `api/mcp/templates/claude_mcp_section.md` for the full tool table +and rules of thumb (start with `search_code`; prefer structural tools +over `ask`; run `impact_analysis` before refactoring). + +Environment: + +- `CODE_GRAPH_AUTO_INDEX=true` — auto-index CWD on MCP startup. +- `CGRAPH_MODE=mcp` — run `cgraph-mcp` instead of the FastAPI web + server when using the Docker image. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..072acea8 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,136 @@ +# Benchmark glossary (CONTEXT.md) + +Scope: glossary for the **benchmark workstream** (`bench/` and related +changes). Not a project-wide glossary for code-graph. + +## Terms + +### Agent +The autonomous loop that reads a task, calls tools, edits code, and +submits a result. We adopt **mini-swe-agent** (SWE-agent project's +recommended minimal harness) as the agent. The original SWE-agent +is **not** used: upstream now points users at mini- instead, and its +bash-only tool surface is a much smaller, more transparent +integration. The agent loop is fixed across all configs. + +### Config +One of `baseline`, `lsp`, `code-graph`. A config is **fully defined by +its `system_preamble.md` plus the `PATH` it exposes to the agent's +bash**. Same model, same scaffolding template, same step/cost limits +across all three. (mini-swe-agent has no per-config `tools.yaml` +because bash is the only tool; the per-config `tools.yaml` files in +the repo are kept as design documentation.) + +### baseline (config) +mini-swe-agent's stock bash environment — `cat`, `grep`, `find`, +`sed`, `git`, the agent's own implicit submit protocol. **Not +"zero tools"** — an LLM with no filesystem access is not a useful +comparison. + +### lsp (config) +`baseline` + an `lsp` command on PATH that wraps multilspy/jedi +(`goto-definition`, `find-references`, `hover`, `document-symbols`), +each shaped by the LSP response shim (see below). The plan originally +specified pyright + `workspace_symbols`; we run **jedi-language-server** +(what the pinned multilspy fork ships) and drop `workspace_symbols` +(the fork doesn't implement `request_workspace_symbol`). The shim +normalizes responses so jedi-vs-pyright does not affect the validity +comparison; agent falls back to bash+grep for workspace-wide symbol +search. + +### code-graph (config) +`baseline` + a `cg` command on PATH that talks to the code-graph +HTTP service: `graph-entities`, `get-neighbors`, `find-paths`, +`auto-complete`, `find-symbol`, plus `note-edit`. The GraphRAG `chat` +endpoint is **excluded** to avoid nested-agent token double-counting. + +### Accuracy +The SWE-bench end-to-end metric: did the agent's patch pass the repo's +test suite? Only accuracy number reported. We considered an intrinsic +retrieval diagnostic and dropped it. + +### Token cost +LLM input + LLM output tokens summed across one agent session for one +task. Always reported as median, p90, and **Δ vs baseline**. + +### Indexing cost +Wall-clock seconds and any LLM tokens spent to build the FalkorDB graph +for a `@` pair. Reported **separately** and **never +combined** with per-task token cost. The writeup states the amortization +break-even task count, no fake math. + +### Task +One instance from SWE-bench Verified — `(repo, base-commit, issue, +gold-patch, tests)`. + +### Run +One execution of (config × task). We report **pass@1 at temperature 0**. +Failed runs are re-tried 2× more to filter stochastic failures; the +re-tries never change a pass into a fail. + +### Indexed pair +A `@` for which a FalkorDB graph has been built. Cache +key. No incremental indexing across commits. + +### Tool service architecture +mini-swe-agent runs each step as `subprocess.run` in a configured cwd +(the prepared repo working tree). **Tools live on the host** (local +process model): multilspy/jedi runs in-process via the `lsp` CLI +wrapper; code-graph is reached via an HTTP client (`cg` CLI wrapper) +to the FastAPI + FalkorDB service. The runner sets `PATH` so the +agent sees `bench/cli/` only for configs that include those tools +(baseline gets the unmodified host `PATH`). code-graph's graph is +built once per `@` and would otherwise go stale on agent +edits, so the code-graph bundle includes a `cg note-edit PATH` tool +that triggers a **single-file incremental re-index** of the touched +file. This keeps fairness with the live-by-default LSP. + +### LSP response shim +Raw LSP responses are too verbose for a fair token-cost comparison +(`find_references` can return hundreds of locations; `hover` can return +multi-paragraph markdown). The `lsp` config wraps every pyright tool in +a thin adapter (`bench/tools/lsp/adapter.py`) that: + +- Caps result lists at **50** entries. Further results behind a `page` + arg on the same tool. +- Strips `hover` markdown to the **first signature line + first + docstring sentence**. Full hover available via an opt-in + `hover_full`. +- Returns locations as `{path, line, col}`, not the raw LSP `Range`. + +The shim is identical across all LSP runs. We do **not** run a +raw-LSP comparison. + +### Preambles +Each config gets a single-paragraph **symmetric preamble** introducing +its toolkit. The preambles are committed to +`bench/tools//system_preamble.md` and reviewed as artifacts. +Before headline runs we sanity-check phrasing sensitivity by re-running +one config with 2-3 alternative phrasings; if pass rate moves by >5%, +the preambles are dominating signal and we revisit. + +### Rollout +Three-stage: + +1. **Smoke** — 3 hand-picked tasks × 3 configs × 1 run = 9 sessions. + Verify harness, token accounting, indexing path, tool plumbing. +2. **Calibration** — 10 random Verified tasks × 3 configs × 1 run = 30 + sessions. Verify preamble phrasing sensitivity, shim behavior. +3. **Headline** — remaining 40 of the 50-task sample × 3 configs × + pass@1 with retry-2x-on-fail. + +### Dataset +**50-task random sample from SWE-bench Verified** (500-task split). +Random seed committed to `bench/configs/default.yaml`. If the headline +Δ between configs is <10 percentage points, we expand to 150 tasks +before publishing — the 50-task sample's confidence interval is roughly +±7 pp. + +## Conventions + +- `bench/` is the top-level directory for the workstream. +- Results are JSONL, one row per `(task_id, config, run_idx)`, with + token counts pulled from the mini-swe-agent trajectory JSON + (`agent.serialize()` — `messages[*].extra.response.usage`). +- The opencode track and RepoBench track are **not** part of this + workstream (dropped during the grill). diff --git a/Dockerfile b/Dockerfile index d2f4ba7b..f19f6656 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,9 @@ COPY --from=node-base /usr/local/bin/node /usr/local/bin/node COPY --from=node-base /usr/local/lib/node_modules /usr/local/lib/node_modules # Install netcat for wait loop in start.sh and system build tools -RUN apt-get update && apt-get install -y --no-install-recommends \ +RUN apt-get update \ + && apt-get install -y -f \ + && apt-get install -y --no-install-recommends \ netcat-openbsd \ git \ build-essential \ diff --git a/README.md b/README.md index e2e54bb0..417209ec 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,48 @@ npx skills add FalkorDB/code-graph Then ask Claude things like *"what functions call analyze_sources?"* or *"find the dependency chain between parse_config and send_request"* — it will handle the indexing and querying automatically. +### MCP server (`cgraph-mcp`) + +For agents that speak the [Model Context Protocol](https://modelcontextprotocol.io) +(Claude Code, Cursor, Cline, …), code-graph ships a stdio MCP server +that exposes the knowledge graph as 8 first-class tools: `index_repo`, +`search_code`, `get_callers`, `get_callees`, `get_dependencies`, +`impact_analysis`, `find_path`, and `ask` (NL→Cypher via GraphRAG). + +Quickstart — Claude Code: + +```bash +# 1. Install (in any venv with the cgraph package on PATH) +pip install code-graph # or: uv pip install code-graph + +# 2. Register with Claude Code +claude mcp add-json code-graph '{ + "command": "cgraph-mcp", + "env": { + "FALKORDB_HOST": "localhost", + "FALKORDB_PORT": "6379", + "CODE_GRAPH_AUTO_INDEX": "true" + } +}' + +# 3. Drop agent guidance into your repo +cd /path/to/your/repo +cgraph init-agent # writes CLAUDE.md and .cursorrules +``` + +Quickstart — Docker Compose: + +```bash +docker compose up -d falkordb # start the DB +docker compose --profile mcp run --rm -i code-graph-mcp # attach via stdio +``` + +The MCP server auto-bootstraps FalkorDB if it's missing on localhost +(via `cgraph ensure-db`). When `CODE_GRAPH_AUTO_INDEX=true` is set, +the current working directory is indexed automatically on start. + +**Transport:** Phase 1 is stdio only. HTTP/SSE is deferred. + ## Running with Docker ### Using Docker Compose @@ -232,11 +274,19 @@ docker compose up --build This starts FalkorDB and the CodeGraph app together. The checked-in compose file sets `CODE_GRAPH_PUBLIC=1` for the app service. +To run the **MCP stdio server** instead of the web app from the same +image, set `CGRAPH_MODE=mcp` and use the `mcp` profile: + +```bash +docker compose --profile mcp run --rm -i code-graph-mcp +``` + ### Using Docker directly ```bash docker build -t code-graph . +# Web mode (default) docker run -p 5000:5000 \ -e FALKORDB_HOST=host.docker.internal \ -e FALKORDB_PORT=6379 \ @@ -244,6 +294,14 @@ docker run -p 5000:5000 \ -e GEMINI_API_KEY= \ -e SECRET_TOKEN= \ code-graph + +# MCP stdio mode (same image) +docker run --rm -i \ + -e CGRAPH_MODE=mcp \ + -e FALKORDB_HOST=host.docker.internal \ + -e FALKORDB_PORT=6379 \ + -e MODEL_NAME=gemini/gemini-flash-lite-latest \ + code-graph ``` ## Creating a Code Graph diff --git a/api/analyzers/analyzer.py b/api/analyzers/analyzer.py index 33ca5a2b..10b03f8f 100644 --- a/api/analyzers/analyzer.py +++ b/api/analyzers/analyzer.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import Optional -from tree_sitter import Language, Node, Parser, Point, QueryCursor +from tree_sitter import Language, Node, Parser, Point, Query, QueryCursor from api.entities.entity import Entity from api.entities.file import File from abc import ABC, abstractmethod @@ -11,11 +11,20 @@ class AbstractAnalyzer(ABC): def __init__(self, language: Language) -> None: self.language = language self.parser = Parser(language) + # Memoise compiled queries; tree-sitter query compilation is ~370us + # each and adds up to seconds on large repos. + self._query_cache: dict[str, Query] = {} + + def _get_query(self, pattern: str) -> Query: + q = self._query_cache.get(pattern) + if q is None: + q = Query(self.language, pattern) + self._query_cache[pattern] = q + return q def _captures(self, pattern: str, node: Node) -> dict: """Run a tree-sitter query and return captures dict.""" - query = self.language.query(pattern) - cursor = QueryCursor(query) + cursor = QueryCursor(self._get_query(pattern)) return cursor.captures(node) def find_parent(self, node: Node, parent_types: list) -> Node: @@ -55,9 +64,25 @@ def resolve_path(self, file_path: str, path: Path) -> str: def resolve(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[tuple[File, Node]]: try: locations = lsp.request_definition(str(file_path), node.start_point.row, node.start_point.column) - return [(files[Path(self.resolve_path(location['absolutePath'], path))], files[Path(self.resolve_path(location['absolutePath'], path))].tree.root_node.descendant_for_point_range(Point(location['range']['start']['line'], location['range']['start']['character']), Point(location['range']['end']['line'], location['range']['end']['character']))) for location in locations if location and Path(self.resolve_path(location['absolutePath'], path)) in files] - except Exception: + return [(files[Path(self.resolve_path(location['absolutePath'], path))], files[Path(self.resolve_path(location['absolutePath'], path))].tree.root_node.descendant_for_point_range(Point(location['range']['start']['line'], location['range']['start']['character']), Point(location['range']['end']['line'], location['range']['end']['character'])) ) for location in locations if location and Path(self.resolve_path(location['absolutePath'], path)) in files] + except Exception as e: + import logging + logging.getLogger(__name__).warning( + "resolve() failed for %s @%d:%d: %s", + file_path, node.start_point.row, node.start_point.column, e, + ) return [] + + def needs_lsp(self) -> bool: + """Whether this analyzer needs an LSP server started in second_pass. + + Defaults to True for backward compatibility with the original + jedi/multilspy-backed analyzers. Subclasses that resolve symbols + statically (e.g. the tree-sitter resolver in #689) override to + return False so the orchestrator can skip the expensive LSP + warm-up. + """ + return True @abstractmethod def add_dependencies(self, path: Path, files: list[Path]): diff --git a/api/analyzers/javascript/analyzer.py b/api/analyzers/javascript/analyzer.py index abc2879f..d5833cb9 100644 --- a/api/analyzers/javascript/analyzer.py +++ b/api/analyzers/javascript/analyzer.py @@ -3,10 +3,8 @@ from pathlib import Path from typing import Optional -from multilspy import SyncLanguageServer from ...entities.entity import Entity -from ...entities.file import File -from ..analyzer import AbstractAnalyzer +from ..tree_sitter_base import TreeSitterAnalyzer import tree_sitter_javascript as tsjs from tree_sitter import Language, Node @@ -15,13 +13,28 @@ logger = logging.getLogger('code_graph') -class JavaScriptAnalyzer(AbstractAnalyzer): +class JavaScriptAnalyzer(TreeSitterAnalyzer): """Analyzer for JavaScript source files using tree-sitter. Extracts functions, classes, and methods from JavaScript code. Resolves class inheritance (extends) and function/method call references. """ + entity_node_types = { + 'function_declaration': "Function", + 'class_declaration': "Class", + 'method_definition': "Method", + } + type_definition_node_types = ('class_declaration',) + callable_definition_node_types = ( + 'function_declaration', + 'method_definition', + 'class_declaration', + ) + callable_exclude_node_types = ('class_declaration',) + type_resolution_keys = ("base_class",) + method_resolution_keys = ("call",) + def __init__(self) -> None: """Initialize the JavaScript analyzer with the tree-sitter JS grammar.""" super().__init__(Language(tsjs.language())) @@ -33,26 +46,6 @@ def add_dependencies(self, path: Path, files: list[Path]) -> None: """ pass - def get_entity_label(self, node: Node) -> str: - """Return the graph label for a given AST node type. - - Args: - node: A tree-sitter AST node representing a JavaScript entity. - - Returns: - One of 'Function', 'Class', or 'Method'. - - Raises: - ValueError: If the node type is not a recognised entity. - """ - if node.type == 'function_declaration': - return "Function" - elif node.type == 'class_declaration': - return "Class" - elif node.type == 'method_definition': - return "Method" - raise ValueError(f"Unknown entity type: {node.type}") - def get_entity_name(self, node: Node) -> str: """Extract the declared name from a JavaScript entity node. @@ -92,10 +85,6 @@ def get_entity_docstring(self, node: Node) -> Optional[str]: return None raise ValueError(f"Unknown entity type: {node.type}") - def get_entity_types(self) -> list[str]: - """Return the tree-sitter node types recognised as JavaScript entities.""" - return ['function_declaration', 'class_declaration', 'method_definition'] - def add_symbols(self, entity: Entity) -> None: """Extract symbols (references) from a JavaScript entity. @@ -128,45 +117,12 @@ def is_dependency(self, file_path: str) -> bool: """ return "node_modules" in Path(file_path).parts - def resolve_path(self, file_path: str, path: Path) -> str: - """Resolve an import path relative to the project root.""" - return file_path - - def resolve_type(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]: - """Resolve a type reference to its class declaration entity.""" - res = [] - for file, resolved_node in self.resolve(files, lsp, file_path, path, node): - type_dec = self.find_parent(resolved_node, ['class_declaration']) - if type_dec in file.entities: - res.append(file.entities[type_dec]) - return res - - def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]: - """Resolve a call expression to the target function or method entity.""" - res = [] + def _extract_call_target(self, node: Node) -> Optional[Node]: + """Extract the callable target from a JavaScript call expression.""" if node.type == 'call_expression': func_node = node.child_by_field_name('function') if func_node and func_node.type == 'member_expression': func_node = func_node.child_by_field_name('property') if func_node: node = func_node - for file, resolved_node in self.resolve(files, lsp, file_path, path, node): - method_dec = self.find_parent(resolved_node, ['function_declaration', 'method_definition', 'class_declaration']) - if method_dec and method_dec.type == 'class_declaration': - continue - if method_dec in file.entities: - res.append(file.entities[method_dec]) - return res - - def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> list[Entity]: - """Dispatch symbol resolution based on the symbol category. - - Routes ``base_class`` symbols to type resolution and ``call`` symbols - to method resolution. - """ - if key == "base_class": - return self.resolve_type(files, lsp, file_path, path, symbol) - elif key == "call": - return self.resolve_method(files, lsp, file_path, path, symbol) - else: - raise ValueError(f"Unknown key {key}") + return node diff --git a/api/analyzers/kotlin/analyzer.py b/api/analyzers/kotlin/analyzer.py index 3758c302..208a51af 100644 --- a/api/analyzers/kotlin/analyzer.py +++ b/api/analyzers/kotlin/analyzer.py @@ -2,7 +2,7 @@ from ...entities.entity import Entity from ...entities.file import File from typing import Optional -from ..analyzer import AbstractAnalyzer +from ..tree_sitter_base import TreeSitterAnalyzer from multilspy import SyncLanguageServer @@ -12,7 +12,27 @@ import logging logger = logging.getLogger('code_graph') -class KotlinAnalyzer(AbstractAnalyzer): +class KotlinAnalyzer(TreeSitterAnalyzer): + entity_node_types = { + 'class_declaration': "Class", + 'object_declaration': "Object", + 'function_declaration': "Function", + } + type_definition_node_types = ('class_declaration', 'object_declaration') + callable_definition_node_types = ( + 'function_declaration', + 'class_declaration', + 'object_declaration', + ) + callable_exclude_node_types = ('class_declaration', 'object_declaration') + type_resolution_keys = ( + "implement_interface", + "base_class", + "parameters", + "return_type", + ) + method_resolution_keys = ("call",) + def __init__(self) -> None: super().__init__(Language(tskotlin.language())) @@ -44,7 +64,7 @@ def get_entity_name(self, node: Node) -> str: if child.type == 'identifier': return child.text.decode('utf-8') raise ValueError(f"Cannot extract name from entity type: {node.type}") - + def get_entity_docstring(self, node: Node) -> Optional[str]: if node.type in ['class_declaration', 'object_declaration', 'function_declaration']: # Check for KDoc comment (/** ... */) before the node @@ -54,14 +74,11 @@ def get_entity_docstring(self, node: Node) -> Optional[str]: if comment_text.startswith('/**'): return comment_text return None - raise ValueError(f"Unknown entity type: {node.type}") + raise ValueError(f"Unknown entity type: {node.type}") - def get_entity_types(self) -> list[str]: - return ['class_declaration', 'object_declaration', 'function_declaration'] - def _get_delegation_types(self, entity: Entity) -> list[tuple]: """Extract type identifiers from delegation specifiers in order. - + Returns list of (node, is_constructor_invocation) tuples. constructor_invocation indicates a superclass; plain user_type indicates an interface. """ @@ -91,25 +108,25 @@ def add_symbols(self, entity: Entity) -> None: entity.add_symbol("base_class", node) else: entity.add_symbol("implement_interface", node) - + elif entity.node.type == 'object_declaration': types = self._get_delegation_types(entity) for node, _ in types: entity.add_symbol("implement_interface", node) - + elif entity.node.type == 'function_declaration': # Find function calls captures = self._captures("(call_expression) @reference.call", entity.node) if 'reference.call' in captures: for caller in captures['reference.call']: entity.add_symbol("call", caller) - + # Find parameters with types captures = self._captures("(parameter (user_type (identifier) @parameter))", entity.node) if 'parameter' in captures: for parameter in captures['parameter']: entity.add_symbol("parameters", parameter) - + # Find return type captures = self._captures("(function_declaration (user_type (identifier) @return_type))", entity.node) if 'return_type' in captures: @@ -120,18 +137,6 @@ def is_dependency(self, file_path: str) -> bool: # Check if file is in a dependency directory (e.g., build, .gradle cache) return "build/" in file_path or ".gradle/" in file_path or "/cache/" in file_path - def resolve_path(self, file_path: str, path: Path) -> str: - # For Kotlin, just return the file path as-is for now - return file_path - - def resolve_type(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]: - res = [] - for file, resolved_node in self.resolve(files, lsp, file_path, path, node): - type_dec = self.find_parent(resolved_node, ['class_declaration', 'object_declaration']) - if type_dec in file.entities: - res.append(file.entities[type_dec]) - return res - def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]: res = [] # For call expressions, we need to extract the function name @@ -147,11 +152,3 @@ def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_ res.append(file.entities[method_dec]) break return res - - def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> list[Entity]: - if key in ["implement_interface", "base_class", "parameters", "return_type"]: - return self.resolve_type(files, lsp, file_path, path, symbol) - elif key in ["call"]: - return self.resolve_method(files, lsp, file_path, path, symbol) - else: - raise ValueError(f"Unknown key {key}") diff --git a/api/analyzers/python/analyzer.py b/api/analyzers/python/analyzer.py index 7a991202..8cdfe96e 100644 --- a/api/analyzers/python/analyzer.py +++ b/api/analyzers/python/analyzer.py @@ -1,12 +1,16 @@ import os import subprocess -from multilspy import SyncLanguageServer from pathlib import Path import tomllib -from ...entities import * from typing import Optional -from ..analyzer import AbstractAnalyzer + +from multilspy import SyncLanguageServer + +from ...entities.entity import Entity +from ...entities.file import File +from ..tree_sitter_base import TreeSitterAnalyzer +from .ts_resolver import TreeSitterPythonResolver import tree_sitter_python as tspython from tree_sitter import Language, Node @@ -14,11 +18,65 @@ import logging logger = logging.getLogger('code_graph') -class PythonAnalyzer(AbstractAnalyzer): + +_RESOLVER_ENV = "CODE_GRAPH_PY_RESOLVER" +_RESOLVER_TREE_SITTER = "tree_sitter" + + +class PythonAnalyzer(TreeSitterAnalyzer): + entity_node_types = { + 'class_definition': "Class", + 'function_definition': "Function", + } + type_definition_node_types = ('class_definition',) + callable_definition_node_types = ('function_definition', 'class_definition') + type_resolution_keys = ("base_class", "parameters", "return_type") + method_resolution_keys = ("call",) + def __init__(self) -> None: super().__init__(Language(tspython.language())) - + # Resolver selection: 'tree_sitter' opts into the static project-wide + # resolver (issue #689). Default is the historical jedi/LSP path so + # behaviour is unchanged until explicitly enabled. + resolver_choice = os.environ.get(_RESOLVER_ENV, "").strip().lower() + if resolver_choice == _RESOLVER_TREE_SITTER: + self._ts_resolver: Optional[TreeSitterPythonResolver] = ( + TreeSitterPythonResolver(self.language) + ) + logger.info("PythonAnalyzer: tree-sitter static resolver enabled") + else: + self._ts_resolver = None + + def resolve( + self, + files: dict[Path, File], + lsp: SyncLanguageServer, + file_path: Path, + path: Path, + node: Node, + ) -> list[tuple[File, Node]]: + """Resolve a name node to ``(File, def_node)`` pairs. + + When ``CODE_GRAPH_PY_RESOLVER=tree_sitter`` is set, bypass the LSP + and use the project-wide static resolver. Otherwise fall through to + the default jedi-backed implementation in ``AbstractAnalyzer``. + """ + if self._ts_resolver is not None: + return self._ts_resolver.resolve(files, file_path, path, node) + return super().resolve(files, lsp, file_path, path, node) + + def needs_lsp(self) -> bool: + # When the tree-sitter resolver is active we don't touch the LSP, so + # the orchestrator can skip starting one. + return self._ts_resolver is None + def add_dependencies(self, path: Path, files: list[Path]): + # When the tree-sitter resolver is active, we resolve statically + # against the in-project files only — installing the project's + # transitive Python deps just to feed jedi adds 10s–10min of + # zero-value pip work. Short-circuit it. + if self._ts_resolver is not None: + return if Path(f"{path}/venv").is_dir(): return subprocess.run(["python3", "-m", "venv", "venv"], cwd=str(path)) @@ -40,18 +98,11 @@ def add_dependencies(self, path: Path, files: list[Path]): for requirement in requirements: files.extend(Path(f"{path}/venv/lib/").rglob(f"**/site-packages/{requirement}/*.py")) - def get_entity_label(self, node: Node) -> str: - if node.type == 'class_definition': - return "Class" - elif node.type == 'function_definition': - return "Function" - raise ValueError(f"Unknown entity type: {node.type}") - def get_entity_name(self, node: Node) -> str: if node.type in ['class_definition', 'function_definition']: return node.child_by_field_name('name').text.decode('utf-8') raise ValueError(f"Unknown entity type: {node.type}") - + def get_entity_docstring(self, node: Node) -> Optional[str]: if node.type in ['class_definition', 'function_definition']: body = node.child_by_field_name('body') @@ -59,11 +110,8 @@ def get_entity_docstring(self, node: Node) -> Optional[str]: docstring_node = body.children[0].child(0) return docstring_node.text.decode('utf-8') return None - raise ValueError(f"Unknown entity type: {node.type}") - - def get_entity_types(self) -> list[str]: - return ['class_definition', 'function_definition'] - + raise ValueError(f"Unknown entity type: {node.type}") + def add_symbols(self, entity: Entity) -> None: if entity.node.type == 'class_definition': superclasses = entity.node.child_by_field_name("superclasses") @@ -88,37 +136,14 @@ def add_symbols(self, entity: Entity) -> None: def is_dependency(self, file_path: str) -> bool: return "venv" in file_path - def resolve_path(self, file_path: str, path: Path) -> str: - return file_path - - def resolve_type(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path, node: Node) -> list[Entity]: - res = [] + def _extract_type_target(self, node: Node) -> Optional[Node]: if node.type == 'attribute': - node = node.child_by_field_name('attribute') - for file, resolved_node in self.resolve(files, lsp, file_path, path, node): - type_dec = self.find_parent(resolved_node, ['class_definition']) - if type_dec in file.entities: - res.append(file.entities[type_dec]) - return res - - def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]: - res = [] + return node.child_by_field_name('attribute') + return node + + def _extract_call_target(self, node: Node) -> Optional[Node]: if node.type == 'call': node = node.child_by_field_name('function') - if node.type == 'attribute': + if node and node.type == 'attribute': node = node.child_by_field_name('attribute') - for file, resolved_node in self.resolve(files, lsp, file_path, path, node): - method_dec = self.find_parent(resolved_node, ['function_definition', 'class_definition']) - if not method_dec: - continue - if method_dec in file.entities: - res.append(file.entities[method_dec]) - return res - - def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> list[Entity]: - if key in ["base_class", "parameters", "return_type"]: - return self.resolve_type(files, lsp, file_path, path, symbol) - elif key in ["call"]: - return self.resolve_method(files, lsp, file_path, path, symbol) - else: - raise ValueError(f"Unknown key {key}") + return node diff --git a/api/analyzers/python/ts_resolver.py b/api/analyzers/python/ts_resolver.py new file mode 100644 index 00000000..d6b60c79 --- /dev/null +++ b/api/analyzers/python/ts_resolver.py @@ -0,0 +1,506 @@ +"""Tree-sitter-based static symbol resolver for Python. + +A drop-in replacement for the jedi/multilspy round-trip used by +``PythonAnalyzer.resolve``. Builds a project-wide symbol table from the +already-parsed tree-sitter trees and answers ``request_definition``-style +queries by static name resolution. + +Selected at runtime via ``CODE_GRAPH_PY_RESOLVER=tree_sitter``. + +The resolver intentionally returns the same shape ``AbstractAnalyzer.resolve`` +returns: a list of ``(File, Node)`` tuples where ``Node`` is the definition's +tree-sitter node in the target file. This keeps the rest of the analyzer +pipeline (``resolve_type`` / ``resolve_method`` walking up to find_parent) +unchanged. + +What we resolve (Python-only): + +* Module-local names (function / class defined in the same file). +* ``from X import Y`` — resolves ``Y`` to a definition in module ``X``. +* ``from X import Y as Z`` — same, addressed by ``Z``. +* ``import X`` then ``X.Y`` — drills the dotted chain through the import map. +* ``import X as Z`` then ``Z.Y`` — same. +* Cross-project fallback by bare-name lookup (matches the rest of the + codebase's tolerance for missing types — jedi returns ``None`` here + ~80% of the time anyway). + +What we don't resolve (matches jedi's miss behavior): + +* Dynamic dispatch (``getattr``, metaclasses, monkey-patching). +* Type inference beyond direct ``x = Foo()`` assignment. +* Star imports. +* Cross-package imports outside the indexed project tree. +""" + +from __future__ import annotations + +import logging +from collections import defaultdict +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +from tree_sitter import Language, Node, QueryCursor + +from api.entities.file import File + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Symbol table data model +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class _Definition: + """A name defined somewhere in the project.""" + + file_path: Path + node: Node + kind: str # 'class' | 'func' | 'method' | 'var' + + +@dataclass +class _ModuleIndex: + """Per-file index of top-level definitions, imports, and class methods.""" + + module: str + file_path: Path + # Top-level name -> Definition + top_level: dict[str, _Definition] = field(default_factory=dict) + # Class name -> { method_name: Definition } + class_methods: dict[str, dict[str, _Definition]] = field(default_factory=dict) + # Local name -> dotted target module path + # ``import os`` -> {'os': 'os'} + # ``import os.path as op`` -> {'op': 'os.path'} + # ``from x.y import z`` -> {'z': 'x.y.z'} + # ``from x.y import z as w`` -> {'w': 'x.y.z'} + imports: dict[str, str] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Tree-sitter queries (compiled once per language instance) +# --------------------------------------------------------------------------- + + +_QUERY_TOP_LEVEL_FUNC = """ +(module (function_definition name: (identifier) @name) @def) +(module (decorated_definition + definition: (function_definition name: (identifier) @name)) @def) +""" + +_QUERY_TOP_LEVEL_CLASS = """ +(module (class_definition name: (identifier) @name) @def) +(module (decorated_definition + definition: (class_definition name: (identifier) @name)) @def) +""" + +_QUERY_TOP_LEVEL_ASSIGN = """ +(module (expression_statement (assignment left: (identifier) @name) @def)) +""" + +_QUERY_CLASS_METHODS = """ +(class_definition + name: (identifier) @class_name + body: (block (function_definition name: (identifier) @method_name) @method_def)) +(class_definition + name: (identifier) @class_name + body: (block (decorated_definition + definition: (function_definition name: (identifier) @method_name) @method_def))) +""" + +# Plain ``import x`` / ``import x.y`` / ``import x as y`` / ``import x.y as z``. +_QUERY_IMPORT = """ +(import_statement) @stmt +""" + +# ``from x import y`` / ``from x import y as z`` / ``from . import y`` / ``from .x import y``. +_QUERY_IMPORT_FROM = """ +(import_from_statement) @stmt +""" + + +class _Queries: + """Compiled tree-sitter queries for a given Language.""" + + def __init__(self, language: Language) -> None: + self.top_level_func = language.query(_QUERY_TOP_LEVEL_FUNC) + self.top_level_class = language.query(_QUERY_TOP_LEVEL_CLASS) + self.top_level_assign = language.query(_QUERY_TOP_LEVEL_ASSIGN) + self.class_methods = language.query(_QUERY_CLASS_METHODS) + self.imports = language.query(_QUERY_IMPORT) + self.imports_from = language.query(_QUERY_IMPORT_FROM) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _path_to_module(file_path: Path, project_root: Path) -> str: + """Convert ``project/pkg/sub/mod.py`` to ``pkg.sub.mod``. + + Returns the file path itself (stringified) if it lives outside the + project root — those files can still hold definitions but their module + name is informational only. + """ + try: + rel = file_path.relative_to(project_root) + except ValueError: + return str(file_path) + parts = list(rel.parts) + if parts and parts[-1].endswith(".py"): + parts[-1] = parts[-1][:-3] + if parts and parts[-1] == "__init__": + parts.pop() + return ".".join(parts) + + +def _dotted_name_text(node: Node) -> str: + """Reconstruct a dotted ``a.b.c`` string from a tree-sitter node.""" + return node.text.decode("utf-8") + + +def _captures(query, root: Node) -> dict[str, list[Node]]: + cursor = QueryCursor(query) + return cursor.captures(root) + + +# --------------------------------------------------------------------------- +# Public resolver +# --------------------------------------------------------------------------- + + +class TreeSitterPythonResolver: + """Project-wide resolver. Build once, query many times. + + The resolver caches the project symbol table keyed on ``id(files)`` — + when the analyzer passes a fresh ``files`` dict (new index run) we + rebuild lazily on the next call. This avoids holding a reference to + the dict across runs. + """ + + def __init__(self, language: Language) -> None: + self._language = language + self._queries = _Queries(language) + self._files_id: Optional[int] = None + self._files: Optional[dict[Path, File]] = None + self._project_root: Optional[Path] = None + # module name -> _ModuleIndex + self._modules: dict[str, _ModuleIndex] = {} + # file path -> module name (reverse lookup) + self._path_to_module: dict[Path, str] = {} + # name -> [_Definition, ...] (cross-project fallback) + self._by_name: dict[str, list[_Definition]] = defaultdict(list) + + # -- build --------------------------------------------------------------- + + def _ensure_built(self, files: dict[Path, File], project_root: Path) -> None: + if self._files_id == id(files) and self._project_root == project_root: + return + self._files_id = id(files) + self._files = files + self._project_root = project_root + self._modules.clear() + self._path_to_module.clear() + self._by_name.clear() + + for file_path, file in files.items(): + if file_path.suffix != ".py" or file.tree is None: + continue + module = _path_to_module(file_path, project_root) + mi = _ModuleIndex(module=module, file_path=file_path) + self._modules[module] = mi + self._path_to_module[file_path] = module + self._index_file(mi, file.tree.root_node) + + def _index_file(self, mi: _ModuleIndex, root: Node) -> None: + # Top-level functions + caps = _captures(self._queries.top_level_func, root) + names = caps.get("name", []) + defs = caps.get("def", []) + for name_node, def_node in zip(names, defs): + name = name_node.text.decode("utf-8") + d = _Definition(mi.file_path, _strip_decorator(def_node), "func") + mi.top_level[name] = d + self._by_name[name].append(d) + + # Top-level classes + caps = _captures(self._queries.top_level_class, root) + names = caps.get("name", []) + defs = caps.get("def", []) + for name_node, def_node in zip(names, defs): + name = name_node.text.decode("utf-8") + d = _Definition(mi.file_path, _strip_decorator(def_node), "class") + mi.top_level[name] = d + self._by_name[name].append(d) + + # Top-level assignments (for class aliases like ``Foo = OtherFoo``) + caps = _captures(self._queries.top_level_assign, root) + names = caps.get("name", []) + defs = caps.get("def", []) + for name_node, def_node in zip(names, defs): + name = name_node.text.decode("utf-8") + if name in mi.top_level: + continue + d = _Definition(mi.file_path, def_node, "var") + mi.top_level[name] = d + self._by_name[name].append(d) + + # Class methods + caps = _captures(self._queries.class_methods, root) + class_names = caps.get("class_name", []) + method_names = caps.get("method_name", []) + method_defs = caps.get("method_def", []) + for cls_node, mname_node, mdef_node in zip(class_names, method_names, method_defs): + class_name = cls_node.text.decode("utf-8") + method_name = mname_node.text.decode("utf-8") + d = _Definition(mi.file_path, _strip_decorator(mdef_node), "method") + mi.class_methods.setdefault(class_name, {})[method_name] = d + self._by_name[method_name].append(d) + + # Imports + self._index_imports(mi, root) + + def _index_imports(self, mi: _ModuleIndex, root: Node) -> None: + # ``import X`` statements + for stmt in _captures(self._queries.imports, root).get("stmt", []): + for child in stmt.named_children: + if child.type == "dotted_name": + name = child.text.decode("utf-8") + # ``import pkg.lib`` binds the *top* package name; users + # access ``pkg.lib.x`` via the package head. Map the head + # to itself so resolution walks pkg → lib → x naturally. + head = name.split(".")[0] + mi.imports[head] = head + elif child.type == "aliased_import": + dotted = child.child_by_field_name("name") + alias = child.child_by_field_name("alias") + if dotted and alias: + mi.imports[alias.text.decode("utf-8")] = dotted.text.decode("utf-8") + + # ``from X import Y`` statements + for stmt in _captures(self._queries.imports_from, root).get("stmt", []): + module_node = stmt.child_by_field_name("module_name") + if module_node is None: + continue + base_module = self._resolve_from_module(module_node, mi.module) + if base_module is None: + continue + # Each import target is a sibling after module_name + for child in stmt.named_children: + if child == module_node: + continue + if child.type == "dotted_name": + name = child.text.decode("utf-8") + short = name.split(".")[-1] + mi.imports[short] = f"{base_module}.{name}" + elif child.type == "aliased_import": + dotted = child.child_by_field_name("name") + alias = child.child_by_field_name("alias") + if dotted and alias: + mi.imports[alias.text.decode("utf-8")] = ( + f"{base_module}.{dotted.text.decode('utf-8')}" + ) + # Wildcard: ignored (matches jedi miss) + + def _resolve_from_module(self, module_node: Node, current_module: str) -> Optional[str]: + """Handle relative imports (``from . import x``) by climbing the package.""" + if module_node.type == "dotted_name": + return module_node.text.decode("utf-8") + if module_node.type == "relative_import": + # Count leading dots; resolve relative to current package. + text = module_node.text.decode("utf-8") + dot_count = 0 + for ch in text: + if ch == ".": + dot_count += 1 + else: + break + tail = text[dot_count:] + base_parts = current_module.split(".") + # `from . import x` from pkg.a -> base = pkg + # `from .. import x` from pkg.a -> base = '' + up = dot_count + base = base_parts[: max(0, len(base_parts) - up)] + if tail: + base.append(tail) + return ".".join(p for p in base if p) or None + return None + + # -- query --------------------------------------------------------------- + + def resolve( + self, + files: dict[Path, File], + file_path: Path, + project_root: Path, + node: Node, + ) -> list[tuple[File, Node]]: + """Resolve ``node`` (an identifier or dotted attribute) to definitions. + + Returns a list of ``(File, def_node)`` tuples matching the shape + produced by ``AbstractAnalyzer.resolve``. + """ + self._ensure_built(files, project_root) + parts = _node_to_dotted_parts(node) + if not parts: + return [] + current_module = self._path_to_module.get(file_path) + candidate_defs = self._lookup(current_module, parts) + out: list[tuple[File, Node]] = [] + for d in candidate_defs: + f = files.get(d.file_path) + if f is None: + continue + out.append((f, d.node)) + return out + + def _lookup(self, current_module: Optional[str], parts: list[str]) -> list[_Definition]: + if not parts: + return [] + head = parts[0] + tail = parts[1:] + + # 1. Local module top-level + if current_module and current_module in self._modules: + mi = self._modules[current_module] + if head in mi.top_level: + return self._walk_tail(mi.top_level[head], tail) + # 2. Local file's imports + if head in mi.imports: + imported = mi.imports[head] + # Append the dotted tail to the imported prefix and look up + # the result as a fully-qualified dotted name. This handles + # both ``from x import y`` (imported='x.y', tail=[]) + # and ``import pkg.lib`` (imported='pkg.lib', tail=['shared']). + full_dotted = ".".join([imported, *tail]) if tail else imported + target_def = self._lookup_dotted(full_dotted) + if target_def is not None: + return [target_def] + # If the imported path itself names a module, allow direct + # top-level lookup against that module. + if imported in self._modules and tail: + mi2 = self._modules[imported] + if tail[0] in mi2.top_level: + return self._walk_tail(mi2.top_level[tail[0]], tail[1:]) + + # 3. Cross-project bare-name fallback + if head in self._by_name: + # If there's a tail, try walking each candidate; otherwise return all hits. + if not tail: + return list(self._by_name[head]) + out = [] + for d in self._by_name[head]: + out.extend(self._walk_tail(d, tail)) + return out + + return [] + + def _lookup_dotted(self, dotted: str) -> Optional[_Definition]: + """Resolve a fully-qualified ``pkg.mod.Name`` to its _Definition.""" + if dotted in self._modules: + # A bare module — there's no single definition, just a namespace. + return None + # Try splitting from the right: longest prefix that's a module, suffix is symbol path. + parts = dotted.split(".") + for split in range(len(parts) - 1, 0, -1): + mod_candidate = ".".join(parts[:split]) + symbol_parts = parts[split:] + if mod_candidate in self._modules: + mi = self._modules[mod_candidate] + if symbol_parts[0] in mi.top_level: + return self._walk_tail_single(mi.top_level[symbol_parts[0]], symbol_parts[1:]) + return None + + def _walk_tail(self, start: _Definition, tail: list[str]) -> list[_Definition]: + """Walk a dotted-attribute tail from a starting definition. Returns list.""" + d = self._walk_tail_single(start, tail) + return [d] if d is not None else [] + + def _walk_tail_single(self, start: _Definition, tail: list[str]) -> Optional[_Definition]: + cur = start + for part in tail: + if cur.kind == "class": + class_name = self._class_name_for_def(cur) + if class_name is None: + return None + mi = self._modules.get(self._path_to_module.get(cur.file_path, "")) + if mi is None: + return None + methods = mi.class_methods.get(class_name, {}) + if part in methods: + cur = methods[part] + continue + return None + # Other kinds: can't drill further statically + return None + return cur + + @staticmethod + def _class_name_for_def(d: _Definition) -> Optional[str]: + if d.kind != "class": + return None + name_node = d.node.child_by_field_name("name") + if name_node is None: + # decorated_definition: drill in + for child in d.node.named_children: + if child.type == "class_definition": + name_node = child.child_by_field_name("name") + break + return name_node.text.decode("utf-8") if name_node else None + + +# --------------------------------------------------------------------------- +# Module helpers +# --------------------------------------------------------------------------- + + +def _strip_decorator(def_node: Node) -> Node: + """If ``def_node`` is a decorated_definition, return its inner definition. + + The rest of the analyzer expects ``class_definition`` / ``function_definition`` + nodes (those are what ``add_symbols`` traverses and what ``find_parent`` + looks for), so we unwrap decorators here. + """ + if def_node.type == "decorated_definition": + for child in def_node.named_children: + if child.type in ("class_definition", "function_definition"): + return child + return def_node + + +def _node_to_dotted_parts(node: Node) -> list[str]: + """Reduce a tree-sitter Python expression to its dotted name parts. + + Returns ``[]`` if the node isn't a name reference we can statically resolve. + """ + if node.type == "identifier": + return [node.text.decode("utf-8")] + if node.type == "attribute": + obj = node.child_by_field_name("object") + attr = node.child_by_field_name("attribute") + if obj is None or attr is None: + return [] + head_parts = _node_to_dotted_parts(obj) + if not head_parts: + return [] + return head_parts + [attr.text.decode("utf-8")] + if node.type == "call": + func = node.child_by_field_name("function") + return _node_to_dotted_parts(func) if func else [] + if node.type in ("subscript", "generic_type"): + # ``Optional[Node]`` / ``dict[Path, File]`` — resolve the outer name. + # tree-sitter-python uses ``generic_type`` for type annotations and + # ``subscript`` for runtime indexing expressions. + if node.type == "subscript": + inner = node.child_by_field_name("value") + else: + inner = node.named_children[0] if node.named_children else None + return _node_to_dotted_parts(inner) if inner else [] + if node.type == "type": + # ``type`` wraps the actual annotation expression. + inner = node.named_children[0] if node.named_children else None + return _node_to_dotted_parts(inner) if inner else [] + return [] diff --git a/api/analyzers/source_analyzer.py b/api/analyzers/source_analyzer.py index 9046abcf..0aa61139 100644 --- a/api/analyzers/source_analyzer.py +++ b/api/analyzers/source_analyzer.py @@ -138,8 +138,24 @@ def second_pass(self, graph: Graph, files: list[Path], path: Path) -> None: lsps[".java"] = SyncLanguageServer.create(config, logger, str(path)) else: lsps[".java"] = NullLanguageServer() - if any(path.rglob('*.py')): - config = MultilspyConfig.from_dict({"code_language": "python", "environment_path": f"{path}/venv"}) + if any(path.rglob('*.py')) and analyzers[".py"].needs_lsp(): + import sys + py_venv = path / "venv" + py_dotvenv = path / ".venv" + if py_venv.is_dir() and (py_venv / "bin" / "python").exists(): + env_path = str(py_venv) + elif py_dotvenv.is_dir() and (py_dotvenv / "bin" / "python").exists(): + env_path = str(py_dotvenv) + else: + env_path = str(Path(sys.executable).resolve().parent.parent) + logging.info( + "No venv at %s; falling back to host env %s for jedi LSP", + path, env_path, + ) + config = MultilspyConfig.from_dict({ + "code_language": "python", + "environment_path": env_path, + }) lsps[".py"] = SyncLanguageServer.create(config, logger, str(path)) else: lsps[".py"] = NullLanguageServer() @@ -155,12 +171,23 @@ def second_pass(self, graph: Graph, files: list[Path], path: Path) -> None: with lsps[".java"].start_server(), lsps[".py"].start_server(), lsps[".cs"].start_server(), lsps[".js"].start_server(), lsps[".kt"].start_server(), lsps[".kts"].start_server(): files_len = len(self.files) for i, file_path in enumerate(files): - if file_path not in self.files: + file = self.files.get(file_path) + if file is None: + # first_pass skipped this file (e.g. parse error, empty, + # or ignored after entering the candidate list). Skip + # in second_pass too instead of crashing the whole index. + logging.warning( + "second_pass: %s not in files map (first_pass skipped it); skipping", + file_path, + ) continue - # Skip symbol resolution when no real LSP is available - if isinstance(lsps.get(file_path.suffix), NullLanguageServer): + analyzer = analyzers.get(file_path.suffix) + # Skip symbol resolution when no real LSP is available *and* the + # analyzer can't resolve statically (e.g. tree-sitter resolver). + if isinstance(lsps.get(file_path.suffix), NullLanguageServer) and ( + analyzer is None or analyzer.needs_lsp() + ): continue - file = self.files[file_path] logging.info(f'Processing file ({i + 1}/{files_len}): {file_path}') for _, entity in file.entities.items(): entity.resolved_symbol(lambda key, symbol, fp=file_path: analyzers[fp.suffix].resolve_symbol(self.files, lsps[fp.suffix], fp, path, key, symbol)) @@ -208,21 +235,26 @@ def analyze_local_folder(self, path: str, g: Graph, ignore: Optional[list[str]] logging.info("Done analyzing path") - def analyze_local_repository(self, path: str, ignore: Optional[list[str]] = None) -> Graph: + def analyze_local_repository(self, path: str, ignore: Optional[list[str]] = None, branch: Optional[str] = None) -> Graph: """ Analyze a local Git repository. Args: path (str): Path to a local git repository ignore (List(str)): List of paths to skip + branch (Optional[str]): Branch name. Auto-detected from the + checkout when ``None``. """ if ignore is None: ignore = [] from pygit2.repository import Repository + from ..project import detect_branch proj_name = Path(path).name - graph = Graph(proj_name) + if branch is None: + branch = detect_branch(Path(path)) + graph = Graph(proj_name, branch=branch) self.analyze_local_folder(path, graph, ignore) # Save processed commit hash to the DB diff --git a/api/analyzers/tree_sitter_base.py b/api/analyzers/tree_sitter_base.py new file mode 100644 index 00000000..459ccc38 --- /dev/null +++ b/api/analyzers/tree_sitter_base.py @@ -0,0 +1,107 @@ +"""Shared base class for tree-sitter-backed analyzers.""" + +from pathlib import Path +from typing import Optional + +from multilspy import SyncLanguageServer +from tree_sitter import Node + +from api.entities.entity import Entity +from api.entities.file import File + +from .analyzer import AbstractAnalyzer + + +class TreeSitterAnalyzer(AbstractAnalyzer): + """Base implementation for analyzers that use tree-sitter plus LSP resolution. + + Subclasses declare the node types they treat as graph entities and the symbol + keys that resolve to type or callable definitions. Language-specific AST + normalization can be implemented by overriding the target-extraction hooks. + """ + + entity_node_types: dict[str, str] = {} + type_definition_node_types: tuple[str, ...] = () + callable_definition_node_types: tuple[str, ...] = () + callable_exclude_node_types: tuple[str, ...] = () + type_resolution_keys: tuple[str, ...] = () + method_resolution_keys: tuple[str, ...] = () + + def resolve_path(self, file_path: str, path: Path) -> str: + """Resolve an LSP path into the key used by the analyzed file map.""" + return file_path + + def get_entity_types(self) -> list[str]: + """Return the tree-sitter node types recognized as graph entities.""" + return list(self.entity_node_types.keys()) + + def get_entity_label(self, node: Node) -> str: + """Return the graph label for an entity node declared by the subclass.""" + try: + return self.entity_node_types[node.type] + except KeyError as exc: + raise ValueError(f"Unknown entity type: {node.type}") from exc + + def resolve_symbol( + self, + files: dict[Path, File], + lsp: SyncLanguageServer, + file_path: Path, + path: Path, + key: str, + symbol: Node, + ) -> list[Entity]: + """Dispatch a captured symbol to type or callable resolution.""" + if key in self.type_resolution_keys: + return self.resolve_type(files, lsp, file_path, path, symbol) + if key in self.method_resolution_keys: + return self.resolve_method(files, lsp, file_path, path, symbol) + raise ValueError(f"Unknown key {key}") + + def _extract_call_target(self, node: Node) -> Optional[Node]: + """Normalize a call symbol before resolving it to a callable definition.""" + return node + + def _extract_type_target(self, node: Node) -> Optional[Node]: + """Normalize a type symbol before resolving it to a type definition.""" + return node + + def resolve_type( + self, + files: dict[Path, File], + lsp: SyncLanguageServer, + file_path: Path, + path: Path, + node: Node, + ) -> list[Entity]: + """Resolve a type reference to matching type-definition entities.""" + res = [] + target = self._extract_type_target(node) + if target is None: + return res + for file, resolved_node in self.resolve(files, lsp, file_path, path, target): + type_dec = self.find_parent(resolved_node, self.type_definition_node_types) + if type_dec in file.entities: + res.append(file.entities[type_dec]) + return res + + def resolve_method( + self, + files: dict[Path, File], + lsp: SyncLanguageServer, + file_path: Path, + path: Path, + node: Node, + ) -> list[Entity]: + """Resolve a call reference to matching callable-definition entities.""" + res = [] + target = self._extract_call_target(node) + if target is None: + return res + for file, resolved_node in self.resolve(files, lsp, file_path, path, target): + method_dec = self.find_parent(resolved_node, self.callable_definition_node_types) + if method_dec and method_dec.type in self.callable_exclude_node_types: + continue + if method_dec in file.entities: + res.append(file.entities[method_dec]) + return res diff --git a/api/auto_complete.py b/api/auto_complete.py index deebc827..8c69bc8b 100644 --- a/api/auto_complete.py +++ b/api/auto_complete.py @@ -1,15 +1,17 @@ +from typing import Optional + from .graph import Graph, AsyncGraphQuery -def prefix_search(repo: str, prefix: str) -> str: +def prefix_search(repo: str, prefix: str, branch: Optional[str] = None) -> str: """ Returns a list of all entities in the repository that start with the given prefix. """ - g = Graph(repo) + g = Graph(repo, branch=branch) return g.prefix_search(prefix) -async def async_prefix_search(repo: str, prefix: str) -> list: +async def async_prefix_search(repo: str, prefix: str, branch: Optional[str] = None) -> list: """Async version of prefix_search using AsyncGraphQuery.""" - g = AsyncGraphQuery(repo) + g = AsyncGraphQuery(repo, branch=branch) try: return await g.prefix_search(prefix) finally: diff --git a/api/cli.py b/api/cli.py index 960f8f39..bf9ed4d6 100644 --- a/api/cli.py +++ b/api/cli.py @@ -172,6 +172,9 @@ def index( repo: Optional[str] = typer.Option( None, "--repo", help="Graph name (defaults to folder name)" ), + branch: Optional[str] = typer.Option( + None, "--branch", help="Branch to associate with this index (auto-detected from git checkout when omitted; '_default' for non-git paths)" + ), ) -> None: """Index a local folder into the knowledge graph.""" from .project import Project @@ -204,14 +207,14 @@ def index( _stderr(f"Indexing {folder} as '{name}'…") try: - project = Project(name, folder, url) + project = Project(name, folder, url, branch=branch) graph = project.analyze_sources(ignore=list(ignore) if ignore else []) stats = graph.stats() except Exception as e: _json_error(str(e)) - _stderr(f"Done — {stats['node_count']} nodes, {stats['edge_count']} edges") - _json_out({"status": "ok", "repo": name, **stats}) + _stderr(f"Done — {stats['node_count']} nodes, {stats['edge_count']} edges (branch={project.branch})") + _json_out({"status": "ok", "repo": name, "branch": project.branch, **stats}) # ── index-repo ───────────────────────────────────────────────────────── @@ -223,6 +226,9 @@ def index_repo( ignore: Optional[List[str]] = typer.Option( None, "--ignore", help="Directories to ignore (repeatable)" ), + branch: Optional[str] = typer.Option( + None, "--branch", help="Branch to associate with this index (auto-detected from the cloned checkout when omitted)" + ), ) -> None: """Clone a git repository and index it into the knowledge graph.""" from .project import Project @@ -233,14 +239,14 @@ def index_repo( import io import contextlib with contextlib.redirect_stdout(io.StringIO()): - project = Project.from_git_repository(url) + project = Project.from_git_repository(url, branch=branch) graph = project.analyze_sources(ignore=list(ignore) if ignore else []) stats = graph.stats() except Exception as e: _json_error(str(e)) - _stderr(f"Done — {stats['node_count']} nodes, {stats['edge_count']} edges") - _json_out({"status": "ok", "repo": project.name, **stats}) + _stderr(f"Done — {stats['node_count']} nodes, {stats['edge_count']} edges (branch={project.branch})") + _json_out({"status": "ok", "repo": project.name, "branch": project.branch, **stats}) # ── list ─────────────────────────────────────────────────────────────── @@ -248,7 +254,7 @@ def index_repo( @app.command("list") def list_repos() -> None: - """List all indexed repositories.""" + """List all indexed (project, branch) pairs.""" from .graph import get_repos try: @@ -259,6 +265,30 @@ def list_repos() -> None: _json_out({"repos": repos}) +# ── migrate ──────────────────────────────────────────────────────────── + + +@app.command("migrate") +def migrate( + dry_run: bool = typer.Option(False, "--dry-run", help="Print actions without performing them"), +) -> None: + """Promote legacy (pre-T17) graphs and Redis keys into the per-branch namespace. + + Renames each legacy ```` graph to ``code::_default``, + each ``{project}_info`` Redis key to ``{project}:_default_info``, and + each ``{project}_git`` graph to ``{project}:_default_git``. Idempotent. + """ + + from .migrations.per_branch import run_migration + + try: + result = run_migration(dry_run=dry_run) + except Exception as e: + _json_error(str(e)) + + _json_out(result) + + # ── search ───────────────────────────────────────────────────────────── @@ -268,18 +298,24 @@ def search( repo: Optional[str] = typer.Option( None, "--repo", help="Repository name (defaults to CWD name)" ), + branch: Optional[str] = typer.Option( + None, "--branch", help="Branch (auto-detected from CWD; '_default' for non-git paths)" + ), ) -> None: """Search for entities by prefix (full-text search).""" from .graph import Graph + from .project import detect_branch name = _default_repo(repo) + if branch is None: + branch = detect_branch(Path.cwd()) try: - g = Graph(name) + g = Graph(name, branch=branch) results = g.prefix_search(query) except Exception as e: _json_error(str(e)) - _json_out({"repo": name, "results": results}) + _json_out({"repo": name, "branch": branch, "results": results}) # ── neighbors ────────────────────────────────────────────────────────── @@ -297,18 +333,24 @@ def neighbors( label: Optional[str] = typer.Option( None, "--label", help="Filter by destination label (e.g. Function, Class)" ), + branch: Optional[str] = typer.Option( + None, "--branch", help="Branch (auto-detected from CWD; '_default' for non-git paths)" + ), ) -> None: """Get neighboring entities of the given node(s).""" from .graph import Graph + from .project import detect_branch name = _default_repo(repo) + if branch is None: + branch = detect_branch(Path.cwd()) try: - g = Graph(name) + g = Graph(name, branch=branch) result = g.get_neighbors(node_ids, rel=rel, lbl=label) except Exception as e: _json_error(str(e)) - _json_out({"repo": name, **result}) + _json_out({"repo": name, "branch": branch, **result}) # ── paths ────────────────────────────────────────────────────────────── @@ -321,18 +363,24 @@ def paths( repo: Optional[str] = typer.Option( None, "--repo", help="Repository name (defaults to CWD name)" ), + branch: Optional[str] = typer.Option( + None, "--branch", help="Branch (auto-detected from CWD; '_default' for non-git paths)" + ), ) -> None: """Find call-chain paths between two nodes.""" from .graph import Graph + from .project import detect_branch name = _default_repo(repo) + if branch is None: + branch = detect_branch(Path.cwd()) try: - g = Graph(name) + g = Graph(name, branch=branch) result = g.find_paths(src, dest) except Exception as e: _json_error(str(e)) - _json_out({"repo": name, "paths": result}) + _json_out({"repo": name, "branch": branch, "paths": result}) # ── info ─────────────────────────────────────────────────────────────── @@ -343,20 +391,68 @@ def info( repo: Optional[str] = typer.Option( None, "--repo", help="Repository name (defaults to CWD name)" ), + branch: Optional[str] = typer.Option( + None, "--branch", help="Branch (auto-detected from CWD; '_default' for non-git paths)" + ), ) -> None: """Show repository statistics and metadata.""" from .graph import Graph from .info import get_repo_info + from .project import detect_branch name = _default_repo(repo) + if branch is None: + branch = detect_branch(Path.cwd()) try: - g = Graph(name) + g = Graph(name, branch=branch) stats = g.stats() - metadata = get_repo_info(name) or {} + metadata = get_repo_info(name, branch) or {} except Exception as e: _json_error(str(e)) - _json_out({"repo": name, **stats, "metadata": metadata}) + _json_out({"repo": name, "branch": branch, **stats, "metadata": metadata}) + + +# ── init-agent ───────────────────────────────────────────────────────── + + +_TEMPLATES_DIR = Path(__file__).parent / "mcp" / "templates" + + +@app.command("init-agent") +def init_agent( + force: bool = typer.Option( + False, "--force", "-f", help="Overwrite existing CLAUDE.md / .cursorrules." + ), +) -> None: + """Drop AI-agent guidance files (CLAUDE.md, .cursorrules) into CWD. + + Copies the canonical code-graph MCP guidance bundled with this + package so any repo can announce the tools to Cursor and Claude + Code with one command. + """ + targets = { + "CLAUDE.md": _TEMPLATES_DIR / "claude_mcp_section.md", + ".cursorrules": _TEMPLATES_DIR / "cursorrules.template", + } + + cwd = Path.cwd() + if not force: + existing = [name for name in targets if (cwd / name).exists()] + if existing: + _json_error( + f"Refusing to overwrite existing files: {', '.join(existing)}. " + "Re-run with --force to clobber." + ) + + written: List[str] = [] + for name, template in targets.items(): + dest = cwd / name + dest.write_text(template.read_text(encoding="utf-8"), encoding="utf-8") + written.append(str(dest)) + _stderr(f"Wrote {dest}") + + _json_out({"status": "ok", "written": written, "force": force}) if __name__ == "__main__": diff --git a/api/code_coverage/lcov/lcov.py b/api/code_coverage/lcov/lcov.py index 734f285d..94daae31 100644 --- a/api/code_coverage/lcov/lcov.py +++ b/api/code_coverage/lcov/lcov.py @@ -1,5 +1,7 @@ import os import sys +from typing import Optional + from ...graph import Graph def lcovparse(content): @@ -124,7 +126,7 @@ def _line(l, report): else: sys.stdout.write("Unknown method name %s" % method) -def process_lcov(repo: str, lcov_file: str) -> None: +def process_lcov(repo: str, lcov_file: str, branch: Optional[str] = None) -> None: # create report from coverage lcov file with open(lcov_file, "r") as file: content = file.read() # Reads the entire file as a single string @@ -134,7 +136,7 @@ def process_lcov(repo: str, lcov_file: str) -> None: # SF:/__w/FalkorDB/FalkorDB/src/algorithms/detect_cycle.c prefix = "/__w/FalkorDB/FalkorDB/" # prefix to remove - g = Graph(repo) + g = Graph(repo, branch=branch) #--------------------------------------------------------------------------- # Process report diff --git a/api/git_utils/git_utils.py b/api/git_utils/git_utils.py index 0c723b03..e9cbad19 100644 --- a/api/git_utils/git_utils.py +++ b/api/git_utils/git_utils.py @@ -14,8 +14,21 @@ # Configure logging logging.basicConfig(level=logging.DEBUG, format='%(filename)s - %(asctime)s - %(levelname)s - %(message)s') -def GitRepoName(repo_name): - """ Returns the git repository name """ +def GitRepoName(repo_name, branch=None): + """ Returns the git transitions graph key for ``(repo_name, branch)``. + + Format: ``{repo_name}:{branch}_git``. Hash-tag stays on ``repo_name`` + so the git-graph key lives on the same FalkorDB cluster slot as its + sibling code graph and ``*_info`` Redis hash. + """ + from ..graph import DEFAULT_BRANCH + if branch is None or branch == "": + branch = DEFAULT_BRANCH + return "{" + repo_name + "}" + ":" + branch + "_git" + + +def LegacyGitRepoName(repo_name): + """Pre-T17 git graph key shape — kept for the migration helper.""" return "{" + repo_name + "}_git" def is_ignored(file_path: str, ignore_list: List[str]) -> bool: @@ -70,7 +83,7 @@ def classify_changes( return added, deleted, modified # build a graph capturing the git commit history -def build_commit_graph(path: str, analyzer: SourceAnalyzer, repo_name: str, ignore_list: Optional[List[str]] = None) -> GitGraph: +def build_commit_graph(path: str, analyzer: SourceAnalyzer, repo_name: str, ignore_list: Optional[List[str]] = None, branch: Optional[str] = None) -> GitGraph: """ Builds a graph representation of the git commit history. @@ -78,6 +91,7 @@ def build_commit_graph(path: str, analyzer: SourceAnalyzer, repo_name: str, igno path (str): Path to the git repository. repo_name (str): Name of the repository. ignore_list (List[str], optional): List of file patterns to ignore. + branch (Optional[str]): Branch name. Defaults to ``_default``. Returns: GitGraph: Graph object representing the commit history. @@ -86,13 +100,15 @@ def build_commit_graph(path: str, analyzer: SourceAnalyzer, repo_name: str, igno if ignore_list is None: ignore_list = [] - # Copy the graph into a temporary graph - logging.info("Cloning source graph %s -> %s_tmp", repo_name, repo_name) - # Will be deleted at the end of this function - g = Graph(repo_name).clone(repo_name + "_tmp") + # Copy the graph into a temporary graph (sibling key with `_tmp` suffix on + # the branch component so the clone lands on the same cluster slot). + source = Graph(repo_name, branch=branch) + tmp_name = source.name + "_tmp" + logging.info("Cloning source graph %s -> %s", source.name, tmp_name) + g = source.clone(tmp_name) g.enable_backlog() - git_graph = GitGraph(GitRepoName(repo_name)) + git_graph = GitGraph(GitRepoName(repo_name, branch)) supported_types = analyzer.supported_types() # Initialize with the current commit @@ -252,12 +268,12 @@ def build_commit_graph(path: str, analyzer: SourceAnalyzer, repo_name: str, igno # Delete temporaty graph g.disable_backlog() - logging.debug(f"Deleting temporary graph {repo_name + '_tmp'}") + logging.debug(f"Deleting temporary graph {g.name}") g.delete() return git_graph -def switch_commit(repo: str, to: str): +def switch_commit(repo: str, to: str, branch: Optional[str] = None): """ Switches the state of a graph repository from its current commit to the given commit. @@ -268,6 +284,7 @@ def switch_commit(repo: str, to: str): Args: repo (str): The name of the graph repository to switch commits. to (str): The target commit hash to switch the graph to. + branch (Optional[str]): The branch. Defaults to ``_default``. """ # Validate input arguments @@ -280,11 +297,11 @@ def switch_commit(repo: str, to: str): logging.info(f"Switching to commit: {to}") # Initialize the graph and GitGraph objects - g = Graph(repo) - git_graph = GitGraph(GitRepoName(repo)) + g = Graph(repo, branch=branch) + git_graph = GitGraph(GitRepoName(repo, branch)) # Get the current commit hash of the graph - current_hash = get_repo_commit(repo) + current_hash = get_repo_commit(repo, branch) logging.info(f"Current graph commit: {current_hash}") if current_hash == to: @@ -329,5 +346,5 @@ def switch_commit(repo: str, to: str): g.rerun_query(_q, _p) # Update the graph's commit to the new target commit - set_repo_commit(repo, to) + set_repo_commit(repo, to, branch) logging.info(f"Graph commit updated to {to}") diff --git a/api/graph.py b/api/graph.py index eda72e63..5ec1863c 100644 --- a/api/graph.py +++ b/api/graph.py @@ -1,4 +1,5 @@ import os +import re import time from .entities import * from typing import Optional @@ -10,6 +11,56 @@ logging.basicConfig(level=logging.DEBUG, format='%(filename)s - %(asctime)s - %(levelname)s - %(message)s') + +# --------------------------------------------------------------------------- +# Branch-aware graph naming (T17) +# +# Each indexed (project, branch) pair gets its own FalkorDB graph so that +# concurrent agents indexing the same repo on different branches do not +# overwrite each other. The format is:: +# +# code:{project_name}:{branch} +# +# When ``branch`` is omitted it defaults to ``_default`` — that is also the +# name the one-shot migration uses when promoting legacy ``{project_name}`` +# graphs into the new namespace. +# --------------------------------------------------------------------------- + +DEFAULT_BRANCH = "_default" +_GRAPH_NAME_RE = re.compile(r"^code:(?P[^:]+):(?P.+)$") + + +def compose_graph_name(project_name: str, branch: Optional[str] = None) -> str: + """Compose the FalkorDB graph name for a (project, branch) pair. + + Args: + project_name: The repository / project name (typically the + directory basename). + branch: Branch name. ``None`` is treated as :data:`DEFAULT_BRANCH`. + + Returns: + ``"code:{project_name}:{branch}"``. + """ + + if branch is None or branch == "": + branch = DEFAULT_BRANCH + return f"code:{project_name}:{branch}" + + +def parse_graph_name(graph_name: str) -> Optional[tuple[str, str]]: + """Inverse of :func:`compose_graph_name`. + + Returns ``(project, branch)`` if ``graph_name`` follows the new + ``code:{project}:{branch}`` format, otherwise ``None`` so callers can + treat it as a legacy / unrelated graph. + """ + + m = _GRAPH_NAME_RE.match(graph_name) + if not m: + return None + return m.group("project"), m.group("branch") + + def graph_exists(name: str): db = FalkorDB(host=os.getenv('FALKORDB_HOST', 'localhost'), port=os.getenv('FALKORDB_PORT', 6379), @@ -18,9 +69,21 @@ def graph_exists(name: str): return name in db.list_graphs() -def get_repos() -> list[str]: + +def _is_internal_suffix(graph_name: str) -> bool: + """Internal helper graph suffixes that should never be listed as repos.""" + return graph_name.endswith('_git') or graph_name.endswith('_schema') or graph_name.endswith('_tmp') + + +def get_repos() -> list[dict]: """ - List processed repositories + List processed (project, branch) pairs. + + Returns a list of ``{"project": ..., "branch": ..., "graph": ...}`` + dicts for every graph that matches the new ``code:{project}:{branch}`` + format. Legacy graphs (created before T17) are returned with + ``branch == DEFAULT_BRANCH`` so callers can keep treating them as a + single graph until the migration is run. """ db = FalkorDB(host=os.getenv('FALKORDB_HOST', 'localhost'), @@ -28,22 +91,52 @@ def get_repos() -> list[str]: username=os.getenv('FALKORDB_USERNAME', None), password=os.getenv('FALKORDB_PASSWORD', None)) - graphs = db.list_graphs() - graphs = [g for g in graphs if not (g.endswith('_git') or g.endswith('_schema'))] - return graphs + repos = [] + for g in db.list_graphs(): + if _is_internal_suffix(g): + continue + parsed = parse_graph_name(g) + if parsed is None: + # Legacy graph (pre-T17): synthesize a virtual entry so it stays + # discoverable until the migration helper promotes it. + repos.append({"project": g, "branch": DEFAULT_BRANCH, "graph": g}) + else: + project, branch = parsed + repos.append({"project": project, "branch": branch, "graph": g}) + return repos class Graph(): """ Represents a connection to a graph database using FalkorDB. + + The underlying graph is named ``code:{project_name}:{branch}`` so that + concurrent agents working on different branches of the same repo do + not corrupt each other's data (see T17, issue #651). + + For backwards compatibility ``name`` may be either a bare project name + (``"falkordb"``) or a fully composed graph name (``"code:falkordb:main"``); + in the former case the composition is performed automatically using + ``branch`` (default :data:`DEFAULT_BRANCH`). """ - def __init__(self, name: str) -> None: - self.name = name + def __init__(self, name: str, branch: Optional[str] = None) -> None: + # Accept either an already-composed graph name or a bare project + # name + branch. ``parse_graph_name`` returns ``None`` for legacy / + # bare names, signalling that we need to compose. + parsed = parse_graph_name(name) + if parsed is not None: + self.project, self.branch = parsed + self.name = name + else: + self.project = name + self.branch = branch if branch is not None else DEFAULT_BRANCH + self.name = compose_graph_name(self.project, self.branch) + self.db = FalkorDB(host=os.getenv('FALKORDB_HOST', 'localhost'), port=os.getenv('FALKORDB_PORT', 6379), username=os.getenv('FALKORDB_USERNAME', None), password=os.getenv('FALKORDB_PASSWORD', None)) - self.g = self.db.select_graph(name) + self.g = self.db.select_graph(self.name) # Initialize the backlog as disabled by default self.backlog = None @@ -62,9 +155,34 @@ def __init__(self, name: str) -> None: except Exception: pass + @classmethod + def from_raw_name(cls, raw_name: str) -> "Graph": + """Construct a :class:`Graph` from an already-composed (or raw) name. + + Used by :meth:`clone` and the migration helper, where the caller + already knows the final FalkorDB key. Bypasses + :func:`compose_graph_name`. + """ + + obj = cls.__new__(cls) + obj.name = raw_name + parsed = parse_graph_name(raw_name) + if parsed is None: + obj.project = raw_name + obj.branch = DEFAULT_BRANCH + else: + obj.project, obj.branch = parsed + obj.db = FalkorDB(host=os.getenv('FALKORDB_HOST', 'localhost'), + port=os.getenv('FALKORDB_PORT', 6379), + username=os.getenv('FALKORDB_USERNAME', None), + password=os.getenv('FALKORDB_PASSWORD', None)) + obj.g = obj.db.select_graph(raw_name) + obj.backlog = None + return obj + def clone(self, clone: str) -> "Graph": """ - Create a copy of the graph under the name clone + Create a copy of the graph under the name clone (raw FalkorDB key). Returns: a new instance of Graph @@ -81,7 +199,7 @@ def clone(self, clone: str) -> "Graph": # TODO: add a waiting limit time.sleep(1) - return Graph(clone) + return Graph.from_raw_name(clone) def delete(self) -> None: @@ -639,12 +757,24 @@ async def async_graph_exists(name: str) -> bool: await db.aclose() -async def async_get_repos() -> list[str]: - """List processed repositories (async version).""" +async def async_get_repos() -> list[dict]: + """List processed (project, branch) pairs (async version). + + Mirrors :func:`get_repos`; see that function for the return shape. + """ db = _async_db() try: - graphs = await db.list_graphs() - return [g for g in graphs if not (g.endswith('_git') or g.endswith('_schema'))] + repos = [] + for g in await db.list_graphs(): + if _is_internal_suffix(g): + continue + parsed = parse_graph_name(g) + if parsed is None: + repos.append({"project": g, "branch": DEFAULT_BRANCH, "graph": g}) + else: + project, branch = parsed + repos.append({"project": project, "branch": branch, "graph": g}) + return repos finally: await db.aclose() @@ -654,12 +784,22 @@ class AsyncGraphQuery: Uses falkordb.asyncio under the hood. No index creation or backlog — indexes already exist from the sync Graph used during analysis. + + Accepts either a bare project name + branch or a fully composed graph + name (``code:{project}:{branch}``); see :class:`Graph` for details. """ - def __init__(self, name: str) -> None: - self.name = name + def __init__(self, name: str, branch: Optional[str] = None) -> None: + parsed = parse_graph_name(name) + if parsed is not None: + self.project, self.branch = parsed + self.name = name + else: + self.project = name + self.branch = branch if branch is not None else DEFAULT_BRANCH + self.name = compose_graph_name(self.project, self.branch) self.db = _async_db() - self.g = self.db.select_graph(name) + self.g = self.db.select_graph(self.name) async def graph_exists(self) -> bool: """Check if this graph exists, reusing the current connection.""" diff --git a/api/index.py b/api/index.py index 38dfb61d..95c6085f 100644 --- a/api/index.py +++ b/api/index.py @@ -3,6 +3,7 @@ import asyncio import logging from pathlib import Path +from typing import Optional from dotenv import load_dotenv from fastapi import Depends, FastAPI, Header, HTTPException, Query @@ -15,7 +16,7 @@ from api.graph import Graph, AsyncGraphQuery, async_get_repos from api.info import async_get_repo_info from api.llm import ask -from api.project import Project +from api.project import Project, detect_branch # Load environment variables from .env file @@ -56,35 +57,43 @@ def token_required(authorization: str | None = Header(None)): class RepoRequest(BaseModel): repo: str + branch: Optional[str] = None class NeighborsRequest(BaseModel): repo: str node_ids: list[int] + branch: Optional[str] = None class AutoCompleteRequest(BaseModel): repo: str prefix: str + branch: Optional[str] = None class FindPathsRequest(BaseModel): repo: str src: int dest: int + branch: Optional[str] = None class ChatRequest(BaseModel): repo: str msg: str + branch: Optional[str] = None class AnalyzeFolderRequest(BaseModel): path: str ignore: list[str] = [] + branch: Optional[str] = None class AnalyzeRepoRequest(BaseModel): repo_url: str ignore: list[str] = [] + branch: Optional[str] = None class SwitchCommitRequest(BaseModel): repo: str commit: str + branch: Optional[str] = None # --------------------------------------------------------------------------- # Application @@ -104,24 +113,43 @@ class SwitchCommitRequest(BaseModel): # API routes # --------------------------------------------------------------------------- +@app.get('/api/_health') +async def _health(): + """Diagnostic endpoint: reports the running server's resolver + DB config. + + Used by the bench harness to fail-fast when the API server was started + without ``CODE_GRAPH_PY_RESOLVER=tree_sitter`` — without that flag the + Python indexer falls back to jedi/multilspy, which on real-world repos + (sphinx, sympy, …) spawns a per-repo venv + ``pip install poetry`` and + can wedge for hours at 100% CPU. Cheap (no DB call); safe to ship. + """ + return { + "status": "ok", + "py_resolver": os.environ.get("CODE_GRAPH_PY_RESOLVER", "jedi"), + "falkordb_host": os.environ.get("FALKORDB_HOST", "localhost"), + "falkordb_port": os.environ.get("FALKORDB_PORT", "6379"), + "public": os.environ.get("CODE_GRAPH_PUBLIC", "0"), + } + + @app.get('/api/graph_entities') -async def graph_entities(repo: str = Query(None), _=Depends(public_or_auth)): +async def graph_entities(repo: str = Query(None), branch: Optional[str] = Query(None), _=Depends(public_or_auth)): """Fetch sub-graph entities from a given repository.""" if not repo: logging.error("Missing 'repo' parameter in request.") return JSONResponse({"status": "Missing 'repo' parameter"}, status_code=400) - g = AsyncGraphQuery(repo) + g = AsyncGraphQuery(repo, branch=branch) try: if not await g.graph_exists(): - logging.error("Missing project %s", repo) + logging.error("Missing project %s (branch=%s)", repo, g.branch) return JSONResponse({"status": f"Missing project {repo}"}, status_code=400) sub_graph = await g.get_sub_graph(500) - logging.info("Successfully retrieved sub-graph for repo: %s", repo) - return {"status": "success", "entities": sub_graph} + logging.info("Successfully retrieved sub-graph for repo: %s (branch=%s)", repo, g.branch) + return {"status": "success", "branch": g.branch, "entities": sub_graph} except Exception as e: logging.exception("Error retrieving sub-graph for repo '%s': %s", repo, e) @@ -134,26 +162,26 @@ async def graph_entities(repo: str = Query(None), _=Depends(public_or_auth)): async def get_neighbors(data: NeighborsRequest, _=Depends(public_or_auth)): """Get neighbors of a nodes list in the graph.""" - g = AsyncGraphQuery(data.repo) + g = AsyncGraphQuery(data.repo, branch=data.branch) try: if not await g.graph_exists(): - logging.error("Missing project %s", data.repo) + logging.error("Missing project %s (branch=%s)", data.repo, g.branch) return JSONResponse({"status": f"Missing project {data.repo}"}, status_code=400) neighbors = await g.get_neighbors(data.node_ids) finally: await g.close() - logging.info("Successfully retrieved neighbors for node IDs %s in repo '%s'.", - data.node_ids, data.repo) - return {"status": "success", "neighbors": neighbors} + logging.info("Successfully retrieved neighbors for node IDs %s in repo '%s' (branch=%s).", + data.node_ids, data.repo, g.branch) + return {"status": "success", "branch": g.branch, "neighbors": neighbors} @app.post('/api/auto_complete') async def auto_complete(data: AutoCompleteRequest, _=Depends(public_or_auth)): """Process auto-completion requests for a repository based on a prefix.""" - g = AsyncGraphQuery(data.repo) + g = AsyncGraphQuery(data.repo, branch=data.branch) try: if not await g.graph_exists(): return JSONResponse({"status": f"Missing project {data.repo}"}, status_code=400) @@ -161,12 +189,12 @@ async def auto_complete(data: AutoCompleteRequest, _=Depends(public_or_auth)): completions = await g.prefix_search(data.prefix) finally: await g.close() - return {"status": "success", "completions": completions} + return {"status": "success", "branch": g.branch, "completions": completions} @app.get('/api/list_repos') async def list_repos(_=Depends(public_or_auth)): - """List all available repositories.""" + """List all available repositories (returns (project, branch) pairs).""" repos = await async_get_repos() return {"status": "success", "repositories": repos} @@ -176,7 +204,7 @@ async def list_repos(_=Depends(public_or_auth)): async def repo_info(data: RepoRequest, _=Depends(public_or_auth)): """Retrieve information about a specific repository.""" - g = AsyncGraphQuery(data.repo) + g = AsyncGraphQuery(data.repo, branch=data.branch) try: if not await g.graph_exists(): return JSONResponse({"status": f'Missing repository "{data.repo}"'}, status_code=400) @@ -184,29 +212,29 @@ async def repo_info(data: RepoRequest, _=Depends(public_or_auth)): stats = await g.stats() finally: await g.close() - info = await async_get_repo_info(data.repo) + info = await async_get_repo_info(data.repo, data.branch) if info is None: return JSONResponse({"status": f'Missing repository "{data.repo}"'}, status_code=400) stats |= info - return {"status": "success", "info": stats} + return {"status": "success", "branch": g.branch, "info": stats} @app.post('/api/find_paths') async def find_paths(data: FindPathsRequest, _=Depends(public_or_auth)): """Find all paths between a source and destination node in the graph.""" - g = AsyncGraphQuery(data.repo) + g = AsyncGraphQuery(data.repo, branch=data.branch) try: if not await g.graph_exists(): - logging.error("Missing project %s", data.repo) + logging.error("Missing project %s (branch=%s)", data.repo, g.branch) return JSONResponse({"status": f"Missing project {data.repo}"}, status_code=400) paths = await g.find_paths(data.src, data.dest) finally: await g.close() - return {"status": "success", "paths": paths} + return {"status": "success", "branch": g.branch, "paths": paths} @app.post('/api/chat') @@ -241,33 +269,35 @@ async def analyze_folder(data: AnalyzeFolderRequest, _=Depends(token_required)): status_code=400) proj_name = resolved_path.name + branch = data.branch if data.branch is not None else detect_branch(resolved_path) def _analyze(): - g = Graph(proj_name) + g = Graph(proj_name, branch=branch) analyzer = SourceAnalyzer() analyzer.analyze_local_folder(str(resolved_path), g, data.ignore) loop = asyncio.get_running_loop() await loop.run_in_executor(None, _analyze) - return {"status": "success", "project": proj_name} + return {"status": "success", "project": proj_name, "branch": branch} @app.post('/api/analyze_repo') async def analyze_repo(data: AnalyzeRepoRequest, _=Depends(token_required)): """Analyze a GitHub repository. Always requires a valid token.""" - logger.debug('Received repo_url: %s', data.repo_url) + logger.debug('Received repo_url: %s branch: %s', data.repo_url, data.branch) def _analyze(): - proj = Project.from_git_repository(data.repo_url) + proj = Project.from_git_repository(data.repo_url, branch=data.branch) proj.analyze_sources(data.ignore) proj.process_git_history(data.ignore) + return proj.branch loop = asyncio.get_running_loop() - await loop.run_in_executor(None, _analyze) + resolved_branch = await loop.run_in_executor(None, _analyze) - return {"status": "success"} + return {"status": "success", "branch": resolved_branch} @app.post('/api/switch_commit') @@ -275,7 +305,7 @@ async def switch_commit(data: SwitchCommitRequest, _=Depends(token_required)): """Switch a repository to a specific commit. Always requires a valid token.""" loop = asyncio.get_running_loop() - await loop.run_in_executor(None, git_utils.switch_commit, data.repo, data.commit) + await loop.run_in_executor(None, git_utils.switch_commit, data.repo, data.commit, data.branch) return {"status": "success"} @@ -283,7 +313,7 @@ async def switch_commit(data: SwitchCommitRequest, _=Depends(token_required)): async def list_commits(data: RepoRequest, _=Depends(public_or_auth)): """List all commits of a specified repository.""" - git_graph = AsyncGitGraph(git_utils.GitRepoName(data.repo)) + git_graph = AsyncGitGraph(git_utils.GitRepoName(data.repo, data.branch)) try: commits = await git_graph.list_commits() finally: diff --git a/api/info.py b/api/info.py index b1d9ea7e..3b41d042 100644 --- a/api/info.py +++ b/api/info.py @@ -4,10 +4,31 @@ import logging from typing import Optional, Dict +from .graph import DEFAULT_BRANCH + # Configure logging logging.basicConfig(level=logging.INFO) -def _repo_info_key(repo_name: str) -> str: + +def _normalize_branch(branch: Optional[str]) -> str: + if branch is None or branch == "": + return DEFAULT_BRANCH + return branch + + +def _repo_info_key(repo_name: str, branch: Optional[str] = None) -> str: + """Compose the Redis hash key holding ``(repo, branch)`` metadata. + + The curly-brace hash-tag stays on ``repo_name`` so per-branch metadata + keys land on the same FalkorDB cluster slot as the equivalent graph + keys (e.g. ``{repo}:{branch}_git``). + """ + branch = _normalize_branch(branch) + return f"{{{repo_name}}}:{branch}_info" + + +def _legacy_repo_info_key(repo_name: str) -> str: + """Pre-T17 key shape, retained for the migration helper / fallback reads.""" return f"{{{repo_name}}}_info" def get_redis_connection() -> redis.Redis: @@ -30,12 +51,12 @@ def get_redis_connection() -> redis.Redis: raise -def set_repo_commit(repo_name: str, commit_hash: str) -> None: - """Save processed commit hash to the DB""" +def set_repo_commit(repo_name: str, commit_hash: str, branch: Optional[str] = None) -> None: + """Save processed commit hash to the DB for ``(repo_name, branch)``.""" try: r = get_redis_connection() - key = _repo_info_key(repo_name) # Safely format the key + key = _repo_info_key(repo_name, branch) # Safely format the key # Save the repository URL r.hset(key, 'commit', commit_hash) @@ -46,15 +67,19 @@ def set_repo_commit(repo_name: str, commit_hash: str) -> None: raise -def get_repo_commit(repo_name: str) -> str: - """Get the current commit the repo is at""" +def get_repo_commit(repo_name: str, branch: Optional[str] = None) -> str: + """Get the current commit the repo is at for ``(repo_name, branch)``.""" try: r = get_redis_connection() - key = _repo_info_key(repo_name) + key = _repo_info_key(repo_name, branch) # Retrieve all information about the repository commit_hash = r.hget(key, "commit") + if not commit_hash: + # Fall back to the legacy single-key shape, so reads against + # un-migrated graphs still succeed. + commit_hash = r.hget(_legacy_repo_info_key(repo_name), "commit") if not commit_hash: logging.warning(f"Failed to retrieve {repo_name} current commit hash") return None @@ -67,18 +92,20 @@ def get_repo_commit(repo_name: str) -> str: raise -def save_repo_info(repo_name: str, repo_url: str) -> None: +def save_repo_info(repo_name: str, repo_url: str, branch: Optional[str] = None) -> None: """ - Saves repository information (URL) to Redis under a hash named {repo_name}_info. + Saves repository information (URL) to Redis under a hash named + ``{repo_name}:{branch}_info``. Args: repo_name (str): The name of the repository. repo_url (str): The URL of the repository. + branch (Optional[str]): The branch. Defaults to ``_default``. """ try: r = get_redis_connection() - key = _repo_info_key(repo_name) + key = _repo_info_key(repo_name, branch) # Save the repository URL r.hset(key, 'repo_url', repo_url) @@ -88,27 +115,34 @@ def save_repo_info(repo_name: str, repo_url: str) -> None: logging.error(f"Error saving repo info for '{repo_name}': {e}") raise -def get_repo_info(repo_name: str) -> Optional[Dict[str, str]]: +def get_repo_info(repo_name: str, branch: Optional[str] = None) -> Optional[Dict[str, str]]: """ - Retrieves repository information from Redis. + Retrieves repository information from Redis for ``(repo_name, branch)``. + + Falls back to the legacy single-key shape so pre-migration graphs + remain readable. Args: repo_name (str): The name of the repository. + branch (Optional[str]): The branch. Defaults to ``_default``. Returns: - Optional[Dict[str, str]]: A dictionary of repository information, or None if not found. + Optional[Dict[str, str]]: A dictionary of repository information, + or ``None`` if not found. """ try: r = get_redis_connection() - key = _repo_info_key(repo_name) - + key = _repo_info_key(repo_name, branch) + # Retrieve all information about the repository repo_info = r.hgetall(key) + if not repo_info: + repo_info = r.hgetall(_legacy_repo_info_key(repo_name)) if not repo_info: logging.warning(f"No repository info found for {repo_name}") return None - + logging.info(f"Repository info retrieved for {repo_name}") return repo_info @@ -131,12 +165,14 @@ async def async_get_redis_connection() -> aioredis.Redis: ) -async def async_get_repo_info(repo_name: str) -> Optional[Dict[str, str]]: +async def async_get_repo_info(repo_name: str, branch: Optional[str] = None) -> Optional[Dict[str, str]]: try: r = await async_get_redis_connection() try: - key = _repo_info_key(repo_name) + key = _repo_info_key(repo_name, branch) repo_info = await r.hgetall(key) + if not repo_info: + repo_info = await r.hgetall(_legacy_repo_info_key(repo_name)) if not repo_info: logging.warning(f"No repository info found for {repo_name}") return None diff --git a/api/llm.py b/api/llm.py index 7c586fac..10808c90 100644 --- a/api/llm.py +++ b/api/llm.py @@ -1,273 +1,128 @@ -import os -import asyncio -import logging - -from graphrag_sdk.models.litellm import LiteModel -from graphrag_sdk import ( - Ontology, - Entity, - Relation, - Attribute, - AttributeType, - KnowledgeGraph, - KnowledgeGraphModelConfig -) +"""Text-to-Cypher chat over an existing FalkorDB code graph. -from .prompts import (CYPHER_GEN_SYSTEM, - CYPHER_GEN_PROMPT, - GRAPH_QA_SYSTEM, - GRAPH_QA_PROMPT, - ) +This module previously relied on `graphrag-sdk` 0.8.x's `KnowledgeGraph` class, +which wrapped a pre-populated graph and provided a `chat_session()` Q&A flow +with custom Cypher-generation and answer-synthesis prompts. +graphrag-sdk 1.x is a ground-up rewrite around document ingestion: the +`KnowledgeGraph` class is gone and the new `GraphRAG` facade expects to own +the graph it serves (it ingests text/files and writes nodes with embeddings). +There is no public primitive for "wrap an existing graph and chat over it". -# Configure logging -logging.basicConfig(level=logging.DEBUG, format='%(filename)s - %(asctime)s - %(levelname)s - %(message)s') +code-graph builds its graphs through dedicated language analyzers, not through +ingestion. We therefore keep the text-to-Cypher pipeline in-house here: +generate Cypher from the question + ontology, execute it against FalkorDB, +then synthesize a natural-language answer. We use `graphrag-sdk`'s `LiteLLM` +provider as a thin LiteLLM wrapper so we still benefit from its retry logic. +""" -def _define_ontology() -> Ontology: - # Build ontology: - ontology = Ontology() +from __future__ import annotations - # Entities: - # 1. File - # 2. Class - # 3. Function - # 4. Struct (TODO: Add struct) - - # Relations: - # File - DEFINES -> Class - # File - DEFINES -> Function - # Class - DEFINES -> Class - # Class - DEFINES -> Function - # Function - DEFINES -> Function - # Class - CALLS -> Function - # Function - CALLS -> Function +import logging +import os +import re +from typing import Any - # TODO: auto generate ontology - #"call db.labels()" - #"call db.relationshiptypes()" - #"match (n: File) return keys(n) limit 1" - #"match (n: File) return n limit 1" - #"match ()-[e: {}]->() return e limit 1 +from falkordb.asyncio import FalkorDB as AsyncFalkorDB +from graphrag_sdk import ChatMessage, LiteLLM - # Function: - # name - # path - # src_start - # src_end - # args "[[cls, Unknown]]" - # src +from .prompts import ( + CYPHER_GEN_PROMPT, + CYPHER_GEN_SYSTEM, + GRAPH_QA_PROMPT, + GRAPH_QA_SYSTEM, +) - function = Entity( - label="Function", - attributes=[ - Attribute( - name="name", - attr_type=AttributeType.STRING, - required=True, - unique=True, - ), - Attribute( - name="path", - attr_type=AttributeType.STRING, - required=False, - unique=False, - ), - Attribute( - name="src_start", - attr_type=AttributeType.NUMBER, - required=False, - unique=False, - ), - Attribute( - name="src_end", - attr_type=AttributeType.NUMBER, - required=False, - unique=False, - ), - Attribute( - name="args", - attr_type=AttributeType.STRING, - required=False, - unique=False, - ), - Attribute( - name="src", - attr_type=AttributeType.STRING, - required=False, - unique=False, - ), - ] - ) +logger = logging.getLogger(__name__) - # File: - # name - # ext - # path - file = Entity( - label="File", - attributes=[ - Attribute( - name="name", - attr_type=AttributeType.STRING, - required=True, - unique=True, - ), - Attribute( - name="path", - attr_type=AttributeType.STRING, - required=False, - unique=False, - ), - Attribute( - name="ext", - attr_type=AttributeType.STRING, - required=False, - unique=False, - ) - ] - ) - # Class: - # name - # path - # src_start - # src_end - # doc +# The ontology is described to the LLM as plain text. Keeping this as a +# static string (rather than the old `Ontology` object tree) avoids depending +# on v0-only classes and makes the prompt easier to reason about. +_ONTOLOGY_TEXT = """ +Entities: +- File(name: string [required, unique], path: string, ext: string) +- Class(name: string [required, unique], path: string, src_start: number, src_end: number, doc: string) +- Function(name: string [required, unique], path: string, src_start: number, src_end: number, args: string, src: string) +- Interface(name: string [required, unique], path: string, src_start: number, src_end: number, doc: string) - cls = Entity( - label="Class", - attributes=[ - Attribute( - name="name", - attr_type=AttributeType.STRING, - required=True, - unique=True, - ), - Attribute( - name="path", - attr_type=AttributeType.STRING, - required=False, - unique=False, - ), - Attribute( - name="src_start", - attr_type=AttributeType.NUMBER, - required=False, - unique=False, - ), - Attribute( - name="src_end", - attr_type=AttributeType.NUMBER, - required=False, - unique=False, - ), - Attribute( - name="doc", - attr_type=AttributeType.STRING, - required=False, - unique=False, - ), - ] - ) - - interface = Entity( - label="Interface", - attributes=[ - Attribute( - name="name", - attr_type=AttributeType.STRING, - required=True, - unique=True, - ), - Attribute( - name="path", - attr_type=AttributeType.STRING, - required=False, - unique=False, - ), - Attribute( - name="src_start", - attr_type=AttributeType.NUMBER, - required=False, - unique=False, - ), - Attribute( - name="src_end", - attr_type=AttributeType.NUMBER, - required=False, - unique=False, - ), - Attribute( - name="doc", - attr_type=AttributeType.STRING, - required=False, - unique=False, - ), - ] - ) +Relations (source -[TYPE]-> target): +- File -[DEFINES]-> Class +- File -[DEFINES]-> Function +- Class -[DEFINES]-> Class +- Class -[DEFINES]-> Function +- Function -[DEFINES]-> Function +- Class -[CALLS]-> Function +- Function -[CALLS]-> Function +- Class -[EXTENDS]-> Class +- Class -[IMPLEMENTS]-> Interface +""".strip() - ontology.add_entity(cls) - ontology.add_entity(file) - ontology.add_entity(function) - ontology.add_entity(interface) - # Relations: - # File - DEFINES -> Class - # File - DEFINES -> Function - # Class - DEFINES -> Class - # Class - DEFINES -> Function - # Function - DEFINES -> Function - # Class - CALLS -> Function - # Function - CALLS -> Function +_CYPHER_BLOCK_RE = re.compile(r"```(?:cypher)?\s*(.*?)\s*```", re.DOTALL) - ontology.add_relation(Relation("CALLS", "Class", "Function")) - ontology.add_relation(Relation("CALLS", "Function", "Function")) - ontology.add_relation(Relation("DEFINES", "File", "Class")) - ontology.add_relation(Relation("DEFINES", "File", "Function")) - ontology.add_relation(Relation("DEFINES", "Class", "Class")) - ontology.add_relation(Relation("EXTENDS", "Class", "Class")) - ontology.add_relation(Relation("IMPLEMENTS", "Class", "Interface")) - ontology.add_relation(Relation("DEFINES", "Class", "Function")) - ontology.add_relation(Relation("DEFINES", "Function", "Function")) - return ontology +def _extract_cypher(text: str) -> str: + """Pull the Cypher statement out of an LLM response.""" + match = _CYPHER_BLOCK_RE.search(text) + if match: + return match.group(1).strip() + return text.strip() -# Global ontology -ontology = _define_ontology() -def _create_kg_agent(repo_name: str): - model_name = os.getenv('MODEL_NAME', 'gemini/gemini-flash-lite-latest') +def _build_llm() -> LiteLLM: + model_name = os.getenv("MODEL_NAME", "gemini/gemini-flash-lite-latest") + return LiteLLM(model_name, temperature=0.0) - model = LiteModel(model_name) - #ontology = _define_ontology() - code_graph_kg = KnowledgeGraph( - name=repo_name, - ontology=ontology, - model_config=KnowledgeGraphModelConfig.with_model(model), - host=os.getenv('FALKORDB_HOST', 'localhost'), - port=os.getenv('FALKORDB_PORT', 6379), - username=os.getenv('FALKORDB_USERNAME', None), - password=os.getenv('FALKORDB_PASSWORD', None), - cypher_system_instruction=CYPHER_GEN_SYSTEM, - qa_system_instruction=GRAPH_QA_SYSTEM, - cypher_gen_prompt=CYPHER_GEN_PROMPT, - qa_prompt=GRAPH_QA_PROMPT, +def _falkordb() -> AsyncFalkorDB: + return AsyncFalkorDB( + host=os.getenv("FALKORDB_HOST", "localhost"), + port=int(os.getenv("FALKORDB_PORT", "6379")), + username=os.getenv("FALKORDB_USERNAME") or None, + password=os.getenv("FALKORDB_PASSWORD") or None, ) - return code_graph_kg.chat_session() -def _ask_sync(repo_name: str, question: str) -> str: - chat = _create_kg_agent(repo_name) - - logging.debug(f"Question: {question}") - print(f"Question: {question}") - response = chat.send_message(question) - logging.debug(f"Response: {response}") - print(f"Response: {response['response']}") - return response['response'] +async def _run_cypher(repo_name: str, cypher: str) -> list[list[Any]]: + if not cypher: + return [] + db = _falkordb() + graph = db.select_graph(repo_name) + try: + result = await graph.query(cypher) + return list(result.result_set or []) + except Exception: + logger.exception("Cypher execution failed: %s", cypher) + return [] async def ask(repo_name: str, question: str) -> str: - loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, _ask_sync, repo_name, question) + """Answer a natural-language question against the code graph for repo_name.""" + llm = _build_llm() + + cypher_resp = await llm.ainvoke_messages( + [ + ChatMessage(role="system", content=CYPHER_GEN_SYSTEM.format(ontology=_ONTOLOGY_TEXT)), + ChatMessage(role="user", content=CYPHER_GEN_PROMPT.format(question=question)), + ] + ) + cypher = _extract_cypher(cypher_resp.content) + logger.debug("Generated Cypher: %s", cypher) + + context = await _run_cypher(repo_name, cypher) + + answer_resp = await llm.ainvoke_messages( + [ + ChatMessage(role="system", content=GRAPH_QA_SYSTEM), + ChatMessage( + role="user", + content=GRAPH_QA_PROMPT.format( + cypher=cypher, + context=context, + question=question, + ), + ), + ] + ) + return answer_resp.content diff --git a/api/mcp/__init__.py b/api/mcp/__init__.py new file mode 100644 index 00000000..c161c8e1 --- /dev/null +++ b/api/mcp/__init__.py @@ -0,0 +1,8 @@ +"""MCP server module for code-graph. + +Exposes the code-graph indexer and graph queries as MCP tools so AI coding +agents (Claude Code, Cursor, Copilot, Roo/Cline) can drive the indexer over +the standard Model Context Protocol stdio transport. + +Entry point: ``cgraph-mcp`` (defined in ``pyproject.toml``). +""" diff --git a/api/mcp/auto_init.py b/api/mcp/auto_init.py new file mode 100644 index 00000000..86ac913d --- /dev/null +++ b/api/mcp/auto_init.py @@ -0,0 +1,172 @@ +"""Zero-config startup helpers for the MCP server (T12). + +Two automation behaviours: + +1. :func:`ensure_falkordb` — at server boot, ping FalkorDB; if it's + unreachable on a localhost host, run ``cgraph ensure-db`` to spin up + the existing Docker container. Reuses ``api.cli.ensure_db`` rather + than duplicating Docker logic. + +2. :func:`maybe_auto_index` — when ``CODE_GRAPH_AUTO_INDEX=true`` is set + (opt-in, off by default), index the current working directory into a + per-branch graph so the agent doesn't have to call ``index_repo`` + first. Idempotent within a single process — the second call for the + same ``(project, branch)`` is a no-op. + +Both are deliberately conservative: ensure-db only acts on localhost +hosts, and auto-index requires explicit opt-in because indexing a +large repo can take minutes and surprising the user with that on +first tool call is bad UX. +""" + +from __future__ import annotations + +import logging +import os +import socket +import subprocess +from pathlib import Path +from typing import Iterable, Optional + + +logger = logging.getLogger(__name__) + + +_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"} +_AUTO_INDEXED: set[tuple[str, str]] = set() + + +# --------------------------------------------------------------------------- +# ensure_falkordb +# --------------------------------------------------------------------------- + + +def _falkordb_reachable(host: str, port: int, timeout: float = 1.0) -> bool: + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: + return False + + +def ensure_falkordb() -> dict: + """Make sure FalkorDB is reachable; bootstrap Docker if not. + + Returns a small status dict so the caller can log it. Never raises — + the goal is to start the MCP server even if the bootstrap fails; + individual tools will then surface their own errors. + """ + host = os.getenv("FALKORDB_HOST", "localhost") + try: + port = int(os.getenv("FALKORDB_PORT", "6379")) + except ValueError: + return {"status": "error", "message": "invalid FALKORDB_PORT"} + + if _falkordb_reachable(host, port): + return {"status": "ok", "host": host, "port": port, "action": "none"} + + if host not in _LOCAL_HOSTS: + return { + "status": "error", + "host": host, + "port": port, + "message": "FalkorDB unreachable; auto-start only supports localhost", + } + + logger.info("FalkorDB unreachable on %s:%s — running `cgraph ensure-db`", host, port) + try: + # Subprocess so the CLI's stdout (which prints JSON) doesn't pollute + # the MCP server's own stdio transport. + result = subprocess.run( + ["cgraph", "ensure-db"], + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError: + return {"status": "error", "message": "cgraph CLI not on PATH"} + + return { + "status": "ok" if result.returncode == 0 else "error", + "host": host, + "port": port, + "action": "started", + "stdout": result.stdout.strip(), + "stderr": result.stderr.strip(), + } + + +# --------------------------------------------------------------------------- +# maybe_auto_index +# --------------------------------------------------------------------------- + + +def _truthy(val: Optional[str]) -> bool: + return (val or "").strip().lower() in {"1", "true", "yes", "on"} + + +def _detect_branch(cwd: Path) -> str: + """Best-effort current-branch detection. Falls back to ``_default``.""" + try: + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=str(cwd), + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + except FileNotFoundError: + pass + return "_default" + + +def maybe_auto_index( + cwd: Optional[Path] = None, + *, + project: Optional[str] = None, + branch: Optional[str] = None, +) -> dict: + """If opt-in env var is set, index ``cwd`` into the per-branch graph. + + Caches "already auto-indexed this session" per ``(project, branch)`` + in the module-level :data:`_AUTO_INDEXED` set so subsequent calls + are no-ops. + """ + if not _truthy(os.getenv("CODE_GRAPH_AUTO_INDEX")): + return {"status": "skipped", "reason": "CODE_GRAPH_AUTO_INDEX not set"} + + cwd_path = (cwd or Path.cwd()).resolve() + project_name = project or cwd_path.name + branch_name = branch or _detect_branch(cwd_path) + + key = (project_name, branch_name) + if key in _AUTO_INDEXED: + return {"status": "skipped", "reason": "already auto-indexed", "key": key} + + # Local imports so the MCP server can import this module without paying + # the analyzer-stack import cost at module load. + from api.analyzers.source_analyzer import SourceAnalyzer + from api.graph import Graph + + logger.info("Auto-indexing %s @ %s into code:%s:%s", cwd_path, branch_name, project_name, branch_name) + graph = Graph(project_name, branch=branch_name) + SourceAnalyzer().analyze_local_folder(str(cwd_path), graph) + + _AUTO_INDEXED.add(key) + return { + "status": "indexed", + "project": project_name, + "branch": branch_name, + "path": str(cwd_path), + } + + +def reset_auto_index_cache(keys: Optional[Iterable[tuple[str, str]]] = None) -> None: + """Drop the auto-index session cache. Tests only.""" + if keys is None: + _AUTO_INDEXED.clear() + else: + for k in keys: + _AUTO_INDEXED.discard(k) diff --git a/api/mcp/code_prompts.py b/api/mcp/code_prompts.py new file mode 100644 index 00000000..c1ed80c1 --- /dev/null +++ b/api/mcp/code_prompts.py @@ -0,0 +1,33 @@ +"""MCP-side GraphRAG prompt overrides (T10). + +Today this module is a thin re-export of ``api.prompts``. The point is the +**seam**: when the MCP ``ask`` tool needs prompt framing tuned for +"the user is an AI agent inspecting a codebase" instead of +"a human chatting about their repo", divergence happens *here* without +touching the existing FastAPI ``/api/chat`` prompts. + +Until that day, every prompt below is identical to its ``api.prompts`` +counterpart — verified by ``tests/mcp/test_code_prompts.py``. +""" + +from __future__ import annotations + +from api.prompts import ( + CYPHER_GEN_PROMPT, + CYPHER_GEN_SYSTEM, + GRAPH_QA_PROMPT, + GRAPH_QA_SYSTEM, +) + + +__all__ = [ + "CYPHER_GEN_SYSTEM", + "CYPHER_GEN_PROMPT", + "GRAPH_QA_SYSTEM", + "GRAPH_QA_PROMPT", +] + + +# TODO(MCP): start diverging here when agent-vs-human framing matters. +# Keep `api/prompts.py` as the canonical reference for the FastAPI +# chat endpoint and override the MCP-facing variants in this module. diff --git a/api/mcp/graphrag_init.py b/api/mcp/graphrag_init.py new file mode 100644 index 00000000..20196a82 --- /dev/null +++ b/api/mcp/graphrag_init.py @@ -0,0 +1,85 @@ +"""GraphRAG init for the MCP ``ask`` tool (T9, refined by T11). + +The MCP ``ask`` tool needs one ``KnowledgeGraph`` instance per +``(project, branch)`` to drive GraphRAG's NL→Cypher→QA round-trip. Building +one is non-trivial — ontology, model, prompts, FalkorDB connection — and +the existing ``api/llm.py`` builder bakes in a single repo name at module +import. + +This module exposes: + +* :func:`get_or_create_kg` — process-wide cache keyed by + ``(project, branch)``. Cheap to call; one instance reused across many + ``ask`` invocations. +* :func:`reset_cache` — used in tests to drop the cache between runs. + +The ontology is intentionally reused from ``api.llm.define_ontology`` — it's +200+ lines of hand-tuned descriptions of File/Class/Function entities that +the LLM relies on to generate good Cypher. Replacing it with +``Ontology.from_kg_graph()`` (auto-extraction) is a regression. +""" + +from __future__ import annotations + +import os +from typing import Any, Tuple + +from api.graph import compose_graph_name +from api.mcp.code_prompts import ( + CYPHER_GEN_PROMPT, + CYPHER_GEN_SYSTEM, + GRAPH_QA_PROMPT, + GRAPH_QA_SYSTEM, +) + + +# Lazily imported to keep the MCP server starting even on graphrag-sdk 1.x, +# where ``KnowledgeGraph`` was removed. The ``ask`` tool still requires the +# 0.8 surface; structural tools work without it. +_CACHE: dict[Tuple[str, str], Any] = {} + + +def _make_model(): + """Build the LiteModel from ``$MODEL_NAME``.""" + from graphrag_sdk.models.litellm import LiteModel + model_name = os.getenv("MODEL_NAME", "gemini/gemini-flash-lite-latest") + return LiteModel(model_name) + + +def get_or_create_kg(project_name: str, branch: str = "_default"): + """Return a cached ``KnowledgeGraph`` for ``(project, branch)``. + + Raises ``ImportError`` at call time if the installed graphrag-sdk does + not expose ``KnowledgeGraph`` (i.e. 1.x). This keeps the rest of the + MCP server functional under either SDK major. + """ + from graphrag_sdk import KnowledgeGraph, KnowledgeGraphModelConfig # noqa: WPS433 + from api.llm import define_ontology # noqa: WPS433 + + key = (project_name, branch) + cached = _CACHE.get(key) + if cached is not None: + return cached + + graph_name = compose_graph_name(project_name, branch) + model = _make_model() + kg = KnowledgeGraph( + name=graph_name, + ontology=define_ontology(), + model_config=KnowledgeGraphModelConfig.with_model(model), + host=os.getenv("FALKORDB_HOST", "localhost"), + port=int(os.getenv("FALKORDB_PORT", 6379)), + username=os.getenv("FALKORDB_USERNAME", None), + password=os.getenv("FALKORDB_PASSWORD", None), + cypher_system_instruction=CYPHER_GEN_SYSTEM, + qa_system_instruction=GRAPH_QA_SYSTEM, + cypher_gen_prompt=CYPHER_GEN_PROMPT, + qa_prompt=GRAPH_QA_PROMPT, + ) + _CACHE[key] = kg + return kg + + +def reset_cache() -> None: + """Drop the per-process KG cache. Tests only.""" + _CACHE.clear() diff --git a/api/mcp/server.py b/api/mcp/server.py new file mode 100644 index 00000000..7be41464 --- /dev/null +++ b/api/mcp/server.py @@ -0,0 +1,39 @@ +"""FastMCP server for code-graph. + +This is the scaffold (T1). It instantiates a single FastMCP app, exposes it +as ``app`` for tests and embedders, and registers a ``main()`` entry point +that runs the server over stdio. Tools are registered in later tickets +(T4-T8, T11) by importing this module's ``app`` and decorating functions +with ``@app.tool(...)``. +""" + +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP + +app: FastMCP = FastMCP("code-graph") + +# Register tools on import so both direct ``import api.mcp.server`` and the +# stdio entry point see the same tool list. Imported below ``app`` because +# the tool modules need a reference to it. +from . import tools # noqa: F401, E402 + + +def main() -> None: + """Run the MCP server over stdio. + + Console-script entry point for ``cgraph-mcp``. Runs the T12 + auto-init helpers first so a freshly-cloned user gets a working + FalkorDB without manual `cgraph ensure-db`, and (opt-in via + ``CODE_GRAPH_AUTO_INDEX``) an indexed CWD without manual + `index_repo`. + """ + from .auto_init import ensure_falkordb, maybe_auto_index + + ensure_falkordb() + maybe_auto_index() + app.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/api/mcp/templates/claude_mcp_section.md b/api/mcp/templates/claude_mcp_section.md new file mode 100644 index 00000000..adf8b009 --- /dev/null +++ b/api/mcp/templates/claude_mcp_section.md @@ -0,0 +1,42 @@ +# code-graph MCP server — agent guidance + +This repo is indexed into a FalkorDB **code knowledge graph** exposed +to you over MCP as `code-graph`. Use it instead of grepping when you +need to understand how symbols connect. + +## When to call each tool + +| Tool | Call this when… | Example | +|---|---|---| +| `index_repo(path_or_url, branch?)` | **First** thing in a new repo; or after large changes outside your edits. Project name is **derived from the folder or repo URL** — read it back from the response. | `index_repo(path_or_url=".")` | +| `search_code(prefix, project)` | You know part of a symbol name and need its id. | `search_code(prefix="processPay", project="myrepo")` | +| `get_callers(symbol_id, project)` | "Who calls this?" — refactoring a function, tracking down a regression. | `get_callers(symbol_id=42, project="myrepo")` | +| `get_callees(symbol_id, project)` | "What does this call?" — understanding a function before editing it. | `get_callees(symbol_id=42, project="myrepo")` | +| `get_dependencies(symbol_id, project)` | All edges out of a symbol (CALLS + IMPORTS + DEFINES). | `get_dependencies(symbol_id=42, project="myrepo")` | +| `impact_analysis(symbol_id, project, direction, depth)` | **"What breaks if I change this?"** Transitive upstream callers. | `impact_analysis(symbol_id=42, project="myrepo", direction="IN", depth=3)` | +| `find_path(source_id, dest_id, project)` | Show the call chain between two known symbols. | `find_path(source_id=10, dest_id=42, project="myrepo")` | +| `ask(question, project)` | Open-ended natural-language question. **More expensive — use last.** | `ask(question="why does login fail when MFA is on?", project="myrepo")` | + +## Rules of thumb + +1. **Start with `search_code`** to turn names into ids. Most tools take a `symbol_id`. +2. **Prefer structural tools over `ask`.** `get_callers` is one cheap Cypher + hop; `ask` is two LLM round-trips. Use `ask` for fuzzy/conceptual + questions, not for "who calls X". +3. **`impact_analysis` before refactoring.** Even when you think you know + the answer — the transitive closure often surprises you. +4. **`branch` is optional** but pass it when working on a feature branch + so you query the right per-branch index. +5. **Response shape.** Tools that return collections (`search_code`, + `get_callers`, `get_callees`, `get_dependencies`, `find_path`, + `impact_analysis`) put the array in `structuredContent.result` per + the MCP spec. The text content is the same JSON for convenience. + `index_repo` and `ask` return a single object. + +## Environment + +- `CODE_GRAPH_AUTO_INDEX=true` — auto-index CWD on first tool call (off by + default; opt-in because indexing big repos takes minutes). +- `FALKORDB_HOST` / `FALKORDB_PORT` — defaults to `localhost:6379`. If + unreachable on localhost, the server runs `cgraph ensure-db` to + spin up the official Docker image. diff --git a/api/mcp/templates/cursorrules.template b/api/mcp/templates/cursorrules.template new file mode 100644 index 00000000..aa449eaf --- /dev/null +++ b/api/mcp/templates/cursorrules.template @@ -0,0 +1,32 @@ +# Cursor rules — code-graph MCP + +This project is indexed into a FalkorDB code knowledge graph via the +`code-graph` MCP server. Use it instead of grepping when you need to +understand how symbols connect. + +## Tool selection + +- Symbol lookup by name: use `code-graph.search_code` (gives you the + numeric id every other tool needs). +- "Who calls X?": `code-graph.get_callers`. +- "What does X call?": `code-graph.get_callees`. +- All outgoing edges (CALLS + IMPORTS + DEFINES): `code-graph.get_dependencies`. +- Refactoring impact ("what breaks if I change X"): + `code-graph.impact_analysis` with `direction="IN"`. +- Call chain between two specific symbols: `code-graph.find_path`. +- Natural-language question over the graph (expensive — last resort): + `code-graph.ask`. + +## Rules + +- Always `search_code` first to resolve names to ids. +- Prefer structural tools (`get_callers`, `find_path`, `impact_analysis`) + over `ask` for "who/what/where" questions. +- Run `impact_analysis(direction="IN", depth=3)` before any non-trivial + refactor. +- Pass `branch` when on a feature branch. + +## First run + +If the repo isn't indexed yet, call `index_repo(path=".")` once. After +that, navigate via the structural tools. diff --git a/api/mcp/tools/__init__.py b/api/mcp/tools/__init__.py new file mode 100644 index 00000000..76ce2d1d --- /dev/null +++ b/api/mcp/tools/__init__.py @@ -0,0 +1,7 @@ +"""MCP tool implementations for code-graph. + +Each submodule registers tools against the shared FastMCP app exposed by +``api.mcp.server``. Import this package to register all tools. +""" + +from . import ask, structural # noqa: F401 (registers tools on import) diff --git a/api/mcp/tools/ask.py b/api/mcp/tools/ask.py new file mode 100644 index 00000000..53ffff7c --- /dev/null +++ b/api/mcp/tools/ask.py @@ -0,0 +1,93 @@ +"""MCP ``ask`` tool — NL → Cypher → QA via GraphRAG (T11). + +This is the strategic differentiator vs purely structural code-graph MCP +servers: the agent asks a natural-language question, and we return the +LLM's answer plus the actual Cypher that was executed (for transparency +and learning). + +Two LLM round-trips bracket one FalkorDB query: + +1. **LLM #1 (cypher gen):** question + ontology → Cypher +2. **FalkorDB:** execute Cypher → rows of nodes +3. **LLM #2 (QA synthesis):** question + rows → natural-language answer + +The graph itself never goes to the LLM — only the schema and per-query +results — which is what makes this scale to huge codebases. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Optional + +from ..graphrag_init import get_or_create_kg +from ..server import app + + +logger = logging.getLogger(__name__) + + +def _normalize_response(raw: Any) -> dict[str, Any]: + """Coerce graphrag-sdk's chat response into the MCP payload shape. + + graphrag-sdk shapes its return as a ``dict`` with at least a + ``response`` (the natural-language answer) and, depending on the + SDK version, ``cypher`` / ``context``. We surface ``cypher_query`` + and ``context_nodes`` regardless — the design doc requires the + Cypher to be visible so agents can debug, learn, and decide whether + the query was sensible. + """ + if not isinstance(raw, dict): + return {"answer": str(raw), "cypher_query": None, "context_nodes": []} + + answer = raw.get("response") or raw.get("answer") or "" + cypher = raw.get("cypher_query") or raw.get("cypher") or raw.get("query") + ctx = ( + raw.get("context_nodes") + or raw.get("context") + or raw.get("results") + or [] + ) + return { + "answer": answer, + "cypher_query": cypher, + "context_nodes": ctx, + } + + +@app.tool( + name="ask", + description=( + "Ask a natural-language question about the indexed codebase. " + "Powered by GraphRAG: the question is translated to Cypher, " + "executed against the FalkorDB code graph, and the rows are " + "summarised in English. The executed Cypher is returned in " + "`cypher_query` so the agent can verify the answer and learn the " + "schema." + ), +) +async def ask( + question: str, + project: str, + branch: Optional[str] = None, +) -> dict[str, Any]: + kg = get_or_create_kg(project, branch or "_default") + loop = asyncio.get_running_loop() + + def _ask_sync() -> Any: + chat = kg.chat_session() + return chat.send_message(question) + + try: + raw = await loop.run_in_executor(None, _ask_sync) + except Exception as exc: # surface as a structured failure, not a crash + logger.exception("ask failed for project=%s branch=%s", project, branch) + return { + "answer": "", + "cypher_query": None, + "context_nodes": [], + "error": str(exc), + } + + return _normalize_response(raw) diff --git a/api/mcp/tools/structural.py b/api/mcp/tools/structural.py new file mode 100644 index 00000000..f2e002b3 --- /dev/null +++ b/api/mcp/tools/structural.py @@ -0,0 +1,505 @@ +"""Structural MCP tools (T4-T8). + +These tools wrap the existing ``Project`` / ``Graph`` / ``AsyncGraphQuery`` +operations so MCP-capable agents (Claude Code, Cursor, Copilot, Cline) +can drive code-graph over the standard stdio transport. + +Conventions shared by all tools in this module: + +* Every tool accepts an optional ``branch`` so the agent can scope queries + to a specific per-branch graph (see T17, issue #651). When omitted the + branch is either auto-detected from a local checkout (``index_repo``) + or defaults to ``_default``. +* Long-running synchronous operations are pushed into a thread via + ``asyncio.get_running_loop().run_in_executor`` so the MCP event loop + stays responsive. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from pathlib import Path +from typing import Any, Optional + +from ..server import app + + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _looks_like_url(spec: str) -> bool: + """Return True for HTTP(S) / git URLs, False for local paths.""" + return spec.startswith(("http://", "https://", "git@", "ssh://", "git://")) + + +def _languages_detected(graph) -> list[str]: + """Best-effort enumeration of distinct ``File.ext`` values. + + Returns a sorted list of extension strings (without the leading dot). + Empty when no files were indexed. + """ + try: + rows = graph.g.query( + "MATCH (f:File) RETURN DISTINCT f.ext AS ext" + ).result_set + except Exception as e: # pragma: no cover — defensive + logger.warning("languages_detected query failed: %s", e) + return [] + seen: set[str] = set() + for row in rows or []: + ext = (row[0] or "").lstrip(".") + if ext: + seen.add(ext) + return sorted(seen) + + +def _count(graph, label: str) -> int: + try: + rows = graph.g.query( + f"MATCH (n:{label}) RETURN count(n) AS c" + ).result_set + return int(rows[0][0]) if rows else 0 + except Exception: + return 0 + + +def _count_edges(graph) -> int: + try: + rows = graph.g.query("MATCH ()-[r]->() RETURN count(r) AS c").result_set + return int(rows[0][0]) if rows else 0 + except Exception: + return 0 + + +# --------------------------------------------------------------------------- +# T4 — index_repo +# --------------------------------------------------------------------------- + + +@app.tool( + name="index_repo", + description=( + "Index a code repository into code-graph for subsequent navigation. " + "Accepts a local path or a git URL. When `branch` is omitted, " + "auto-detects the current branch from the local checkout (defaults " + "to '_default' for non-git folders). Returns the indexed graph's " + "node/edge counts, detected languages, and the (project, branch) " + "identity callers should pass to other code-graph tools." + ), +) +async def index_repo( + path_or_url: str, + branch: Optional[str] = None, + incremental: bool = True, # accepted now, fully honored once T18 lands + ignore: Optional[list[str]] = None, +) -> dict[str, Any]: + """Implementation for the ``index_repo`` MCP tool. + + Args: + path_or_url: Filesystem path to a local repository **or** a clonable + git URL (``https://...``, ``git@host:...``, ``ssh://...``). + branch: Branch identity for the indexed graph. When ``None``: + auto-detect from the checkout via ``git rev-parse --abbrev-ref + HEAD``; falls back to ``_default`` if not a git checkout. + incremental: Accepted for forward-compatibility with T18; the + current full-reindex path ignores it. + ignore: List of relative paths to skip during analysis. + """ + + from api.project import Project, detect_branch + + if ignore is None: + ignore = [] + + loop = asyncio.get_running_loop() + + def _do_index() -> dict[str, Any]: + if _looks_like_url(path_or_url): + project = Project.from_git_repository(path_or_url, branch=branch) + else: + local_path = Path(path_or_url).expanduser().resolve() + if not local_path.exists(): + raise ValueError(f"path does not exist: {local_path}") + + # Reject paths outside the allow-list when one is configured. + allowed_root = os.getenv("ALLOWED_ANALYSIS_DIR") + if allowed_root: + allowed = Path(allowed_root).expanduser().resolve() + try: + local_path.relative_to(allowed) + except ValueError as e: + raise ValueError( + f"path {local_path} is outside ALLOWED_ANALYSIS_DIR={allowed}" + ) from e + + # Use Project for git-repo paths so commit metadata is saved, + # otherwise drive SourceAnalyzer directly so non-git folders work. + if (local_path / ".git").is_dir(): + project = Project.from_local_repository(local_path, branch=branch) + else: + # Synthesize a Project-like object so the return shape is uniform. + from api.analyzers.source_analyzer import SourceAnalyzer + from api.graph import Graph + + detected = branch if branch is not None else detect_branch(local_path) + graph = Graph(local_path.name, branch=detected) + analyzer = SourceAnalyzer() + analyzer.analyze_local_folder(str(local_path), graph, ignore) + + class _Synth: # tiny shim to mirror Project's surface + name = local_path.name + + def __init__(self, g, b): + self.graph = g + self.branch = b + + return _payload(_Synth(graph, detected)) + + project.analyze_sources(ignore) + return _payload(project) + + def _payload(project) -> dict[str, Any]: + g = project.graph + return { + "project_name": project.name, + "branch": getattr(project, "branch", None), + "graph_name": g.name, + "num_nodes": ( + _count(g, "File") + _count(g, "Class") + _count(g, "Function") + ), + "num_edges": _count_edges(g), + "languages_detected": _languages_detected(g), + # T18 will flip this to "incremental" when only changed files + # were re-analyzed. + "mode": "full", + } + + return await loop.run_in_executor(None, _do_index) + + +# --------------------------------------------------------------------------- +# T5 — get_callers / get_callees / get_dependencies +# --------------------------------------------------------------------------- + + +def _project_arg(project: str, branch: Optional[str]): + """Return an :class:`AsyncGraphQuery` for ``(project, branch)``.""" + from api.graph import AsyncGraphQuery + + return AsyncGraphQuery(project, branch=branch) + + +def _node_summary(n: Any) -> dict[str, Any]: + """Normalize a FalkorDB Node (or already-encoded dict) to a flat payload. + + ``encode_node`` returns ``{id, labels, properties: {...}}`` because Node + properties live on a nested attribute. Agents want a flat record, and + they also want a single ``label`` (the meaningful one — File, Class, + Function — not the fulltext-index marker ``Searchable``). + """ + if hasattr(n, "properties"): + props = dict(n.properties or {}) + labels = list(n.labels or []) + node_id = getattr(n, "id", None) + else: + d = dict(n) + props = dict(d.get("properties") or {}) + labels = list(d.get("labels") or []) + node_id = d.get("id") + + label = next((lbl for lbl in labels if lbl != "Searchable"), None) + return { + "id": node_id, + "name": props.get("name"), + "label": label, + "file": props.get("path"), + "line": props.get("src_start"), + } + + +def _coerce_node_id(symbol_id: Any) -> int: + """Accept int or stringified int; raise ValueError otherwise. + + The MCP wire format is JSON; agents sometimes hand back the id as a + string. Be permissive on input, strict on type after parsing. + """ + if isinstance(symbol_id, bool): # bool is an int subclass; reject loudly + raise ValueError(f"symbol_id must be an integer, got bool: {symbol_id!r}") + if isinstance(symbol_id, int): + return symbol_id + if isinstance(symbol_id, str) and symbol_id.lstrip("-").isdigit(): + return int(symbol_id) + raise ValueError(f"symbol_id must be an integer id, got: {symbol_id!r}") + + +async def _neighbors_payload( + project: str, + branch: Optional[str], + symbol_id: Any, + rel: str, + direction: str, + limit: int, +) -> list[dict[str, Any]]: + """Shared implementation for caller/callee/dependency tools. + + ``direction`` is ``IN`` (incoming edges, e.g. callers) or ``OUT`` + (outgoing edges, e.g. callees). When ``IN`` we run the inverse Cypher + ``(neighbor)-[:rel]->(target)``; ``AsyncGraphQuery.get_neighbors`` only + walks outgoing edges, so we inline the Cypher here for symmetry. + """ + node_id = _coerce_node_id(symbol_id) + g = _project_arg(project, branch) + try: + if direction == "OUT": + q = ( + f"MATCH (n)-[e:{rel}]->(dest) " + f"WHERE ID(n) = $sid " + f"RETURN dest, type(e) AS rel " + f"LIMIT $limit" + ) + elif direction == "IN": + q = ( + f"MATCH (src)-[e:{rel}]->(n) " + f"WHERE ID(n) = $sid " + f"RETURN src AS dest, type(e) AS rel " + f"LIMIT $limit" + ) + else: + raise ValueError(f"direction must be IN or OUT, got: {direction!r}") + + res = await g._query(q, {"sid": node_id, "limit": int(limit)}) + out: list[dict[str, Any]] = [] + for row in res.result_set: + entry = _node_summary(row[0]) + entry["relation"] = row[1] + entry["direction"] = direction + out.append(entry) + return out + finally: + await g.close() + + +@app.tool( + name="get_callers", + description=( + "Return functions that call the given symbol (incoming CALLS edges). " + "`symbol_id` is the integer node id returned by `search_code` or " + "other tools." + ), +) +async def get_callers( + symbol_id: Any, + project: str, + branch: Optional[str] = None, + limit: int = 50, +) -> list[dict[str, Any]]: + return await _neighbors_payload(project, branch, symbol_id, "CALLS", "IN", limit) + + +@app.tool( + name="get_callees", + description=( + "Return functions that the given symbol calls (outgoing CALLS edges)." + ), +) +async def get_callees( + symbol_id: Any, + project: str, + branch: Optional[str] = None, + limit: int = 50, +) -> list[dict[str, Any]]: + return await _neighbors_payload(project, branch, symbol_id, "CALLS", "OUT", limit) + + +@app.tool( + name="get_dependencies", + description=( + "Return outgoing neighbors of the given symbol across any of the " + "specified relation types (default: IMPORTS, CALLS, DEFINES). " + "Useful for 'what does this depend on' queries." + ), +) +async def get_dependencies( + symbol_id: Any, + project: str, + branch: Optional[str] = None, + rels: Optional[list[str]] = None, + limit: int = 50, +) -> list[dict[str, Any]]: + if rels is None: + rels = ["IMPORTS", "CALLS", "DEFINES"] + # Aggregate across relations; preserve ordering and dedupe by id. + seen: set[Any] = set() + out: list[dict[str, Any]] = [] + for rel in rels: + rows = await _neighbors_payload(project, branch, symbol_id, rel, "OUT", limit) + for row in rows: + key = (row.get("id"), row.get("relation")) + if key in seen: + continue + seen.add(key) + out.append(row) + if len(out) >= limit: + return out + return out + + +# --------------------------------------------------------------------------- +# T7 — find_path +# --------------------------------------------------------------------------- + + +@app.tool( + name="find_path", + description=( + "Return up to `max_paths` CALLS-path sequences from `source_id` to " + "`dest_id`. Useful for 'how does A reach B' questions. Returns an " + "empty list when no path exists." + ), +) +async def find_path( + source_id: Any, + dest_id: Any, + project: str, + branch: Optional[str] = None, + max_paths: int = 10, +) -> list[dict[str, Any]]: + src = _coerce_node_id(source_id) + dst = _coerce_node_id(dest_id) + g = _project_arg(project, branch) + try: + raw = await g.find_paths(src, dst) + finally: + await g.close() + + # ``AsyncGraphQuery.find_paths`` returns each path as an alternating + # [node, edge, node, edge, ..., node] list; we strip edges and surface + # only the node sequence — that's what agents typically want. + paths: list[dict[str, Any]] = [] + for entry in raw[:max_paths]: + node_seq = [ + _node_summary(x) + for x in entry + # Edges in the alternating list carry a top-level ``relation`` + # key (from ``encode_edge``); nodes carry ``properties``. + if isinstance(x, dict) and "properties" in x + ] + paths.append({"path": node_seq}) + return paths + + +# --------------------------------------------------------------------------- +# T8 — search_code +# --------------------------------------------------------------------------- + + +@app.tool( + name="search_code", + description=( + "Prefix-search for symbols (functions, classes, files) whose name " + "starts with `prefix`. Backed by FalkorDB's full-text index. The " + "agent typically calls this first to discover symbol ids for the " + "navigation tools (`get_callers`, `find_path`, ...)." + ), +) +async def search_code( + prefix: str, + project: str, + branch: Optional[str] = None, + limit: int = 20, +) -> list[dict[str, Any]]: + g = _project_arg(project, branch) + try: + raw = await g.prefix_search(prefix) + finally: + await g.close() + return [_node_summary(node) for node in raw[:limit]] + + +# --------------------------------------------------------------------------- +# T6 — impact_analysis (variable-depth Cypher with DISTINCT for cycle safety) +# --------------------------------------------------------------------------- + + +IMPACT_MAX_DEPTH = 10 +"""Hard cap on traversal depth — passed values above this are silently +clamped. Prevents pathological queries (e.g. depth=999) from hammering +FalkorDB while still letting agents request "deep" impact without +hitting an error.""" + + +def _clamp_depth(depth: Any) -> int: + """Coerce ``depth`` to ``1..IMPACT_MAX_DEPTH``. Strings accepted.""" + if isinstance(depth, bool): + raise ValueError(f"depth must be an integer, got bool: {depth!r}") + if isinstance(depth, str) and depth.lstrip("-").isdigit(): + depth = int(depth) + if not isinstance(depth, int): + raise ValueError(f"depth must be an integer, got: {depth!r}") + if depth < 1: + return 1 + if depth > IMPACT_MAX_DEPTH: + return IMPACT_MAX_DEPTH + return depth + + +@app.tool( + name="impact_analysis", + description=( + "Transitive call-graph impact for refactoring: " + "`direction='IN'` returns all upstream callers (what breaks if you " + "change this symbol); `direction='OUT'` returns all downstream " + "callees (what this symbol indirectly depends on). Traverses only " + f"CALLS edges. Depth is clamped to {IMPACT_MAX_DEPTH}; cycles are " + "deduplicated via Cypher DISTINCT (each node appears at most once)." + ), +) +async def impact_analysis( + symbol_id: Any, + project: str, + branch: Optional[str] = None, + direction: str = "IN", + depth: int = 3, +) -> list[dict[str, Any]]: + node_id = _coerce_node_id(symbol_id) + eff_depth = _clamp_depth(depth) + + if direction == "IN": + # Upstream callers: (impacted) -[:CALLS*]-> (n) + q = ( + f"MATCH (n)<-[:CALLS*1..{eff_depth}]-(impacted) " + f"WHERE ID(n) = $sid " + f"RETURN DISTINCT impacted" + ) + elif direction == "OUT": + # Downstream callees: (n) -[:CALLS*]-> (impacted) + q = ( + f"MATCH (n)-[:CALLS*1..{eff_depth}]->(impacted) " + f"WHERE ID(n) = $sid " + f"RETURN DISTINCT impacted" + ) + else: + raise ValueError( + f"direction must be 'IN' (upstream) or 'OUT' (downstream), " + f"got: {direction!r}" + ) + + g = _project_arg(project, branch) + try: + res = await g._query(q, {"sid": node_id}) + finally: + await g.close() + + out: list[dict[str, Any]] = [] + for row in res.result_set: + entry = _node_summary(row[0]) + entry["direction"] = direction + out.append(entry) + return out diff --git a/api/migrations/__init__.py b/api/migrations/__init__.py new file mode 100644 index 00000000..e6f73896 --- /dev/null +++ b/api/migrations/__init__.py @@ -0,0 +1,4 @@ +"""One-shot data migrations for code-graph. + +Each migration is idempotent and safe to re-run. +""" diff --git a/api/migrations/per_branch.py b/api/migrations/per_branch.py new file mode 100644 index 00000000..f54f2223 --- /dev/null +++ b/api/migrations/per_branch.py @@ -0,0 +1,151 @@ +"""T17 (#651): promote legacy graphs into the per-branch namespace. + +Before T17 a repo named ``myrepo`` lived at: + + * Graph key: ``myrepo`` + * Info hash: ``{myrepo}_info`` + * Git graph: ``{myrepo}_git`` + +After T17, the new format is: + + * Graph key: ``code:myrepo:_default`` + * Info hash: ``{myrepo}:_default_info`` + * Git graph: ``{myrepo}:_default_git`` + +This module renames every legacy artifact in place. It is safe to run +multiple times — already-migrated graphs are skipped — and exposes a +``--dry-run`` mode via ``cgraph migrate`` for previewing changes. +""" + +from __future__ import annotations + +import logging +import os +from typing import Iterable + +from falkordb import FalkorDB + +from ..graph import ( + DEFAULT_BRANCH, + compose_graph_name, + parse_graph_name, +) +from ..info import _legacy_repo_info_key, _repo_info_key + + +logger = logging.getLogger(__name__) + + +def _connect() -> FalkorDB: + return FalkorDB( + host=os.getenv("FALKORDB_HOST", "localhost"), + port=os.getenv("FALKORDB_PORT", 6379), + username=os.getenv("FALKORDB_USERNAME", None), + password=os.getenv("FALKORDB_PASSWORD", None), + ) + + +def _legacy_graphs(all_graphs: Iterable[str]) -> list[str]: + """Return graphs that look like the pre-T17 single-name shape.""" + + legacy = [] + for name in all_graphs: + # Already migrated? + if parse_graph_name(name) is not None: + continue + # System graphs etc. — also skip the ``_tmp`` mid-migration name and + # the per-repo ``_git`` companion (handled separately, see below). + if name.endswith("_tmp") or name.endswith("_schema") or name.endswith("_git"): + continue + legacy.append(name) + return legacy + + +def _rename_graph(db: FalkorDB, src: str, dst: str, *, dry_run: bool) -> bool: + """Copy ``src`` to ``dst`` and delete ``src``. Returns ``True`` on success. + + Skips with ``False`` (and a warning) when ``dst`` already exists. + """ + + if db.connection.exists(dst): + logger.warning("Target graph %s already exists — skipping rename of %s", dst, src) + return False + if dry_run: + logger.info("[dry-run] would rename graph %s -> %s", src, dst) + return True + g = db.select_graph(src) + g.copy(dst) + g.delete() + logger.info("Renamed graph %s -> %s", src, dst) + return True + + +def _rename_redis_key(db: FalkorDB, src: str, dst: str, *, dry_run: bool) -> bool: + """Rename a Redis hash key. Idempotent — skips when ``dst`` already exists. + + Uses the underlying ``redis-py`` connection on the FalkorDB client so + we don't need a separate Redis connection. + """ + + conn = db.connection + if not conn.exists(src): + return False + if conn.exists(dst): + logger.warning("Target Redis key %s already exists — skipping rename of %s", dst, src) + return False + if dry_run: + logger.info("[dry-run] would rename Redis key %s -> %s", src, dst) + return True + conn.rename(src, dst) + logger.info("Renamed Redis key %s -> %s", src, dst) + return True + + +def run_migration(*, dry_run: bool = False) -> dict: + """Run the per-branch migration once. + + Returns a small summary dict suitable for the CLI to print as JSON:: + + {"status": "ok", "graphs_renamed": int, "info_renamed": int, + "git_renamed": int, "dry_run": bool, "skipped": list[str]} + """ + + db = _connect() + all_graphs = list(db.list_graphs()) + legacy = _legacy_graphs(all_graphs) + + graphs_renamed = 0 + info_renamed = 0 + git_renamed = 0 + skipped: list[str] = [] + + for project in legacy: + # Rename the main code graph itself. + new_graph = compose_graph_name(project, DEFAULT_BRANCH) + if _rename_graph(db, project, new_graph, dry_run=dry_run): + graphs_renamed += 1 + else: + skipped.append(project) + continue + + # Rename the sibling Redis info key, if any. + legacy_info = _legacy_repo_info_key(project) + new_info = _repo_info_key(project, DEFAULT_BRANCH) + if _rename_redis_key(db, legacy_info, new_info, dry_run=dry_run): + info_renamed += 1 + + # Rename the sibling ``{project}_git`` graph, if any. + legacy_git = "{" + project + "}_git" + new_git = "{" + project + "}:" + DEFAULT_BRANCH + "_git" + if legacy_git in all_graphs: + if _rename_graph(db, legacy_git, new_git, dry_run=dry_run): + git_renamed += 1 + + return { + "status": "ok", + "dry_run": dry_run, + "graphs_renamed": graphs_renamed, + "info_renamed": info_renamed, + "git_renamed": git_renamed, + "skipped": skipped, + } diff --git a/api/project.py b/api/project.py index aed5a9e7..2478c7c4 100644 --- a/api/project.py +++ b/api/project.py @@ -6,7 +6,7 @@ from pygit2.repository import Repository from .info import * from pathlib import Path -from .graph import Graph +from .graph import Graph, DEFAULT_BRANCH from typing import Optional, List from urllib.parse import urlparse from .analyzers import SourceAnalyzer @@ -15,6 +15,30 @@ # Configure logging logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s') + +def detect_branch(path: Path) -> str: + """Resolve the current branch name for a checkout at ``path``. + + Uses ``git rev-parse --abbrev-ref HEAD``. Returns + :data:`api.graph.DEFAULT_BRANCH` when the path is not a git checkout + or when HEAD is detached (the ``rev-parse`` call returns ``HEAD``). + """ + + try: + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=str(path), + capture_output=True, + text=True, + check=True, + ) + branch = (result.stdout or "").strip() + if not branch or branch == "HEAD": + return DEFAULT_BRANCH + return branch + except (FileNotFoundError, subprocess.CalledProcessError): + return DEFAULT_BRANCH + def _clone_source(url: str, name: str) -> Path: # path to local repositories path = Path.cwd() / "repositories" / name @@ -37,17 +61,21 @@ def _clone_source(url: str, name: str) -> Path: return path class Project(): - def __init__(self, name: str, path: Path, url: Optional[str]): - self.url = url - self.name = name - self.path = path - self.graph = Graph(name) + def __init__(self, name: str, path: Path, url: Optional[str], branch: Optional[str] = None): + self.url = url + self.name = name + self.path = path + # Auto-detect branch from the working tree when not explicitly given. + if branch is None: + branch = detect_branch(path) if path is not None and Path(path).exists() else DEFAULT_BRANCH + self.branch = branch + self.graph = Graph(name, branch=self.branch) if url is not None: - save_repo_info(name, url) + save_repo_info(name, url, self.branch) @classmethod - def from_git_repository(cls, url: str): + def from_git_repository(cls, url: str, branch: Optional[str] = None): # Validate url if not validators.url(url): raise Exception(f"invalid url: {url}") @@ -57,10 +85,10 @@ def from_git_repository(cls, url: str): name = parsed_url.path.split('/')[-1] path = _clone_source(url, name) - return cls(name, path, url) + return cls(name, path, url, branch=branch) @classmethod - def from_local_repository(cls, path: Path|str): + def from_local_repository(cls, path: Path|str, branch: Optional[str] = None): path = Path(path) if isinstance(path, str) else path # Validate path exists @@ -74,7 +102,7 @@ def from_local_repository(cls, path: Path|str): name = path.name - return cls(name, path, url) + return cls(name, path, url, branch=branch) def analyze_sources(self, ignore: Optional[List[str]] = None) -> Graph: if ignore is None: @@ -86,7 +114,7 @@ def analyze_sources(self, ignore: Optional[List[str]] = None) -> Graph: # Save processed commit hash to the DB repo = Repository(self.path) current_commit = repo.walk(repo.head.target).__next__() - set_repo_commit(self.name, current_commit.short_id) + set_repo_commit(self.name, current_commit.short_id, self.branch) except Exception: # Probably not .git folder is missing pass @@ -103,7 +131,7 @@ def process_git_history(self, ignore: Optional[List[str]] = []) -> GitGraph: logging.info(f"Switching current working directory to: {self.path}") os.chdir(self.path) - git_graph = build_commit_graph(self.path, self.analyzer, self.name, ignore) + git_graph = build_commit_graph(self.path, self.analyzer, self.name, ignore, branch=self.branch) # Restore original working directory logging.info(f"Restoring current working directory to: {original_dir}") diff --git a/bench/.gitignore b/bench/.gitignore new file mode 100644 index 00000000..fdedd30c --- /dev/null +++ b/bench/.gitignore @@ -0,0 +1,7 @@ +cache/ +*.jsonl +report/results.md +report/results.jsonl +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 00000000..4b4ebbeb --- /dev/null +++ b/bench/README.md @@ -0,0 +1,59 @@ +# Benchmark workstream + +Quantify code-graph's value to a coding agent vs: + +- `baseline` — no navigation tools (file read/write/grep/bash only). +- `lsp` — multilspy-driven pyright tools. +- `code-graph` — primitive graph operations against this repo's HTTP API. + +See `CONTEXT.md` at the repo root for the glossary and locked-in +decisions. See the session plan at +`~/.copilot/session-state//plan.md` for the full design doc and the +deferred pre-requisites. + +## Status + +**Scaffold only.** Directory layout and contracts exist; runners do not. +Next steps are tracked in the session's todo list. + +## Layout + +```text +bench/ + agents/ # (planned) thin adapters around SWE-agent + runners/ # (planned) swe_bench.py + metrics/ # (planned) token + accuracy from SWE-agent trajectories + report/ # (planned) JSONL -> markdown table aggregator + configs/ # YAML run configs (model, temperature, split, budget) + cache/ # FalkorDB graph cache keyed by @ (gitignored) + tools/ + baseline/ # SWE-agent default tools (no navigation) + lsp/ # baseline + pyright tools via multilspy + shim + code_graph/ # baseline + primitive graph tools (graph_entities, + # get_neighbors, find_paths, auto_complete, + # find_symbol, note_edit) +``` + +## Headline metric + +- **Outcome accuracy** — SWE-bench-Verified-sample patch-pass rate + (pass@1, temperature 0, retry stochastic failures 2×). +- **Token cost** — LLM in+out tokens per task; report median, p90, + and Δ vs baseline. **Indexing cost reported separately**; never + combined with per-task token cost. + +## Run targets (planned Makefile) + +```text +make bench-smoke # stage 1: 3 hand-picked tasks x 3 configs +make bench-calibrate # stage 2: 10 random tasks x 3 configs +make bench-headline # stage 3: 37 remaining tasks x 3 configs (pass@1+retry) +make bench-report # aggregate JSONL into bench/report/results.md +``` + +## Out of scope (decided during the grill) + +- RepoBench — shape mismatch with code-graph's node-granularity outputs. +- opencode qualitative track — marketing, not validity. +- Intrinsic retrieval diagnostic — focus on the outcome question. +- Raw-LSP comparison run — shim is documented and consistent. diff --git a/bench/agents/__init__.py b/bench/agents/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bench/agents/code_graph_adapter.py b/bench/agents/code_graph_adapter.py new file mode 100644 index 00000000..96f5cfdf --- /dev/null +++ b/bench/agents/code_graph_adapter.py @@ -0,0 +1,175 @@ +"""HTTP adapter to code-graph's FastAPI service for the benchmark. + +The benchmark agent runs inside SWE-agent's container; this module is the +in-container tool surface for the `code_graph` config. Each public function +maps 1:1 onto an SWE-agent tool name registered in +`bench/tools/code_graph/tools.yaml`. + +We talk to the host-side code-graph service over HTTP (see backend.service_url +in tools.yaml). The five primitives below — plus `find_symbol` and +`note_edit` — are the only tools the code-graph config gets. The GraphRAG +`chat` endpoint is intentionally NOT exposed (Q2 grill decision). + +This module is pure-HTTP (httpx). It does no model calls and no graph +traversal of its own. Token cost on the code-graph side comes only from +the agent's LLM reading these tool responses. +""" + +from __future__ import annotations + +import os +from typing import Any + +import httpx + + +DEFAULT_TIMEOUT_SEC = 30.0 + + +class CodeGraphClient: + """Thin synchronous client. SWE-agent tools are typically sync.""" + + def __init__( + self, + base_url: str | None = None, + token: str | None = None, + timeout: float = DEFAULT_TIMEOUT_SEC, + *, + transport: httpx.BaseTransport | None = None, + ) -> None: + self.base_url = (base_url or os.getenv("CODEGRAPH_URL") + or "http://host.docker.internal:5000").rstrip("/") + self.token = token or os.getenv("SECRET_TOKEN") or os.getenv("CODEGRAPH_TOKEN") + headers = {} + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + self._client = httpx.Client( + base_url=self.base_url, + headers=headers, + timeout=timeout, + transport=transport, + ) + + def close(self) -> None: + self._client.close() + + def __enter__(self) -> CodeGraphClient: + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + # ------------------------------------------------------------------ + # Primitive tools — names match bench/tools/code_graph/tools.yaml + # ------------------------------------------------------------------ + + def graph_entities(self, repo: str) -> dict[str, Any]: + """Fetch the sub-graph for a repo (paginated server-side).""" + r = self._client.get("/api/graph_entities", params={"repo": repo}) + r.raise_for_status() + return r.json() + + def get_neighbors(self, repo: str, node_ids: list[int]) -> dict[str, Any]: + r = self._client.post( + "/api/get_neighbors", + json={"repo": repo, "node_ids": list(node_ids)}, + ) + r.raise_for_status() + return r.json() + + def find_paths(self, repo: str, src: int, dest: int) -> dict[str, Any]: + r = self._client.post( + "/api/find_paths", + json={"repo": repo, "src": src, "dest": dest}, + ) + r.raise_for_status() + return r.json() + + def auto_complete(self, repo: str, prefix: str) -> dict[str, Any]: + r = self._client.post( + "/api/auto_complete", + json={"repo": repo, "prefix": prefix}, + ) + r.raise_for_status() + return r.json() + + # ------------------------------------------------------------------ + # Helper tools — bench-only, not part of the FastAPI surface + # ------------------------------------------------------------------ + + def find_symbol(self, repo: str, name: str) -> list[dict[str, Any]]: + """Exact-name lookup. Thin wrapper around auto_complete + filter. + + auto_complete returns prefix matches; the agent often wants exact + matches. Doing this client-side keeps the FastAPI surface untouched. + + The auto_complete payload nests the symbol name under + `item["properties"]["name"]` (FalkorDB node properties), so we look + there first and only fall back to a top-level `name` for older / + flatter shapes the tests may pass in. + """ + payload = self.auto_complete(repo, name) + results = payload.get("completions") or payload.get("results") or payload + if isinstance(results, dict): + results = results.get("items", []) + out: list[dict[str, Any]] = [] + for item in (results or []): + if not isinstance(item, dict): + continue + props = item.get("properties") if isinstance(item.get("properties"), dict) else {} + if props.get("name") == name or item.get("name") == name: + out.append(item) + return out + + def note_edit(self, repo: str, path: str) -> dict[str, Any]: + """Tell code-graph the agent just edited `path`; trigger an + incremental re-index of that single file. + + Implemented by hitting analyze_folder with the file's directory. + Until the backend grows a true single-file endpoint this is the + cheapest available approximation. Failures are non-fatal — the + agent should continue and accept a slightly stale graph. + """ + try: + r = self._client.post( + "/api/analyze_folder", + json={"path": os.path.dirname(path) or path, "ignore": []}, + ) + r.raise_for_status() + return {"ok": True, "reindexed": path} + except httpx.HTTPError as exc: + return {"ok": False, "error": str(exc), "path": path} + + +# Convenience function aliases — the SWE-agent tool registry expects +# top-level callables. Each spins up a short-lived client; for hot loops +# the agent should keep a CodeGraphClient open instead. + +def graph_entities(repo: str, **kw: Any) -> dict[str, Any]: + with CodeGraphClient(**kw) as c: + return c.graph_entities(repo) + + +def get_neighbors(repo: str, node_ids: list[int], **kw: Any) -> dict[str, Any]: + with CodeGraphClient(**kw) as c: + return c.get_neighbors(repo, node_ids) + + +def find_paths(repo: str, src: int, dest: int, **kw: Any) -> dict[str, Any]: + with CodeGraphClient(**kw) as c: + return c.find_paths(repo, src, dest) + + +def auto_complete(repo: str, prefix: str, **kw: Any) -> dict[str, Any]: + with CodeGraphClient(**kw) as c: + return c.auto_complete(repo, prefix) + + +def find_symbol(repo: str, name: str, **kw: Any) -> list[dict[str, Any]]: + with CodeGraphClient(**kw) as c: + return c.find_symbol(repo, name) + + +def note_edit(repo: str, path: str, **kw: Any) -> dict[str, Any]: + with CodeGraphClient(**kw) as c: + return c.note_edit(repo, path) diff --git a/bench/agents/code_graph_mcp_adapter.py b/bench/agents/code_graph_mcp_adapter.py new file mode 100644 index 00000000..31b7c8b7 --- /dev/null +++ b/bench/agents/code_graph_mcp_adapter.py @@ -0,0 +1,211 @@ +"""MCP-transport adapter to cgraph-mcp for the benchmark. + +Sibling of `code_graph_adapter.py` (HTTP). Where the HTTP adapter talks +to the host FastAPI service over the network, this one spawns the +`cgraph-mcp` stdio MCP server in-process via the official MCP Python +SDK and dispatches tool calls over JSON-RPC. + +This gives us a second, real-world benchmark track that exercises the +exact same transport agents (Claude Code, Cursor, …) will use in +production. Tool names match the 8-tool MCP surface +(`index_repo`, `search_code`, `get_callers`, `get_callees`, +`get_dependencies`, `impact_analysis`, `find_path`, `ask`). + +Each call spawns a fresh server, runs the call, and exits. That's +~0.5-1s overhead per call but keeps the model trivially safe to call +from a bash shim (one process per invocation, no shared state). +A future optimisation could persist the server across calls via a +side-channel daemon, but per-call spawn matches how external agents +actually use MCP servers today. +""" + +from __future__ import annotations + +import asyncio +import json +import os +from typing import Any + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + + +def _default_timeout() -> float: + """Pick the per-call MCP timeout, honoring CGRAPH_MCP_TIMEOUT_SEC. + + Defaults to 900 s (15 min). Bumped from the historical 300 s after + we observed `index_repo` on full SWE-bench worktrees (sphinx-doc, + sympy) finish in 5-10 min when spawned via the MCP stdio transport + rather than the warm in-process HTTP path. + """ + try: + return float(os.environ.get("CGRAPH_MCP_TIMEOUT_SEC", "900")) + except ValueError: + return 900.0 + + +DEFAULT_TIMEOUT_SEC = _default_timeout() + + +def _env_for_mcp() -> dict[str, str]: + """Build the env for the spawned cgraph-mcp process. + + Pass through everything from the caller but make sure the FalkorDB + coordinates are present — the runner usually sets them to point at + the host FalkorDB container. + + Also default ``CODE_GRAPH_PY_RESOLVER=tree_sitter``. Without this, + the spawned server falls back to the legacy jedi/multilspy path, + which does ``python -m venv && pip install poetry && poetry install`` + per repo before analyzing the transitive dep tree — wedges for hours + on large SWE-bench repos (sphinx, sympy). The HTTP track gets this + via ``bench/scripts/start-api.sh``; we mirror it here so the MCP + track is symmetric regardless of how the caller shell is configured. + """ + env = dict(os.environ) + env.setdefault("FALKORDB_HOST", os.environ.get("FALKORDB_HOST", "127.0.0.1")) + env.setdefault("FALKORDB_PORT", os.environ.get("FALKORDB_PORT", "6379")) + env.setdefault("CODE_GRAPH_PY_RESOLVER", "tree_sitter") + return env + + +def _extract(result: Any) -> Any: + """Normalize a CallToolResult into a JSON-serialisable Python value. + + FastMCP serializes list returns as **N separate TextContent + chunks** (one per item) AND echoes the full list in + ``structuredContent['result']``. Earlier versions of this helper + returned only the *first* TextContent chunk, which meant every + list-returning tool (``search_code``, ``get_callers``, + ``get_callees``, ``get_dependencies``, ``impact_analysis``, + ``find_path``) silently returned just the first element. On + sympy-19040 the Opus agent searched "factor", got back one record + instead of ten, gave up on the graph entirely and burned ~50 turns + on bash exploration — cg_mcp ended up at +35% vs baseline. + + The new policy: prefer ``structuredContent`` when present (it + always carries the full payload), unwrapping the spec's + ``{"result": ...}`` envelope. Fall back to concatenating the text + chunks (which may be JSON-each or a single JSON document). + """ + struct = getattr(result, "structuredContent", None) + if isinstance(struct, dict): + if set(struct.keys()) == {"result"}: + return struct["result"] + return struct + + chunks: list[str] = [c.text for c in (result.content or []) if getattr(c, "text", None)] + if not chunks: + return None + # Try parsing each chunk as JSON and assembling them. If every + # chunk parses, we likely had a per-item list serialization; if + # only one chunk parses, that's the whole payload. + parsed: list[Any] = [] + for c in chunks: + try: + parsed.append(json.loads(c)) + except json.JSONDecodeError: + return "\n".join(chunks) + if len(parsed) == 1: + return parsed[0] + return parsed + + +async def _call_tool_async(name: str, arguments: dict[str, Any], timeout: float) -> Any: + params = StdioServerParameters(command="cgraph-mcp", args=[], env=_env_for_mcp()) + # Silence the server's stderr (analyzer + MCP DEBUG logs). When the + # bash shim is invoked by the agent, the server's stderr is merged + # into the agent's tool-output buffer, inflating context by ~1.8kB + # per call. The agent only needs the JSON-RPC result on stdout. + devnull = open(os.devnull, "w") + async with stdio_client(params, errlog=devnull) as (read, write): + async with ClientSession(read, write) as session: + await asyncio.wait_for(session.initialize(), timeout=timeout) + result = await asyncio.wait_for( + session.call_tool(name, arguments), timeout=timeout + ) + payload = _extract(result) + if getattr(result, "isError", False): + return {"error": payload} + return payload + + +def call_tool(name: str, arguments: dict[str, Any], *, timeout: float = DEFAULT_TIMEOUT_SEC) -> Any: + """Sync entry point for the bash shim. One spawn per call.""" + return asyncio.run(_call_tool_async(name, arguments, timeout)) + + +# ── Top-level convenience wrappers ───────────────────────────────────── +# Names map 1:1 onto MCP tool names (and onto bench/tools/code_graph_mcp/ +# tools.yaml entries). Kwargs mirror each tool's MCP arg schema. + + +def index_repo(path_or_url: str, branch: str | None = None, ignore: list[str] | None = None) -> dict[str, Any]: + args: dict[str, Any] = {"path_or_url": path_or_url} + if branch is not None: + args["branch"] = branch + if ignore is not None: + args["ignore"] = ignore + return call_tool("index_repo", args) + + +def search_code(prefix: str, project: str, branch: str | None = None, limit: int = 10) -> Any: + args: dict[str, Any] = {"prefix": prefix, "project": project, "limit": limit} + if branch is not None: + args["branch"] = branch + return call_tool("search_code", args) + + +def _neighbors(tool: str, symbol_id: int, project: str, branch: str | None, limit: int) -> Any: + args: dict[str, Any] = {"symbol_id": symbol_id, "project": project, "limit": limit} + if branch is not None: + args["branch"] = branch + return call_tool(tool, args) + + +def get_callers(symbol_id: int, project: str, branch: str | None = None, limit: int = 50) -> Any: + return _neighbors("get_callers", symbol_id, project, branch, limit) + + +def get_callees(symbol_id: int, project: str, branch: str | None = None, limit: int = 50) -> Any: + return _neighbors("get_callees", symbol_id, project, branch, limit) + + +def get_dependencies(symbol_id: int, project: str, branch: str | None = None, limit: int = 50) -> Any: + return _neighbors("get_dependencies", symbol_id, project, branch, limit) + + +def impact_analysis( + symbol_id: int, + project: str, + branch: str | None = None, + direction: str = "IN", + depth: int = 3, +) -> Any: + args: dict[str, Any] = { + "symbol_id": symbol_id, + "project": project, + "direction": direction, + "depth": depth, + } + if branch is not None: + args["branch"] = branch + return call_tool("impact_analysis", args) + + +def find_path(source_id: int, dest_id: int, project: str, branch: str | None = None) -> Any: + args: dict[str, Any] = { + "source_id": source_id, + "dest_id": dest_id, + "project": project, + } + if branch is not None: + args["branch"] = branch + return call_tool("find_path", args) + + +def ask(question: str, project: str, branch: str | None = None) -> Any: + args: dict[str, Any] = {"question": question, "project": project} + if branch is not None: + args["branch"] = branch + return call_tool("ask", args) diff --git a/bench/agents/lsp_adapter.py b/bench/agents/lsp_adapter.py new file mode 100644 index 00000000..aee8f2e6 --- /dev/null +++ b/bench/agents/lsp_adapter.py @@ -0,0 +1,241 @@ +"""LSP adapter for the `lsp` benchmark config. + +Wraps multilspy's `SyncLanguageServer` so we can expose a small set of +LSP-backed navigation tools to SWE-agent. Every response goes through the +shim defined in `bench/tools/lsp/shim.yaml` so raw LSP verbosity doesn't +dominate the token-cost comparison. + +Notes on the language server choice: +- multilspy 0.0.11 (current pinned version, latest that resolves under our + Python 3.13 constraints) ships **jedi-language-server** for Python, not + pyright. The benchmark validity claim doesn't hinge on jedi-vs-pyright: + the shim normalizes responses to {path, line, col} + 1-line hover, and + both servers are competent Python LSPs. If we later need pyright, we'd + bump multilspy to ≥0.0.15. +- multilspy 0.0.11 also has no `request_workspace_symbol`. We therefore + drop `workspace_symbols` from the LSP tool set; the agent falls back to + bash+grep, which is what real LSP workflows actually do. +""" + +from __future__ import annotations + +import logging +import os +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterator + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Shim configuration — mirrors bench/tools/lsp/shim.yaml +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class LSPShim: + max_results_per_call: int = 50 + hover_signature_lines: int = 1 + hover_docstring_sentences: int = 1 + + +DEFAULT_SHIM = LSPShim() + + +def _trim_hover(contents: Any, shim: LSPShim) -> str: + """Reduce an LSP Hover contents blob to 1 signature + 1 docstring sentence.""" + text = _hover_to_str(contents) + if not text: + return "" + # Drop fence-only lines and empty lines, then split into signature vs rest. + real_lines = [ + ln for ln in text.splitlines() + if ln.strip() and not ln.strip().startswith("```") + ] + signature_part = real_lines[: shim.hover_signature_lines] + rest_lines = real_lines[shim.hover_signature_lines :] + docstring = " ".join(ln.lstrip("> ").strip() for ln in rest_lines) + sentences = _split_sentences(docstring, shim.hover_docstring_sentences) + + parts: list[str] = [] + parts.extend(signature_part) + if sentences: + parts.append(sentences) + return "\n".join(parts).strip() + + +def _hover_to_str(contents: Any) -> str: + if contents is None: + return "" + if isinstance(contents, str): + return contents + if isinstance(contents, dict): + return str(contents.get("value", "")) + if isinstance(contents, list): + return "\n".join(_hover_to_str(c) for c in contents) + return str(contents) + + +def _split_sentences(text: str, n: int) -> str: + text = text.strip() + if not text or n <= 0: + return "" + # Lightweight sentence split — good enough for docstrings. + parts: list[str] = [] + buf: list[str] = [] + for ch in text: + buf.append(ch) + if ch in ".!?": + parts.append("".join(buf).strip()) + buf = [] + if len(parts) >= n: + break + if not parts and buf: + parts.append("".join(buf).strip()) + return " ".join(parts[:n]) + + +def _location_to_dict(loc: dict[str, Any]) -> dict[str, Any]: + """Convert a multilspy Location (TypedDict) to the shimmed shape.""" + rng = loc.get("range") or {} + start = rng.get("start") or {} + path = loc.get("relativePath") or loc.get("absolutePath") or "" + return { + "path": path, + "line": int(start.get("line", 0)), + "col": int(start.get("character", 0)), + } + + +def _cap(items: list[Any], shim: LSPShim) -> list[Any]: + return items[: shim.max_results_per_call] + + +# --------------------------------------------------------------------------- +# Client +# --------------------------------------------------------------------------- + +class LSPClient: + """Lazily-started multilspy session for a single repo root. + + Use as a context manager; the underlying language server subprocess + lives only inside `with server_running()`. + """ + + def __init__(self, repo_root: str | Path, language: str = "python", + shim: LSPShim = DEFAULT_SHIM, + environment_path: str | None = None) -> None: + self.repo_root = str(Path(repo_root).resolve()) + self.language = language + self.shim = shim + self._env_path = environment_path + self._server: Any | None = None # SyncLanguageServer + + # ----- lifecycle ------------------------------------------------------ + + def _build_server(self) -> Any: + # Local imports keep this module importable without multilspy installed. + from multilspy import SyncLanguageServer + from multilspy.multilspy_config import MultilspyConfig + from multilspy.multilspy_logger import MultilspyLogger + + # NOTE: The multilspy fork we depend on expects MultilspyConfig built + # via `from_dict` (the constructor doesn't initialize all fields the + # JediServer reads). For Python we also need an environment_path so + # jedi knows where to look for installed packages — fall back to the + # current interpreter's prefix if the caller doesn't provide one. + import sys as _sys + config_dict: dict[str, Any] = {"code_language": self.language} + if self.language == "python": + config_dict["environment_path"] = self._env_path or _sys.prefix + else: + # Pass through if caller explicitly set an env path. + if self._env_path: + config_dict["environment_path"] = self._env_path + config = MultilspyConfig.from_dict(config_dict) + logger_ = MultilspyLogger() + return SyncLanguageServer.create(config, logger_, self.repo_root) + + @contextmanager + def server_running(self) -> Iterator["LSPClient"]: + self._server = self._build_server() + with self._server.start_server(): + try: + yield self + finally: + self._server = None + + # ----- relative path normalization ----------------------------------- + + def _rel(self, file_path: str) -> str: + p = Path(file_path) + if p.is_absolute(): + try: + return str(p.relative_to(self.repo_root)) + except ValueError: + return str(p) + return file_path + + # ----- tools (names mirror bench/tools/lsp/tools.yaml) --------------- + + def goto_definition(self, file: str, line: int, col: int) -> list[dict[str, Any]]: + assert self._server is not None, "LSP server not started" + raw = self._server.request_definition(self._rel(file), line, col) or [] + return _cap([_location_to_dict(loc) for loc in raw], self.shim) + + def find_references(self, file: str, line: int, col: int) -> list[dict[str, Any]]: + assert self._server is not None, "LSP server not started" + raw = self._server.request_references(self._rel(file), line, col) or [] + return _cap([_location_to_dict(loc) for loc in raw], self.shim) + + def hover(self, file: str, line: int, col: int) -> dict[str, Any]: + assert self._server is not None, "LSP server not started" + raw = self._server.request_hover(self._rel(file), line, col) + if not raw: + return {"text": ""} + return {"text": _trim_hover(raw.get("contents"), self.shim)} + + def document_symbols(self, file: str) -> list[dict[str, Any]]: + assert self._server is not None, "LSP server not started" + raw = self._server.request_document_symbols(self._rel(file)) + # multilspy returns (symbols, tree). We only want the flat list. + symbols = raw[0] if isinstance(raw, tuple) else (raw or []) + out: list[dict[str, Any]] = [] + for s in symbols: + loc = s.get("location") or {} + d = _location_to_dict(loc) if loc else {"path": self._rel(file), "line": 0, "col": 0} + d["name"] = s.get("name", "") + d["kind"] = s.get("kind", "") + out.append(d) + return _cap(out, self.shim) + + +# --------------------------------------------------------------------------- +# Module-level callables — what SWE-agent tool registries expect +# --------------------------------------------------------------------------- + +def _client_from_env(shim: LSPShim = DEFAULT_SHIM) -> LSPClient: + repo_root = os.environ.get("LSP_REPO_ROOT") or os.getcwd() + language = os.environ.get("LSP_LANGUAGE", "python") + return LSPClient(repo_root=repo_root, language=language, shim=shim) + + +def goto_definition(file: str, line: int, col: int) -> list[dict[str, Any]]: + with _client_from_env().server_running() as c: + return c.goto_definition(file, line, col) + + +def find_references(file: str, line: int, col: int) -> list[dict[str, Any]]: + with _client_from_env().server_running() as c: + return c.find_references(file, line, col) + + +def hover(file: str, line: int, col: int) -> dict[str, Any]: + with _client_from_env().server_running() as c: + return c.hover(file, line, col) + + +def document_symbols(file: str) -> list[dict[str, Any]]: + with _client_from_env().server_running() as c: + return c.document_symbols(file) diff --git a/bench/cli/__init__.py b/bench/cli/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bench/cli/cg b/bench/cli/cg new file mode 100755 index 00000000..5c4ef685 --- /dev/null +++ b/bench/cli/cg @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Bash-callable entry point for the code-graph CLI. The runner puts +# bench/cli on PATH so the agent can invoke `cg ...` directly. +# BENCH_PYTHON is set by the runner to the venv's python; falls back +# to system python3 if missing (won't have deps; will fail loudly). +# +# stdin is redirected from /dev/null defensively: mini-swe-agent's +# LocalEnvironment runs commands via subprocess.run(shell=True) without +# specifying stdin. When the runner is nohup-detached, the inherited +# FD 0 can be closed/invalid, causing Python to crash at startup with +# `init_sys_streams: Bad file descriptor` before our code ever runs. +# Closing/redirecting stdin to /dev/null gives Python a valid FD. +# -I would also work but we want sys.path manipulation if needed. +exec "${BENCH_PYTHON:-python3}" -m bench.cli.cg "$@" Any: + if not isinstance(n, dict): + return n + out: dict[str, Any] = {} + props = n.get("properties") or {} + for k in _NODE_KEEP: + if k in n and n[k] not in (None, "", [], {}): + out[k] = n[k] + elif k in props and props[k] not in (None, "", [], {}): + out[k] = props[k] + return out + + +def _compact_edge(e: Any) -> Any: + if not isinstance(e, dict): + return e + out: dict[str, Any] = {} + for k in _EDGE_KEEP: + v = e.get(k) + if v not in (None, "", [], {}): + out[k] = v + return out + + +def _compact_neighbors(payload: dict[str, Any], limit: int | None) -> dict[str, Any]: + """Strip empty properties + alias and apply optional limit.""" + if not isinstance(payload, dict): + return payload + n = payload.get("neighbors") or payload + nodes = [_compact_node(x) for x in (n.get("nodes") or [])] + edges = [_compact_edge(x) for x in (n.get("edges") or [])] + if limit is not None and limit > 0: + nodes = nodes[:limit] + edges = edges[:limit] + out: dict[str, Any] = {"nodes": nodes, "edges": edges} + if "branch" in payload: + out["branch"] = payload["branch"] + return out + + +def _compact_symbols(payload: Any) -> Any: + """Trim find-symbol / auto-complete records to the fields the agent needs. + + The HTTP responses vary in shape: + - find_symbol: ``[node, ...]`` + - auto_complete: ``{"branch": ..., "completions": [node, ...]}`` + Compact both consistently. + """ + if isinstance(payload, list): + return [_compact_node(x) for x in payload] + if isinstance(payload, dict): + for key in ("completions", "results", "matches", "items"): + if key in payload: + out = {k: v for k, v in payload.items() if k != key} + out[key] = [_compact_node(x) for x in (payload[key] or [])] + return out + return payload + + +def _print(obj: object) -> None: + # Compact separators shave ~30 % off vs the default indented form, which the + # LLM doesn't need (it ignores whitespace). + json.dump(obj, sys.stdout, separators=(",", ":"), sort_keys=True, default=str) + sys.stdout.write("\n") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="cg", description=__doc__) + sub = parser.add_subparsers(dest="cmd", required=True) + + def add_repo(p: argparse.ArgumentParser) -> None: + p.add_argument("--repo", required=True, help="repository name in the graph") + + ge = sub.add_parser("graph-entities") + add_repo(ge) + + gn = sub.add_parser("get-neighbors") + add_repo(gn) + gn.add_argument("--ids", type=int, nargs="+", required=True) + gn.add_argument("--limit", type=int, default=50, + help="cap nodes/edges in response (default 50, 0 = unlimited)") + + fp = sub.add_parser("find-paths") + add_repo(fp) + fp.add_argument("--src", type=int, required=True) + fp.add_argument("--dst", type=int, required=True) + + ac = sub.add_parser("auto-complete") + add_repo(ac) + ac.add_argument("--prefix", required=True) + + fs = sub.add_parser("find-symbol") + add_repo(fs) + fs.add_argument("--name", required=True) + + ne = sub.add_parser("note-edit") + add_repo(ne) + ne.add_argument("--path", required=True) + + args = parser.parse_args(argv) + + with CodeGraphClient() as c: + if args.cmd == "graph-entities": + _print(c.graph_entities(args.repo)) + elif args.cmd == "get-neighbors": + limit = args.limit if args.limit > 0 else None + _print(_compact_neighbors(c.get_neighbors(args.repo, args.ids), limit)) + elif args.cmd == "find-paths": + _print(c.find_paths(args.repo, args.src, args.dst)) + elif args.cmd == "auto-complete": + _print(_compact_symbols(c.auto_complete(args.repo, args.prefix))) + elif args.cmd == "find-symbol": + _print(_compact_symbols(c.find_symbol(args.repo, args.name))) + elif args.cmd == "note-edit": + _print(c.note_edit(args.repo, args.path)) + else: + parser.error(f"unknown command {args.cmd}") + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bench/cli/cg_mcp.py b/bench/cli/cg_mcp.py new file mode 100644 index 00000000..a039d6ea --- /dev/null +++ b/bench/cli/cg_mcp.py @@ -0,0 +1,228 @@ +"""`cg-mcp` — bash-callable CLI exposing code-graph's 8 MCP tools. + +This is the MCP-transport sibling of `cg`. Where `cg` calls the host +FastAPI service over HTTP, `cg-mcp` spawns the `cgraph-mcp` stdio +server (via the official MCP Python SDK) for every invocation and +dispatches one tool call. + +The MCP track is what external agents (Claude Code, Cursor, …) use +in production; benchmarking through it tells us how the *real-world* +integration behaves under SWE-bench, not just the in-process FastAPI +adapter. + +Subcommands mirror the MCP tool names: + + cg-mcp index_repo --path-or-url . [--branch B] [--ignore PAT ...] + cg-mcp search_code --project P --prefix STR [--branch B] [--limit N] + cg-mcp get_callers --project P --symbol-id ID [--branch B] [--limit N] + cg-mcp get_callees --project P --symbol-id ID [--branch B] [--limit N] + cg-mcp get_dependencies --project P --symbol-id ID [--branch B] [--limit N] + cg-mcp impact_analysis --project P --symbol-id ID [--direction IN|OUT] [--depth N] + cg-mcp find_path --project P --source-id ID --dest-id ID [--branch B] + cg-mcp ask --project P --question "..." [--branch B] + +Output: one JSON document per call on stdout. Errors print to stderr +and exit non-zero. + +Env: FALKORDB_HOST / FALKORDB_PORT are passed through to the spawned +server. Optionally set CGRAPH_MCP_TIMEOUT_SEC to override the +default 900s timeout. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import Any + +from bench.agents import code_graph_mcp_adapter as cgm + + +# --------------------------------------------------------------------------- +# Output compaction (iter2): keep token budget bounded +# --------------------------------------------------------------------------- +# +# Iter1 (chunk-fix) un-broke list returns from the MCP layer. That exposed +# two unbounded payload sources that re-feed every LLM turn: +# +# 1. `impact_analysis` has no `--limit` — a depth=3 traversal on a +# large project (sympy: 142k edges) routinely returns 500+ nodes. +# 2. Every node's `file` field is an absolute worktree path +# (`/Users/.../worktrees//sympy/printing/latex.py`, +# ~130 chars). The 100+-char prefix is repeated for every entry and +# contributes nothing the agent can act on. +# +# We strip the worktree prefix and cap list outputs at the CLI layer so the +# MCP server tools stay unchanged. Caps default to 50 (matches the other +# tool CLIs) and can be overridden per call. + +# Heuristic worktree-prefix patterns we want to strip from `file` fields. +# We match in order; first hit wins. The "//" segment is added +# dynamically at call time because the project name is per-invocation. +_WORKTREE_FRAGMENTS = ( + "/.worktrees/", # git worktree layout + "/bench/cache/worktrees/", # bench harness layout +) + + +def _strip_worktree_prefix(path: Any, project: str | None) -> Any: + """Convert an absolute file path under a project worktree to repo-relative. + + Returns the input unchanged for non-strings or paths we don't recognize. + """ + if not isinstance(path, str) or not project: + return path + needle = f"/{project}/" + idx = path.find(needle) + if idx < 0: + return path + return path[idx + len(needle):] + + +def _compact_entry(entry: Any, project: str | None) -> Any: + """Drop noise from a single node-summary dict.""" + if not isinstance(entry, dict): + return entry + out: dict[str, Any] = {} + for k, v in entry.items(): + if v in (None, "", [], {}): + continue + if k == "file": + v = _strip_worktree_prefix(v, project) + out[k] = v + return out + + +def _compact_list(items: Any, project: str | None, limit: int | None) -> Any: + """Apply `_compact_entry` + truncate to `limit`.""" + if not isinstance(items, list): + return items + compacted = [_compact_entry(x, project) for x in items] + if limit is not None and limit > 0: + compacted = compacted[:limit] + return compacted + + +def _print(obj: Any) -> None: + # Compact JSON: agents don't care about indentation, and every byte we + # save here is re-fed to the LLM every subsequent turn. + json.dump(obj, sys.stdout, separators=(",", ":"), sort_keys=True, default=str) + sys.stdout.write("\n") + + +def _timeout() -> float: + try: + return float(os.getenv("CGRAPH_MCP_TIMEOUT_SEC", "900")) + except ValueError: + return 900.0 + + +def _add_project(p: argparse.ArgumentParser) -> None: + p.add_argument("--project", required=True) + p.add_argument("--branch", default=None) + + +def _add_symbol(p: argparse.ArgumentParser) -> None: + p.add_argument("--symbol-id", type=int, required=True, dest="symbol_id") + p.add_argument("--limit", type=int, default=50) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="cg-mcp", description=__doc__) + sub = parser.add_subparsers(dest="cmd", required=True) + + ir = sub.add_parser("index_repo") + ir.add_argument("--path-or-url", required=True, dest="path_or_url") + ir.add_argument("--branch", default=None) + ir.add_argument("--ignore", nargs="*", default=None) + + sc = sub.add_parser("search_code") + _add_project(sc) + sc.add_argument("--prefix", required=True) + sc.add_argument("--limit", type=int, default=10) + + for name in ("get_callers", "get_callees", "get_dependencies"): + p = sub.add_parser(name) + _add_project(p) + _add_symbol(p) + + ia = sub.add_parser("impact_analysis") + _add_project(ia) + ia.add_argument("--symbol-id", type=int, required=True, dest="symbol_id") + ia.add_argument("--direction", choices=["IN", "OUT"], default="IN") + ia.add_argument("--depth", type=int, default=3) + # Iter2: cap output. impact_analysis on large graphs (sympy: 142k edges) + # routinely returns 500+ entries. Default 50 matches the other tools. + ia.add_argument("--limit", type=int, default=50) + + fp = sub.add_parser("find_path") + _add_project(fp) + fp.add_argument("--source-id", type=int, required=True, dest="source_id") + fp.add_argument("--dest-id", type=int, required=True, dest="dest_id") + + aq = sub.add_parser("ask") + _add_project(aq) + aq.add_argument("--question", required=True) + + args = parser.parse_args(argv) + timeout = _timeout() + + # Inject timeout for adapter calls. + cgm.DEFAULT_TIMEOUT_SEC = timeout + + try: + proj = getattr(args, "project", None) + if args.cmd == "index_repo": + _print(cgm.index_repo(args.path_or_url, branch=args.branch, ignore=args.ignore)) + elif args.cmd == "search_code": + _print(_compact_list( + cgm.search_code(args.prefix, args.project, branch=args.branch, limit=args.limit), + proj, args.limit, + )) + elif args.cmd == "get_callers": + _print(_compact_list( + cgm.get_callers(args.symbol_id, args.project, branch=args.branch, limit=args.limit), + proj, args.limit, + )) + elif args.cmd == "get_callees": + _print(_compact_list( + cgm.get_callees(args.symbol_id, args.project, branch=args.branch, limit=args.limit), + proj, args.limit, + )) + elif args.cmd == "get_dependencies": + _print(_compact_list( + cgm.get_dependencies(args.symbol_id, args.project, branch=args.branch, limit=args.limit), + proj, args.limit, + )) + elif args.cmd == "impact_analysis": + # impact_analysis has no server-side `limit`; cap + compact in CLI. + _print(_compact_list( + cgm.impact_analysis( + args.symbol_id, + args.project, + branch=args.branch, + direction=args.direction, + depth=args.depth, + ), + proj, args.limit, + )) + elif args.cmd == "find_path": + _print(_compact_entry( + cgm.find_path(args.source_id, args.dest_id, args.project, branch=args.branch), + proj, + )) + elif args.cmd == "ask": + _print(cgm.ask(args.question, args.project, branch=args.branch)) + else: # pragma: no cover — argparse already enforces this + parser.error(f"unknown subcommand: {args.cmd}") + except Exception as e: # noqa: BLE001 — surface everything to the agent + print(f"cg-mcp error: {e}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bench/cli/lsp b/bench/cli/lsp new file mode 100755 index 00000000..0fde3f14 --- /dev/null +++ b/bench/cli/lsp @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Bash-callable entry point for the LSP CLI. The runner puts +# bench/cli on PATH so the agent can invoke `lsp ...` directly. +# BENCH_PYTHON is set by the runner to the venv's python; falls back +# to system python3 if missing (won't have deps; will fail loudly). +exec "${BENCH_PYTHON:-python3}" -m bench.cli.lsp "$@" None: + json.dump(obj, sys.stdout, indent=2, sort_keys=True) + sys.stdout.write("\n") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="lsp", description=__doc__) + sub = parser.add_subparsers(dest="cmd", required=True) + + def add_pos(p: argparse.ArgumentParser) -> None: + p.add_argument("--file", required=True) + p.add_argument("--line", type=int, required=True) + p.add_argument("--col", type=int, required=True) + + gd = sub.add_parser("goto-definition") + add_pos(gd) + + fr = sub.add_parser("find-references") + add_pos(fr) + + hv = sub.add_parser("hover") + add_pos(hv) + + ds = sub.add_parser("document-symbols") + ds.add_argument("--file", required=True) + + args = parser.parse_args(argv) + + client = _client_from_env() + with client.server_running() as c: + if args.cmd == "goto-definition": + _print(c.goto_definition(args.file, args.line, args.col)) + elif args.cmd == "find-references": + _print(c.find_references(args.file, args.line, args.col)) + elif args.cmd == "hover": + _print(c.hover(args.file, args.line, args.col)) + elif args.cmd == "document-symbols": + _print(c.document_symbols(args.file)) + else: + parser.error(f"unknown command {args.cmd}") + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bench/cli/regrade.py b/bench/cli/regrade.py new file mode 100644 index 00000000..2df68c0a --- /dev/null +++ b/bench/cli/regrade.py @@ -0,0 +1,126 @@ +"""Retroactively grade existing trajectories via the SWE-bench Docker harness. + +Usage: + python -m bench.cli.regrade \ + --trajectories bench/cache/trajectories \ + --results bench/cache/results.jsonl \ + [--instance-id pytest-dev__pytest-6202] \ + [--config code_graph_mcp] + +For each trajectory file found, extracts the agent's submitted patch from +`info.submission` (and falls back to scanning messages), invokes the +official swebench harness via `bench.datasets.swe_bench.verify_with_swebench_harness`, +and updates the matching row in `results.jsonl` with `outcome=resolved|failed|verifier_unavailable` +plus a `verify_summary`. + +Requires Docker on the host. Without it every row will end up flagged +`verifier_unavailable` (which is still strictly more honest than the +old verifier's silent `failed`). +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from bench.datasets.swe_bench import ( + SweBenchInstance, + load_instances, + verify_with_swebench_harness, + _docker_available, +) + + +def _patch_from_trajectory(path: Path) -> str: + try: + data = json.loads(path.read_text()) + except Exception: + return "" + info = data.get("info", {}) + sub = info.get("submission") or info.get("patch") or "" + if sub: + return sub + # Fallback: look for a `git diff` in trailing assistant messages. + msgs = data.get("messages", []) + for m in reversed(msgs): + content = m.get("content") or "" + if isinstance(content, str) and content.startswith("diff --git"): + return content + return "" + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser() + p.add_argument("--trajectories", type=Path, required=True) + p.add_argument("--results", type=Path, required=True) + p.add_argument("--instance-id", default=None) + p.add_argument("--config", default=None) + p.add_argument("--namespace", default="swebench", + help="Docker image namespace. 'swebench' = prebuilt; " + "None = local build.") + p.add_argument("--timeout", type=int, default=1800) + args = p.parse_args(argv) + + if not _docker_available(): + print("[regrade] docker not available — every row will be marked " + "verifier_unavailable") + + insts_by_id = {i.instance_id: i for i in load_instances()} + + # Index existing results by (task_id, config) so we can patch them. + rows = [] + for line in args.results.read_text().splitlines(): + if line.strip(): + rows.append(json.loads(line)) + index = {(r["task_id"], r["config"]): r for r in rows} + + updated = 0 + for traj_path in sorted(args.trajectories.glob("*.json")): + stem = traj_path.stem # "__" + if "__" not in stem: + continue + instance_id, _, cfg = stem.rpartition("__") + if args.instance_id and instance_id != args.instance_id: + continue + if args.config and cfg != args.config: + continue + inst = insts_by_id.get(instance_id) + if not inst: + print(f"[regrade] {stem}: not in dataset, skip") + continue + patch = _patch_from_trajectory(traj_path) + if not patch.strip(): + print(f"[regrade] {stem}: empty patch") + row = index.get((instance_id, cfg)) + if row: + row["outcome"] = "failed" + row["verify_summary"] = "empty patch" + updated += 1 + continue + resolved, summary = verify_with_swebench_harness( + inst, patch, + run_id=f"regrade-{instance_id}-{cfg}", + namespace=(None if args.namespace.lower() == "none" else args.namespace), + timeout=args.timeout, + ) + if resolved is None: + outcome = "verifier_unavailable" + else: + outcome = "resolved" if resolved else "failed" + print(f"[regrade] {stem}: {outcome} — {summary[:120]}") + row = index.get((instance_id, cfg)) + if row: + row["outcome"] = outcome + row["verify_summary"] = summary[-300:] + updated += 1 + + with args.results.open("w") as f: + for r in rows: + f.write(json.dumps(r) + "\n") + print(f"[regrade] updated {updated} rows in {args.results}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bench/configs/default.yaml b/bench/configs/default.yaml new file mode 100644 index 00000000..bfbafa51 --- /dev/null +++ b/bench/configs/default.yaml @@ -0,0 +1,50 @@ +# Default run config — frontier model on SWE-bench-Lite. +# +# This is the headline config referenced in CONTEXT.md. +# Override for cheaper iteration with a separate file (e.g. flash.yaml). + +model: + # LiteLLM model identifier. Same model is used across baseline/lsp/code-graph. + name: anthropic/claude-sonnet-4-6 + temperature: 0.0 + max_tokens_per_call: 8192 + +agent: + # SWE-agent settings shared across all three tool bundles. + max_iterations: 75 + per_task_budget_tokens: 1500000 # hard cap; tasks exceeding count as failures + +benchmarks: + swe_bench: + split: verified # 500-task human-validated split + sample_size: 50 # random sample + sample_seed: 20260526 # committed for reproducibility + expand_to_if_delta_below_pp: 10 + expand_to_size: 150 + +runs: + default: 1 # pass@1 + retry_on_stochastic_failure: 2 # if failed, retry 2x; never demotes passes + +rollout: + stages: + - name: smoke + tasks: 3 + configs: [baseline, lsp, code-graph] + - name: calibration + tasks: 10 + configs: [baseline, lsp, code-graph] + preamble_phrasing_check: true + - name: headline + tasks: 37 # remaining of the 50-task sample + configs: [baseline, lsp, code-graph] + retries: 2 + +indexing: + cache_dir: bench/cache + # graph cache key is @; no incremental indexing. + +reporting: + out_dir: bench/report + jsonl_file: results.jsonl + summary_file: results.md diff --git a/bench/datasets/__init__.py b/bench/datasets/__init__.py new file mode 100644 index 00000000..6c64a1bb --- /dev/null +++ b/bench/datasets/__init__.py @@ -0,0 +1 @@ +"""Benchmark dataset loaders.""" diff --git a/bench/datasets/swe_bench.py b/bench/datasets/swe_bench.py new file mode 100644 index 00000000..6f73cb73 --- /dev/null +++ b/bench/datasets/swe_bench.py @@ -0,0 +1,373 @@ +"""SWE-bench Verified dataset loader. + +Loads `princeton-nlp/SWE-bench_Verified` via the `datasets` library, +samples instances deterministically by seed, prepares each repo as a +git working tree at the task's base commit, and exposes them as +`bench.runners.mini_runner.Task` objects. + +Repo clones are cached under `bench/cache/repos/{owner__name}/` +(bare-ish clone with all refs). For each task we materialize a +disposable worktree at `bench/cache/worktrees/{instance_id}/` and +hard-reset it to `base_commit`. The test_patch (which adds/modifies +the FAIL_TO_PASS tests but NOT the source patch) is applied so the +agent can actually run the tests; this matches the official +SWE-bench evaluation harness. + +Verification follows SWE-bench's protocol: after the agent submits a +patch, run the FAIL_TO_PASS + PASS_TO_PASS test selection and check +that all FAIL_TO_PASS go from fail→pass and all PASS_TO_PASS stay +passing. We approximate this with `pytest ` since the +official harness needs Docker per-instance environments. + +NOTE: This loader prepares repos for the agent but does NOT set up +per-repo Python dependencies. Real SWE-bench evaluation requires +constructing the exact conda environment specified in the dataset +row's `environment_setup_commit`. For an open evaluation we'll need +to either (a) use the official `swebench` harness for verification +or (b) build per-repo conda envs on the fly. This module flags +verification as "skipped" until that path is wired up. +""" + +from __future__ import annotations + +import json +import os +import random +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable + +from bench.runners.mini_runner import Task + +DATASET_NAME = "princeton-nlp/SWE-bench_Verified" +DEFAULT_CACHE_ROOT = Path(__file__).resolve().parents[1] / "cache" +REPOS_DIR = DEFAULT_CACHE_ROOT / "repos" +WORKTREES_DIR = DEFAULT_CACHE_ROOT / "worktrees" + +# Locked-in seed from plan / configs/default.yaml. +DEFAULT_SEED = 20260526 + +# Per-stage sample sizes (locked-in from plan). +STAGE_SIZES = {"smoke": 3, "calibration": 10, "headline": 37} + + +@dataclass(slots=True) +class SweBenchInstance: + """Subset of SWE-bench Verified fields we use.""" + + instance_id: str + repo: str # "owner/name" + base_commit: str + problem_statement: str + test_patch: str + fail_to_pass: list[str] + pass_to_pass: list[str] + environment_setup_commit: str + version: str + + +def _git(args: list[str], cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", *args], + cwd=str(cwd) if cwd else None, + capture_output=True, + text=True, + check=check, + ) + + +def _parse_list_field(value: Any) -> list[str]: + """SWE-bench stores FAIL_TO_PASS / PASS_TO_PASS as JSON strings.""" + if isinstance(value, list): + return list(value) + if isinstance(value, str): + return list(json.loads(value)) + raise TypeError(f"unsupported list field: {type(value)!r}") + + +def load_instances( + *, + split: str = "test", + cache_dir: Path | None = None, +) -> list[SweBenchInstance]: + """Load all SWE-bench Verified instances from HuggingFace.""" + from datasets import load_dataset # local import — heavy + + kwargs: dict[str, Any] = {"split": split} + if cache_dir is not None: + kwargs["cache_dir"] = str(cache_dir) + ds = load_dataset(DATASET_NAME, **kwargs) + + out: list[SweBenchInstance] = [] + for row in ds: + out.append( + SweBenchInstance( + instance_id=row["instance_id"], + repo=row["repo"], + base_commit=row["base_commit"], + problem_statement=row["problem_statement"], + test_patch=row["test_patch"], + fail_to_pass=_parse_list_field(row["FAIL_TO_PASS"]), + pass_to_pass=_parse_list_field(row["PASS_TO_PASS"]), + environment_setup_commit=row.get("environment_setup_commit") or "", + version=row.get("version") or "", + ) + ) + return out + + +def sample_instances( + instances: Iterable[SweBenchInstance], + *, + stage: str = "smoke", + seed: int = DEFAULT_SEED, + n: int | None = None, +) -> list[SweBenchInstance]: + """Deterministic stratified sample. + + `stage` selects a size from `STAGE_SIZES` unless `n` is given. + Sampling is plain `random.sample` with the locked-in seed so runs + are reproducible across machines. + """ + size = n if n is not None else STAGE_SIZES[stage] + pool = list(instances) + rng = random.Random(seed) + return rng.sample(pool, k=min(size, len(pool))) + + +# --------------------------------------------------------------------------- +# Repo materialization +# --------------------------------------------------------------------------- + + +def _repo_cache_path(repo: str, repos_dir: Path) -> Path: + safe = repo.replace("/", "__") + return repos_dir / safe + + +def _ensure_repo_clone(repo: str, repos_dir: Path) -> Path: + """Ensure we have a local clone of `owner/name`. Returns its path.""" + dest = _repo_cache_path(repo, repos_dir) + if (dest / ".git").exists(): + return dest + dest.parent.mkdir(parents=True, exist_ok=True) + url = f"https://github.com/{repo}.git" + _git(["clone", url, str(dest)]) + return dest + + +def prepare_worktree( + inst: SweBenchInstance, + *, + repos_dir: Path = REPOS_DIR, + worktrees_dir: Path = WORKTREES_DIR, + apply_test_patch: bool = True, +) -> Path: + """Materialize a fresh working tree at `inst.base_commit`. + + Uses `git clone` of the cached repo (cheap because clones share + objects). The test_patch is applied unless `apply_test_patch` is + False so the agent can actually run the FAIL_TO_PASS tests. + """ + src = _ensure_repo_clone(inst.repo, repos_dir) + + dest = worktrees_dir / inst.instance_id + if dest.exists(): + shutil.rmtree(dest) + dest.parent.mkdir(parents=True, exist_ok=True) + + # Make a local clone (shares objects via --shared-style alternates). + _git(["clone", str(src), str(dest)]) + _git(["fetch", "origin", inst.base_commit], cwd=dest, check=False) + _git(["checkout", "--detach", inst.base_commit], cwd=dest) + + if apply_test_patch and inst.test_patch.strip(): + patch_file = dest / ".swebench-test.patch" + patch_file.write_text(inst.test_patch) + try: + _git(["apply", "--allow-empty", str(patch_file)], cwd=dest) + except subprocess.CalledProcessError as exc: + # Some test_patches need 3-way merge; retry with -3. + res = subprocess.run( + ["git", "apply", "--3way", str(patch_file)], + cwd=dest, capture_output=True, text=True, + ) + if res.returncode != 0: + raise RuntimeError( + f"failed to apply test_patch for {inst.instance_id}: {exc.stderr}" + ) + patch_file.unlink(missing_ok=True) + + return dest + + +def instance_to_task(inst: SweBenchInstance, repo_path: Path) -> Task: + """Wrap a SWE-bench instance as a bench.runners Task.""" + return Task( + task_id=inst.instance_id, + repo_name=inst.repo, + repo_path=repo_path, + problem_statement=inst.problem_statement, + verify_cmd=None, # verification done via swe_bench.verify_instance + ) + + +# --------------------------------------------------------------------------- +# Verification — official SWE-bench harness path +# --------------------------------------------------------------------------- +# +# The original verify_instance implementation ran modern pytest from the +# bench venv against the SWE-bench worktree's legacy code, which collected +# zero tests on most instances (e.g. pytest-6202 INTERNALERRORs on removed +# config keys like `rsyncdirs`). Every trajectory was graded `failed` +# regardless of patch correctness, invalidating the Sonnet calibration's +# 0/10 resolve rate. +# +# The replacement defers to the official swebench harness, which builds / +# pulls per-instance Docker images with the right Python + dependencies +# and runs FAIL_TO_PASS + PASS_TO_PASS exactly the way the leaderboard +# does. Requires Docker on the host; when Docker is absent we return a +# `verifier_unavailable` outcome so we never silently grade wrongly again. + + +def _docker_available() -> bool: + """Cheap probe — does `docker info` succeed?""" + try: + r = subprocess.run( + ["docker", "info"], + capture_output=True, + text=True, + timeout=5, + ) + return r.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def verify_with_swebench_harness( + inst: SweBenchInstance, + patch: str, + *, + run_id: str | None = None, + namespace: str | None = "swebench", + timeout: int = 1800, + report_dir: Path | None = None, +) -> tuple[bool | None, str]: + """Grade a single (instance, patch) via the official swebench harness. + + Returns (resolved, summary): + - (True, "..." ) — FAIL_TO_PASS flipped + PASS_TO_PASS held + - (False, "...") — patch did not resolve + - (None, "verifier_unavailable: ") — could not grade (no + Docker, harness exception). Caller should record outcome= + `verifier_unavailable` rather than `failed`. + + `namespace="swebench"` pulls prebuilt images from Docker Hub (the + `swebench/sweb.eval.x86_64.` family) and avoids the 30+ + minute per-instance build step. Pass `namespace=None` to force a + local build instead. + """ + if not _docker_available(): + return None, "verifier_unavailable: docker not available on host" + + if not patch.strip(): + return False, "empty patch" + + run_id = run_id or f"code-graph-bench-{inst.instance_id}" + report_dir = report_dir or (DEFAULT_CACHE_ROOT / "verify" / run_id) + report_dir.mkdir(parents=True, exist_ok=True) + + # predictions.jsonl in the format the harness expects + model_tag = "code-graph-bench" + pred_path = report_dir / "predictions.jsonl" + with pred_path.open("w") as f: + f.write(json.dumps({ + "instance_id": inst.instance_id, + "model_name_or_path": model_tag, + "model_patch": patch, + }) + "\n") + + try: + from swebench.harness.run_evaluation import main as run_eval + except Exception as e: # pragma: no cover — bench extra missing + return None, f"verifier_unavailable: swebench import failed: {e}" + + try: + run_eval( + dataset_name=DATASET_NAME, + split="test", + instance_ids=[inst.instance_id], + predictions_path=str(pred_path), + max_workers=1, + force_rebuild=False, + cache_level="env", + clean=False, + open_file_limit=4096, + run_id=run_id, + timeout=timeout, + namespace=namespace, + rewrite_reports=False, + modal=False, + report_dir=str(report_dir), + ) + except Exception as e: + return None, f"verifier_unavailable: harness raised {type(e).__name__}: {e}" + + # The harness writes per-instance reports under + # logs/run_evaluation////report.json + # but the path is CWD-relative. Find the resulting report. + candidates = list(Path.cwd().glob( + f"logs/run_evaluation/{run_id}/{model_tag}/{inst.instance_id}/report.json" + )) + if not candidates: + # Fallback: the harness writes a top-level run report too. + top = list(report_dir.glob(f"*.{run_id}.json")) + if top: + try: + data = json.loads(top[0].read_text()) + resolved_ids = set(data.get("resolved_ids", [])) + ok = inst.instance_id in resolved_ids + return ok, f"top-level report: resolved={ok}" + except Exception: + pass + return None, "verifier_unavailable: no per-instance report produced" + + try: + data = json.loads(candidates[0].read_text()) + # The per-instance report nests under the instance_id. + inst_data = data.get(inst.instance_id, data) + resolved = bool(inst_data.get("resolved")) + tests_status = inst_data.get("tests_status", {}) + f2p = tests_status.get("FAIL_TO_PASS", {}) + p2p = tests_status.get("PASS_TO_PASS", {}) + summary = ( + f"resolved={resolved} " + f"F2P: success={len(f2p.get('success', []))} " + f"failure={len(f2p.get('failure', []))} " + f"P2P: success={len(p2p.get('success', []))} " + f"failure={len(p2p.get('failure', []))}" + ) + return resolved, summary + except Exception as e: + return None, f"verifier_unavailable: report parse failed: {e}" + + +def verify_instance( + inst: SweBenchInstance, + repo_path: Path, + *, + python: str | None = None, +) -> tuple[bool, str]: + """DEPRECATED — runs modern pytest against legacy repos and returns + bogus results. Kept as a thin shim that fails loud so any caller + still using it gets a clear message instead of a silent wrong grade. + + Real verification goes through `verify_with_swebench_harness(inst, + patch)`, which uses the official swebench Docker harness. + """ + return False, ( + "verify_instance is deprecated; use verify_with_swebench_harness " + "with the agent's submitted patch and a Docker-enabled host." + ) diff --git a/bench/metrics/__init__.py b/bench/metrics/__init__.py new file mode 100644 index 00000000..ec817c69 --- /dev/null +++ b/bench/metrics/__init__.py @@ -0,0 +1,241 @@ +"""Trajectory parsing and per-task metrics for the benchmark. + +SWE-agent emits a trajectory JSON per task. We extract the headline numbers: + +- token usage (input + output, per-call sum) +- tool calls (count + per-tool breakdown) +- patch (the agent's final diff submission) +- outcome (resolved / failed / budget_exceeded), evaluated externally + by SWE-bench's official scorer + +The functions in this module are pure and trajectory-shape-agnostic to the +extent possible: each is defensive about missing/renamed fields because +SWE-agent's exact JSON schema has drifted between versions. The tests in +`tests/test_bench_metrics.py` lock the contract for a small synthetic +trajectory. +""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + + +@dataclass +class TaskMetrics: + """One row of the results JSONL: per-task per-config measurement.""" + + benchmark: str # "swe_bench_verified" + task_id: str # e.g. "django__django-12345" + config: str # "baseline" | "lsp" | "code_graph" + run_idx: int # 0 for pass@1, 1+ for retries + + # token cost (LLM only — never combined with indexing) + input_tokens: int + output_tokens: int + + # tool-call sanity check + tool_calls_total: int + tool_calls_by_name: dict[str, int] = field(default_factory=dict) + # Tool-usage rate: fraction of bash commands that actually invoke the + # configured tool (cg / cg-mcp / lsp). Low rate = agent abandoned the + # tool and ran on plain bash. None for baseline (no tool expected). + tool_usage_rate: float | None = None + tool_usage_turns: int = 0 + tool_usage_total: int = 0 + # Fallback rate: fraction of bash commands that are plain text search + # (grep / rg / find / ack / ag) instead of the configured tool. + # Always populated (incl. baseline as a reference point). + fallback_rate: float | None = None + fallback_turns: int = 0 + # One-time indexing wall-clock (only on first run per worktree). None + # for baseline/lsp (no indexing) and 0.0 when the graph was already + # built (cache hit on subsequent retries). + index_sec: float | None = None + + # outcome (set after scoring; None until then) + outcome: str | None = None # "resolved" | "failed" | "budget_exceeded" | "error" | "tool_unavailable" + patch: str | None = None + wall_clock_sec: float | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +_TOKEN_KEYS_IN = ("prompt_tokens", "input_tokens", "tokens_in") +_TOKEN_KEYS_OUT = ("completion_tokens", "output_tokens", "tokens_out") + + +def _first_int(d: dict[str, Any], keys: tuple[str, ...]) -> int: + for k in keys: + v = d.get(k) + if isinstance(v, int): + return v + return 0 + + +def _iter_history_steps(traj: dict[str, Any]) -> list[dict[str, Any]]: + """SWE-agent has flipped between top-level `history`, `trajectory`, and + `steps`. mini-swe-agent uses `messages`. Return whichever list is + present, else []. + """ + for key in ("history", "trajectory", "steps", "messages"): + v = traj.get(key) + if isinstance(v, list): + return v + return [] + + +def _step_usage(step: dict[str, Any]) -> dict[str, Any] | None: + """Find an OpenAI/Anthropic-style usage dict on a step, across schemas.""" + if not isinstance(step, dict): + return None + # SWE-agent: step.usage + usage = step.get("usage") + if isinstance(usage, dict): + return usage + # mini-swe-agent: step.extra.response.usage + extra = step.get("extra") + if isinstance(extra, dict): + resp = extra.get("response") + if isinstance(resp, dict): + u = resp.get("usage") + if isinstance(u, dict): + return u + u = extra.get("usage") + if isinstance(u, dict): + return u + return None + + +def extract_token_usage(traj: dict[str, Any]) -> tuple[int, int]: + """Sum input + output tokens across all LLM calls in the trajectory. + + Looks for `usage` sub-objects on each step (the conventional shape for + OpenAI/Anthropic-style responses passed through LiteLLM). + """ + total_in = 0 + total_out = 0 + for step in _iter_history_steps(traj): + usage = _step_usage(step) + if isinstance(usage, dict): + total_in += _first_int(usage, _TOKEN_KEYS_IN) + total_out += _first_int(usage, _TOKEN_KEYS_OUT) + summary = traj.get("total_usage") or traj.get("usage") + if (total_in == 0 and total_out == 0) and isinstance(summary, dict): + total_in = _first_int(summary, _TOKEN_KEYS_IN) + total_out = _first_int(summary, _TOKEN_KEYS_OUT) + return total_in, total_out + + +def _action_name(cmd: str) -> str: + """Bucket a bash command line into a friendly tool-call name. + + The first non-redirection token is good enough — `cg`, `lsp`, `git`, + `grep`, `sed`, etc. Falls back to `bash` for empty or odd shapes. + """ + if not isinstance(cmd, str): + return "bash" + tokens = cmd.strip().split() + if not tokens: + return "bash" + head = tokens[0] + if head in ("printf", "echo") and "COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT" in cmd: + return "submit" + return head + + +def extract_tool_calls(traj: dict[str, Any]) -> tuple[int, dict[str, int]]: + """Count every tool/action invocation and bucket by tool name.""" + by_name: dict[str, int] = {} + total = 0 + for step in _iter_history_steps(traj): + if not isinstance(step, dict): + continue + # mini-swe-agent: step.extra.actions[*].command (bash-only). + extra = step.get("extra") if isinstance(step.get("extra"), dict) else None + if extra and isinstance(extra.get("actions"), list): + for act in extra["actions"]: + if isinstance(act, dict): + by_name_key = _action_name(act.get("command", "")) + by_name[by_name_key] = by_name.get(by_name_key, 0) + 1 + total += 1 + continue + # SWE-agent shapes: action.command, tool_calls[*].function.name, action.name + name = None + action = step.get("action") + if isinstance(action, dict): + name = action.get("command") or action.get("name") or action.get("tool") + if not name: + tcs = step.get("tool_calls") + if isinstance(tcs, list) and tcs: + fn = tcs[0].get("function") if isinstance(tcs[0], dict) else None + if isinstance(fn, dict): + name = fn.get("name") + if not name: + continue + by_name[name] = by_name.get(name, 0) + 1 + total += 1 + return total, by_name + + +def extract_patch(traj: dict[str, Any]) -> str | None: + """The final diff the agent submitted, if any.""" + for key in ("model_patch", "submission", "patch"): + v = traj.get(key) + if isinstance(v, str) and v.strip(): + return v + info = traj.get("info") if isinstance(traj.get("info"), dict) else None + if info: + for key in ("model_patch", "submission", "patch"): + v = info.get(key) + if isinstance(v, str) and v.strip(): + return v + return None + + +def task_metrics_from_trajectory( + traj: dict[str, Any], + *, + benchmark: str, + task_id: str, + config: str, + run_idx: int = 0, + wall_clock_sec: float | None = None, +) -> TaskMetrics: + tin, tout = extract_token_usage(traj) + n_calls, by_name = extract_tool_calls(traj) + return TaskMetrics( + benchmark=benchmark, + task_id=task_id, + config=config, + run_idx=run_idx, + input_tokens=tin, + output_tokens=tout, + tool_calls_total=n_calls, + tool_calls_by_name=by_name, + patch=extract_patch(traj), + wall_clock_sec=wall_clock_sec, + ) + + +def load_trajectory(path: str | Path) -> dict[str, Any]: + with open(path) as f: + return json.load(f) + + +def write_jsonl(path: str | Path, rows: list[TaskMetrics]) -> None: + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + with open(p, "w") as f: + for r in rows: + f.write(json.dumps(r.to_dict()) + "\n") + + +def append_jsonl(path: str | Path, row: TaskMetrics) -> None: + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + with open(p, "a") as f: + f.write(json.dumps(row.to_dict()) + "\n") diff --git a/bench/report/__init__.py b/bench/report/__init__.py new file mode 100644 index 00000000..d967a6ef --- /dev/null +++ b/bench/report/__init__.py @@ -0,0 +1,161 @@ +"""Aggregate per-task metrics into a per-config summary table. + +Reads the JSONL emitted by bench/metrics and produces: +- a markdown table grouped by (benchmark, config) with accuracy + tokens +- a token-savings Δ column vs the baseline config +""" + +from __future__ import annotations + +import json +import statistics +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +@dataclass +class ConfigSummary: + benchmark: str + config: str + n_tasks: int + n_resolved: int + median_tokens: int + p90_tokens: int + median_tool_usage: float | None = None # fraction in [0,1] or None for baseline + median_fallback: float | None = None # fraction in [0,1] of bash cmds that were grep/find/rg + median_wall_sec: float | None = None # median wall-clock seconds per task + median_index_sec: float | None = None # median one-time indexing seconds (cg / cg-mcp only) + + @property + def resolve_rate(self) -> float: + return self.n_resolved / self.n_tasks if self.n_tasks else 0.0 + + @property + def total_tokens(self) -> int: + return self.median_tokens * self.n_tasks # not perfectly accurate, replaced below + + +def _percentile(values: list[int], p: float) -> int: + if not values: + return 0 + s = sorted(values) + k = (len(s) - 1) * p + f = int(k) + c = min(f + 1, len(s) - 1) + if f == c: + return s[f] + return int(s[f] + (s[c] - s[f]) * (k - f)) + + +def load_jsonl(path: str | Path) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + with open(path) as f: + for line in f: + line = line.strip() + if line: + rows.append(json.loads(line)) + return rows + + +def summarize(rows: list[dict[str, Any]]) -> list[ConfigSummary]: + by_key: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list) + for r in rows: + by_key[(r["benchmark"], r["config"])].append(r) + + summaries: list[ConfigSummary] = [] + for (bench, cfg), task_rows in sorted(by_key.items()): + # Pick the best run per (task_id) — pass@1 with retries. + best_by_task: dict[str, dict[str, Any]] = {} + for r in task_rows: + tid = r["task_id"] + prev = best_by_task.get(tid) + if prev is None: + best_by_task[tid] = r + continue + # prefer a resolved outcome + if prev.get("outcome") != "resolved" and r.get("outcome") == "resolved": + best_by_task[tid] = r + + token_sums = [ + (r["input_tokens"] + r["output_tokens"]) for r in best_by_task.values() + ] + usage_rates = [ + r["tool_usage_rate"] for r in best_by_task.values() + if r.get("tool_usage_rate") is not None + ] + fallback_rates = [ + r["fallback_rate"] for r in best_by_task.values() + if r.get("fallback_rate") is not None + ] + wall_secs = [ + r["wall_clock_sec"] for r in best_by_task.values() + if r.get("wall_clock_sec") is not None + ] + index_secs = [ + r["index_sec"] for r in best_by_task.values() + if r.get("index_sec") is not None + ] + n_resolved = sum(1 for r in best_by_task.values() if r.get("outcome") == "resolved") + summaries.append( + ConfigSummary( + benchmark=bench, + config=cfg, + n_tasks=len(best_by_task), + n_resolved=n_resolved, + median_tokens=int(statistics.median(token_sums)) if token_sums else 0, + p90_tokens=_percentile(token_sums, 0.9), + median_tool_usage=statistics.median(usage_rates) if usage_rates else None, + median_fallback=statistics.median(fallback_rates) if fallback_rates else None, + median_wall_sec=statistics.median(wall_secs) if wall_secs else None, + median_index_sec=statistics.median(index_secs) if index_secs else None, + ) + ) + return summaries + + +def render_markdown(summaries: list[ConfigSummary]) -> str: + lines: list[str] = [] + lines.append("# Benchmark results\n") + by_bench: dict[str, list[ConfigSummary]] = defaultdict(list) + for s in summaries: + by_bench[s.benchmark].append(s) + + for bench, group in sorted(by_bench.items()): + baseline = next((s for s in group if s.config == "baseline"), None) + baseline_med = baseline.median_tokens if baseline else 0 + + lines.append(f"## {bench}\n") + baseline_wall = baseline.median_wall_sec if baseline else None + lines.append("| config | tasks | resolved | resolve rate | median tokens | p90 tokens | Δ tokens vs baseline | median wall (s) | Δ wall vs baseline | median index (s) | tool-usage rate | fallback rate |") + lines.append("|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|") + for s in sorted(group, key=lambda x: x.config): + delta = "—" + if baseline_med and s.config != "baseline": + pct = (s.median_tokens - baseline_med) / baseline_med * 100 + delta = f"{pct:+.1f}%" + wall = "—" if s.median_wall_sec is None else f"{s.median_wall_sec:.0f}" + wall_delta = "—" + if baseline_wall and s.median_wall_sec is not None and s.config != "baseline": + wp = (s.median_wall_sec - baseline_wall) / baseline_wall * 100 + wall_delta = f"{wp:+.1f}%" + idx = "—" if s.median_index_sec is None else f"{s.median_index_sec:.1f}" + usage = "—" if s.median_tool_usage is None else f"{s.median_tool_usage * 100:.0f}%" + fb = "—" if s.median_fallback is None else f"{s.median_fallback * 100:.0f}%" + lines.append( + f"| {s.config} | {s.n_tasks} | {s.n_resolved} | " + f"{s.resolve_rate * 100:.1f}% | {s.median_tokens:,} | " + f"{s.p90_tokens:,} | {delta} | {wall} | {wall_delta} | {idx} | {usage} | {fb} |" + ) + lines.append("") + return "\n".join(lines) + + +def aggregate_to_markdown(jsonl_path: str | Path, out_path: str | Path) -> None: + rows = load_jsonl(jsonl_path) + summaries = summarize(rows) + md = render_markdown(summaries) + out = Path(out_path) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(md) diff --git a/bench/report/__main__.py b/bench/report/__main__.py new file mode 100644 index 00000000..fea565e0 --- /dev/null +++ b/bench/report/__main__.py @@ -0,0 +1,49 @@ +"""CLI: render bench/cache/results.jsonl as a markdown summary. + +Usage: + uv run python -m bench.report \\ + --input bench/cache/results.jsonl \\ + --output bench/cache/report.md + +Defaults to bench/cache/results.jsonl → bench/cache/report.md. Also +prints the rendered markdown to stdout so it can be piped to a PR +body or pasted into an issue. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from bench.report import aggregate_to_markdown, load_jsonl, render_markdown, summarize + +DEFAULT_INPUT = Path(__file__).resolve().parents[1] / "cache" / "results.jsonl" +DEFAULT_OUTPUT = Path(__file__).resolve().parents[1] / "cache" / "report.md" + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="render benchmark results as markdown") + p.add_argument("--input", type=Path, default=DEFAULT_INPUT) + p.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + p.add_argument("--quiet", action="store_true", help="don't echo markdown to stdout") + args = p.parse_args(argv) + + if not args.input.exists(): + print(f"error: input not found: {args.input}") + return 1 + + rows = load_jsonl(args.input) + if not rows: + print("error: input has no rows") + return 1 + md = render_markdown(summarize(rows)) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(md) + if not args.quiet: + print(md) + print(f"\n[report] {len(rows)} row(s) -> {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bench/runners/__init__.py b/bench/runners/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bench/runners/index_cache.py b/bench/runners/index_cache.py new file mode 100644 index 00000000..d51605ec --- /dev/null +++ b/bench/runners/index_cache.py @@ -0,0 +1,83 @@ +"""Indexing-cache registry — track which `@` pairs have been +indexed by code-graph, so the benchmark runner doesn't re-index between runs. + +This is a tiny JSON-on-disk registry. It does NOT actually run analysis — +that goes through code-graph's existing `/api/analyze_folder` / +`/api/analyze_repo` endpoints. This module just records what's been done. + +Each entry records (repo, commit, indexed_at, source_path) so the runner +can ask "is `django@4.2.7` already indexed?" before kicking off another +full analysis. Cache is content-addressed by `@`. +""" + +from __future__ import annotations + +import json +import time +from dataclasses import asdict, dataclass +from pathlib import Path + + +@dataclass +class IndexEntry: + repo: str + commit: str + source_path: str + indexed_at: float # unix epoch seconds + + +class IndexCache: + """JSON-file-backed cache. Single-writer assumed (one runner at a time).""" + + def __init__(self, root: str | Path) -> None: + self.root = Path(root) + self.root.mkdir(parents=True, exist_ok=True) + self._file = self.root / "index.json" + + @staticmethod + def key(repo: str, commit: str) -> str: + return f"{repo}@{commit}" + + def _load(self) -> dict[str, IndexEntry]: + if not self._file.exists(): + return {} + with open(self._file) as f: + raw = json.load(f) + return {k: IndexEntry(**v) for k, v in raw.items()} + + def _save(self, entries: dict[str, IndexEntry]) -> None: + data = {k: asdict(v) for k, v in entries.items()} + tmp = self._file.with_suffix(".json.tmp") + with open(tmp, "w") as f: + json.dump(data, f, indent=2, sort_keys=True) + tmp.replace(self._file) + + def has(self, repo: str, commit: str) -> bool: + return self.key(repo, commit) in self._load() + + def get(self, repo: str, commit: str) -> IndexEntry | None: + return self._load().get(self.key(repo, commit)) + + def record(self, repo: str, commit: str, source_path: str) -> IndexEntry: + entries = self._load() + entry = IndexEntry( + repo=repo, + commit=commit, + source_path=str(source_path), + indexed_at=time.time(), + ) + entries[self.key(repo, commit)] = entry + self._save(entries) + return entry + + def forget(self, repo: str, commit: str) -> bool: + entries = self._load() + k = self.key(repo, commit) + if k not in entries: + return False + del entries[k] + self._save(entries) + return True + + def all(self) -> list[IndexEntry]: + return list(self._load().values()) diff --git a/bench/runners/mini_runner.py b/bench/runners/mini_runner.py new file mode 100644 index 00000000..2e3d441f --- /dev/null +++ b/bench/runners/mini_runner.py @@ -0,0 +1,1025 @@ +"""Benchmark runner wired to mini-swe-agent. + +The runner is the glue between: + +- the **benchmark dataset** (SWE-bench Verified, or a tiny synthetic + dataset in `--dry-run` mode so the harness is testable without an + LLM API key), +- the **agent harness** (`mini-swe-agent`, which uses bash as its sole + tool surface), +- and the **per-config tool bundle** (baseline / lsp / code-graph), + exposed to the agent as a `PATH` prefix containing `bench/cli/` + scripts plus the relevant env vars. + +For each (task, config) pair the runner: + +1. Materializes the target repo at the task's base commit. +2. Builds the agent: LitellmModel + LocalEnvironment (cwd = repo, env = + config-specific tool env vars) + system/instance templates assembled + from `bench/tools//system_preamble.md` and the task body. +3. Runs the agent with the locked-in step/cost/wall-time limits. +4. Captures the trajectory (mini-swe-agent's `agent.serialize()` dict) + and `git diff` of the repo as the proposed patch. +5. Hands the trajectory to `bench.metrics.task_metrics_from_trajectory` + and appends a row to `bench/cache/results.jsonl`. + +The `--dry-run` mode swaps the LLM model for a deterministic stub so +the entire pipeline can run with no Anthropic key — this is the mode +we exercise in CI and in `tests/test_bench_runner.py`. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +# mini-swe-agent imports are slow (litellm pre-import). Defer to call time. + +REPO_ROOT = Path(__file__).resolve().parents[2] +BENCH_DIR = REPO_ROOT / "bench" +CLI_DIR = BENCH_DIR / "cli" +TOOLS_DIR = BENCH_DIR / "tools" +DEFAULT_CACHE_DIR = BENCH_DIR / "cache" +DEFAULT_RESULTS = DEFAULT_CACHE_DIR / "results.jsonl" + +VALID_CONFIGS = ("baseline", "lsp", "code_graph", "code_graph_mcp") + + +# --------------------------------------------------------------------------- +# Task model +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class Task: + """A single benchmark instance. + + `repo_path` is an absolute path to the prepared working tree at the + correct base commit. `verify_cmd` is a shell command that must exit + 0 if the patch resolves the task; in dry-run mode it's the cheap + synthetic check, in real SWE-bench mode it's the harness's test + selection. + """ + + task_id: str + repo_name: str + repo_path: Path + problem_statement: str + verify_cmd: str | None = None + + +# --------------------------------------------------------------------------- +# Preamble + instance prompt assembly +# --------------------------------------------------------------------------- + + +# The instance prompt is identical across configs — only `system_preamble.md` +# changes. mini-swe-agent uses Jinja2 templating, so we pre-format with +# explicit placeholders the agent will see. +INSTANCE_TEMPLATE = """\ +You are working in the repository at {{cwd}}. + +The task to solve: + +{{task}} + +When you believe the task is complete, finish your turn with a final +message that contains a unified diff of your changes inside a fenced +``` block, then exit. Do not commit; the harness reads the diff via +`git diff`. +""" + + +# The lsp / code_graph configs use a sharper template that mandates an +# initial tool call. Smoke #2 showed Claude reads the system preamble's +# "use cg/lsp first" guidance and then ignores it; embedding the +# requirement in the per-instance task description is more obtrusive. + +INSTANCE_TEMPLATE_LSP = """\ +You are working in the repository at {{cwd}}. + +The task to solve: + +{{task}} + +**Required workflow.** Before reading or editing any file, your first +two bash commands MUST be: + +1. `grep -rn "" --include='*.py' .` + to locate a `file:line` for jedi to anchor on. +2. `lsp goto-definition --file --line --col ` to + resolve the true definition. + +Then use `lsp find-references` whenever you would have used a recursive +grep, and `lsp document-symbols` whenever you would have run a textual +outline pass. Reach for plain grep/sed/cat only after you've exhausted +the LSP for navigation. + +When you believe the task is complete, finish your turn with a final +message that contains a unified diff of your changes inside a fenced +``` block, then exit. Do not commit; the harness reads the diff via +`git diff`. +""" + +INSTANCE_TEMPLATE_CODE_GRAPH = """\ +You are working in the repository at {{cwd}}. +The code-graph service has already indexed this repository under the +name `$REPO_NAME` (use the env var literally). + +The task to solve: + +{{task}} + +**Required workflow.** Before reading or editing any file, your first +bash command MUST be: + + `cg find-symbol --repo "$REPO_NAME" --name ` + +then use `cg get-neighbors --repo "$REPO_NAME" --ids ` to expand +relationships before doing any textual search. After every file edit, +run `cg note-edit --repo "$REPO_NAME" --path ` so subsequent +graph queries reflect your change. Reach for grep/sed/cat only for +content reading after `cg` has located the right place. + +When you believe the task is complete, finish your turn with a final +message that contains a unified diff of your changes inside a fenced +``` block, then exit. Do not commit; the harness reads the diff via +`git diff`. +""" + + +INSTANCE_TEMPLATE_CODE_GRAPH_MCP = """\ +You are working in the repository at {{cwd}}. +The code-graph MCP server has already indexed this repository under the +project name `$PROJECT_NAME` on branch `$BRANCH` (use the env vars +literally). + +The task to solve: + +{{task}} + +**Required workflow.** Before reading or editing any file, your first +bash command MUST be: + + `cg-mcp search_code --project "$PROJECT_NAME" --branch "$BRANCH" --prefix ` + +Then use `cg-mcp get_callers --project "$PROJECT_NAME" --branch "$BRANCH" --symbol-id ` +to expand relationships before doing any textual search. Use +`cg-mcp impact_analysis ... --symbol-id --depth 3` before +non-trivial edits. + +When you believe the task is complete, finish your turn with a final +message that contains a unified diff of your changes inside a fenced +``` block, then exit. Do not commit; the harness reads the diff via +`git diff`. +""" + + +def load_instance_template(config: str) -> str: + if config == "lsp": + return INSTANCE_TEMPLATE_LSP + if config == "code_graph": + return INSTANCE_TEMPLATE_CODE_GRAPH + if config == "code_graph_mcp": + return INSTANCE_TEMPLATE_CODE_GRAPH_MCP + return INSTANCE_TEMPLATE + + +def load_preamble(config: str) -> str: + """Read the per-config system preamble; fall back to a generic stub.""" + path = TOOLS_DIR / config / "system_preamble.md" + if path.exists(): + return path.read_text() + # Fallback so dry-run tests can run before all preambles are authored. + return ( + f"You are an autonomous coding agent. Configuration: {config}.\n" + "You have one tool: bash. Run commands to read files, search, and " + "edit. Available helpers depend on the configuration.\n" + ) + + +# --------------------------------------------------------------------------- +# Per-config environment +# --------------------------------------------------------------------------- + + +def config_env(config: str, repo_path: Path) -> dict[str, str]: + """Build the environment variables the agent's bash sees. + + The key trick: each config prepends `bench/cli` to PATH only when + the helper scripts are part of the bundle. For the `baseline` + config we deliberately do NOT add the helpers — that's the whole + point of the baseline. + """ + env = dict(os.environ) + # Don't leak the user's PATH editor / shell aliases into the agent. + env["PATH"] = ( + f"{CLI_DIR}:{env.get('PATH', '/usr/bin:/bin')}" + if config != "baseline" + else env.get("PATH", "/usr/bin:/bin") + ) + # Make `python -m bench.cli.cg ...` work too, regardless of cwd. + env["PYTHONPATH"] = str(REPO_ROOT) + os.pathsep + env.get("PYTHONPATH", "") + # Pin the python the bash shims invoke to this process's interpreter so + # the bench deps (requests, multilspy, ...) are available. + env["BENCH_PYTHON"] = sys.executable + if config == "lsp": + env["LSP_REPO_ROOT"] = str(repo_path) + env.setdefault("LSP_LANGUAGE", "python") + elif config == "code_graph": + # The runner is responsible for ensuring the service is up. + env.setdefault("CODEGRAPH_URL", "http://127.0.0.1:5000") + # The agent's preamble references $REPO_NAME — set it to the + # worktree dirname, which is what analyze_folder used as the id. + env["REPO_NAME"] = repo_path.name + elif config == "code_graph_mcp": + # MCP transport: agent calls `cg-mcp …` which spawns the + # `cgraph-mcp` stdio server per call. FalkorDB coordinates + # are passed through verbatim. + env.setdefault("FALKORDB_HOST", os.environ.get("FALKORDB_HOST", "127.0.0.1")) + env.setdefault("FALKORDB_PORT", os.environ.get("FALKORDB_PORT", "6379")) + # `cgraph-mcp` must be on PATH; the runner installs the + # falkordb-code-graph package into the same interpreter, so + # prepending the venv bin gives us the entry point. + venv_bin = str(Path(sys.executable).parent) + env["PATH"] = f"{venv_bin}:{env['PATH']}" + # The preamble references $PROJECT_NAME and $BRANCH; project + # name matches what `index_repo` derives from the folder + # (= worktree dirname), and branch is the per-instance tag we + # used when indexing. + env["PROJECT_NAME"] = repo_path.name + env["BRANCH"] = os.environ.get("CGRAPH_MCP_BRANCH", "_default") + return env + + +def _ensure_indexed(repo_path: Path) -> float: + """Trigger /api/analyze_folder so `cg --repo ` returns data. + + The code-graph backend uses `Path(folder).name` as the repo identifier; + each (instance, config) worktree has a unique directory name like + `pytest-dev__pytest-6202__code_graph`, which becomes the `--repo` value + the agent passes to `cg`. We skip indexing if the graph already exists + in FalkorDB (cheap GRAPH.LIST scan, matches the MCP-track behavior). + + Returns wall-clock seconds spent indexing (0.0 if cache hit / skip). + """ + import httpx + import redis + start = time.monotonic() + + base = os.environ.get("CODEGRAPH_URL", "http://127.0.0.1:5000").rstrip("/") + repo_name = repo_path.name + token = os.environ.get("SECRET_TOKEN") or os.environ.get("CODEGRAPH_TOKEN") + headers = {"Authorization": f"Bearer {token}"} if token else {} + + # Sanity-check the server before we ask it to index anything. We've + # been bitten twice now by an API server launched without + # CODE_GRAPH_PY_RESOLVER=tree_sitter: the jedi/multilspy path tries + # to ``python -m venv venv && pip install poetry && poetry install`` + # per repo, then runs jedi over the full transitive dep tree. On + # sphinx-8035 that wedged the server at 100% CPU for 3h+. Refuse + # to proceed instead of letting it happen again. + try: + with httpx.Client(timeout=5.0, headers=headers) as c: + meta = c.get(f"{base}/api/_health").json() + except Exception as exc: # noqa: BLE001 + raise RuntimeError( + f"could not reach API server at {base}/api/_health ({exc!r}). " + "Start it with bench/scripts/start-api.sh." + ) from exc + if meta.get("py_resolver") != "tree_sitter": + raise RuntimeError( + f"API server at {base} is using py_resolver={meta.get('py_resolver')!r}. " + "The bench requires the tree-sitter static resolver — restart the " + "server with: CODE_GRAPH_PY_RESOLVER=tree_sitter " + "(bench/scripts/start-api.sh sets this by default)." + ) + + # Cheap precheck via FalkorDB GRAPH.LIST. The HTTP /api/list_repos + # path returned a list of names historically; it now returns dicts + # ({project, branch, graph}), so the old `name in repositories` + # match silently failed and every run re-indexed. GRAPH.LIST avoids + # that schema churn. + host = os.environ.get("FALKORDB_HOST", "127.0.0.1") + port = int(os.environ.get("FALKORDB_PORT", "6379")) + expected_graph = repo_name # the HTTP path uses bare folder name as graph key + try: + r = redis.Redis(host=host, port=port, decode_responses=True, socket_timeout=2) + graphs = r.execute_command("GRAPH.LIST") or [] + # Match either bare name (legacy) or "code::" pattern. + if expected_graph in graphs or any( + g == repo_name or g.startswith(f"code:{repo_name}:") for g in graphs + ): + print(f"[index] {repo_name} already in FalkorDB; skip") + return 0.0 + except Exception as exc: # noqa: BLE001 + print(f"[index] WARN GRAPH.LIST precheck failed ({exc!r}); attempting index anyway") + + print(f"[index] analyzing {repo_path} ...") + default_ignore = [ + ".git", "venv", ".venv", "node_modules", "__pycache__", + "rubi/rules", # sympy: blocks indexing for ~hours otherwise + "build", "dist", ".tox", ".eggs", + ] + # Bounded timeout so a server-side hang surfaces instead of stalling + # the entire benchmark. 30 min is generous for any sane repo and + # well below the previous 7200s that masked failures for an hour. + try: + with httpx.Client(timeout=httpx.Timeout(connect=10.0, read=1800.0, write=30.0, pool=10.0), + headers=headers) as c: + r = c.post( + f"{base}/api/analyze_folder", + json={"path": str(repo_path), "ignore": default_ignore}, + ) + if r.status_code != 200: + raise RuntimeError( + f"analyze_folder returned {r.status_code}: {r.text[:300]}. " + f"Check ALLOWED_ANALYSIS_DIR on the API server covers {repo_path}." + ) + elapsed = time.monotonic() - start + print(f"[index] indexed {repo_name} in {elapsed:.1f}s") + return elapsed + except httpx.ReadTimeout as exc: + elapsed = time.monotonic() - start + raise RuntimeError( + f"analyze_folder read-timeout after {elapsed:.0f}s on {repo_name} — " + f"API server likely hung indexing. Check uvicorn logs." + ) from exc + except Exception as exc: + raise RuntimeError(f"failed to index {repo_name} at {repo_path}: {exc}") from exc + + +def _ensure_indexed_mcp(repo_path: Path) -> float: + """MCP-track equivalent of _ensure_indexed. + + Drives the `index_repo` MCP tool in-process via the bench adapter + (avoids spawning a second cgraph-mcp just to bootstrap; the agent + will spawn its own per call). Same skip-if-present optimization + as the HTTP path: cheap GRAPH.LIST scan against FalkorDB. + + Returns wall-clock seconds spent indexing (0.0 if cache hit / skip). + """ + from bench.agents import code_graph_mcp_adapter as cgm + import redis + start = time.monotonic() + + repo_name = repo_path.name + branch = os.environ.get("CGRAPH_MCP_BRANCH", "_default") + host = os.environ.get("FALKORDB_HOST", "127.0.0.1") + port = int(os.environ.get("FALKORDB_PORT", "6379")) + expected_graph = f"code:{repo_name}:{branch}" + try: + r = redis.Redis(host=host, port=port, decode_responses=True, socket_timeout=2) + if expected_graph in (r.execute_command("GRAPH.LIST") or []): + print(f"[index-mcp] {expected_graph} already indexed; skip") + return 0.0 + except Exception as exc: # noqa: BLE001 + print(f"[index-mcp] WARN list_graphs failed ({exc!r}); will attempt index anyway") + + print(f"[index-mcp] indexing {repo_path} as {expected_graph} ...") + try: + payload = cgm.index_repo(str(repo_path), branch=branch) + if isinstance(payload, dict) and payload.get("error"): + print(f"[index-mcp] WARN index_repo error: {payload['error']!r}") + else: + print(f"[index-mcp] indexed in {time.monotonic() - start:.1f}s: {payload}") + except Exception as exc: # noqa: BLE001 + print(f"[index-mcp] WARN failed to index {repo_name}: {exc!r}") + return time.monotonic() - start + + +# --------------------------------------------------------------------------- +# Dry-run stub model +# --------------------------------------------------------------------------- + + +class _DryRunModel: + """A stand-in for a real LLM. Exercises mini-swe-agent's full loop + without making any network calls. + + Returns a single fake "tool call" that submits the task immediately + using mini-swe-agent's `COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` + bash protocol. The bash command itself echoes a tiny payload that + proves the per-config env is wired correctly (e.g. PATH for lsp / + code-graph configs contains `bench/cli/`). + + This is the v2 tool-calls shape: `extra.actions` is a list of dicts + with a `"command"` key, which is what `LocalEnvironment.execute` + consumes. + """ + + def __init__(self, *, marker: str = "dry-run-ok") -> None: + self.n_calls = 0 + self.cost = 0.0 + self.config = type("cfg", (), { + "model_name": "dry-run-stub", + "model_dump": lambda self_, **_: {"model_name": "dry-run-stub"}, + "multimodal_regex": "", + })() + self._marker = marker + + def query(self, messages: list[dict[str, Any]], **_: Any) -> dict[str, Any]: + self.n_calls += 1 + usage = {"prompt_tokens": 10 * self.n_calls, + "completion_tokens": 5, + "total_tokens": 10 * self.n_calls + 5} + # Bash payload: first line of stdout = sentinel; subsequent lines + # = final answer. The sentinel makes LocalEnvironment raise + # Submitted, which the agent treats as a clean exit. + cmd = ( + "printf 'COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT\\n%s\\n' " + f"'{self._marker}: PATH=$PATH'" + ) + return { + "role": "assistant", + "content": f"Submitting ({self._marker}).", + "extra": { + "actions": [{"command": cmd}], + "cost": 0.0, + "response": {"usage": usage}, + }, + } + + # The agent calls these helpers on the model; provide minimal stubs. + def format_message(self, **kwargs: Any) -> dict[str, Any]: + return dict(kwargs) + + def format_observation_messages( + self, message: dict[str, Any], outputs: list[dict[str, Any]], + template_vars: dict[str, Any] | None = None, + ) -> list[dict[str, Any]]: + return [{"role": "user", "content": str(o.get("output", ""))} for o in outputs] + + def get_template_vars(self, **_: Any) -> dict[str, Any]: + return {"model_name": "dry-run-stub"} + + def serialize(self) -> dict[str, Any]: + return {"info": {"config": {"model": {"model_name": "dry-run-stub"}, + "model_type": "dry-run-stub"}}} + + +# --------------------------------------------------------------------------- +# Single-task execution +# --------------------------------------------------------------------------- + + +def _capture_diff(repo_path: Path) -> str: + try: + out = subprocess.run( + ["git", "diff"], cwd=repo_path, capture_output=True, text=True, check=False, + ) + return out.stdout + except FileNotFoundError: + return "" + + +def verify_tool_available(config: str, env: dict[str, str], cwd: Path) -> tuple[bool, str]: + """Smoke-test the agent's primary tool before launching the trajectory. + + Returns (ok, message). When the tool is missing or crashes at startup, + the agent will silently fall back to plain bash and we'd attribute its + cheaper trajectory to the "tool" — invalidating the experiment. This + precheck makes that failure mode loud. + """ + if config == "baseline": + return True, "baseline: no tool" + cmd_map = { + "lsp": ["lsp", "--help"], + "code_graph": ["cg", "--help"], + "code_graph_mcp": ["cg-mcp", "--help"], + } + cmd = cmd_map.get(config) + if cmd is None: + return True, f"no precheck for config {config!r}" + try: + res = subprocess.run( + cmd, cwd=str(cwd), env=env, + capture_output=True, text=True, timeout=15, + ) + except FileNotFoundError as e: + return False, f"{cmd[0]} not on PATH: {e}" + except subprocess.TimeoutExpired: + return False, f"{cmd[0]} --help timed out (>15s)" + if res.returncode != 0: + tail = (res.stderr or res.stdout)[-300:] + return False, f"{cmd[0]} --help returncode={res.returncode}: {tail}" + return True, f"{cmd[0]} ok" + + +TOOL_KEYWORDS = { + "baseline": (), + "lsp": ("lsp",), + "code_graph": ("cg ",), + "code_graph_mcp": ("cg-mcp",), +} + +# Bash search commands the agent might fall back to instead of using the +# configured code-navigation tool. Matched as whole tokens against the +# bash command string. Tracked passively as a "fallback_rate" metric so +# we can quantify how often each tool track silently degrades to grep. +_FALLBACK_RE = re.compile(r"(?:^|[\s;&|`(])(grep|rg|find|ack|ag)(?:\s|$)") + + +def compute_tool_usage(messages: list[dict[str, Any]], config: str) -> dict[str, Any]: + """Count assistant bash commands that invoke the configured tool vs + fall back to plain text search. + + Returns {turns, tool_turns, fallback_turns, rate, fallback_rate}. + - rate = tool_turns / turns (None for baseline) + - fallback_rate = fallback_turns / turns (always reported; baseline's + fallback_rate is its raw grep/find usage and serves as a reference + point — tool tracks should be meaningfully below it). + + A low tool rate combined with a high fallback rate on a tool track + means the agent abandoned the tool and is operating as a baseline + with extra preamble. + """ + kws = TOOL_KEYWORDS.get(config, ()) + turns = 0 + tool_turns = 0 + fallback_turns = 0 + for m in messages: + if m.get("role") != "assistant": + continue + # mini-swe-agent v2 puts the bash command in tool_calls[*].function.arguments + tcs = m.get("tool_calls") or [] + for tc in tcs: + fn = tc.get("function") or {} + if fn.get("name") != "bash": + continue + args = fn.get("arguments") or "" + if isinstance(args, dict): + args = args.get("command", "") + if not isinstance(args, str): + args = str(args) + turns += 1 + if kws and any(kw in args for kw in kws): + tool_turns += 1 + if _FALLBACK_RE.search(args): + fallback_turns += 1 + rate = (tool_turns / turns) if (turns and kws) else None + fallback_rate = (fallback_turns / turns) if turns else None + return { + "turns": turns, + "tool_turns": tool_turns, + "rate": rate, + "fallback_turns": fallback_turns, + "fallback_rate": fallback_rate, + } + + +def run_task( + task: Task, + config: str, + *, + benchmark: str = "dry_run", + run_idx: int = 0, + model_name: str = "anthropic/claude-sonnet-4-5", + step_limit: int = 50, + cost_limit: float = 3.0, + wall_time_limit_seconds: int = 1200, + dry_run: bool = False, +) -> dict[str, Any]: + """Execute a single (task, config) pair. + + Returns a dict with keys: + metrics — a `bench.metrics.TaskMetrics` instance. + trajectory — the full mini-swe-agent `agent.serialize()` dict. + exit_status — "ok" | "error". + exit_reason — exception detail if exit_status == "error". + diff — `git diff` of the working tree after the run. + """ + if config not in VALID_CONFIGS: + raise ValueError(f"unknown config {config!r}; expected one of {VALID_CONFIGS}") + + # Late imports — they trigger litellm side effects. + from minisweagent.agents.default import DefaultAgent + from minisweagent.environments.local import LocalEnvironment # noqa: F401 (still referenced in docstring) + + from bench.runners.safe_local_env import SafeLocalEnvironment + + env_vars = config_env(config, task.repo_path) + + # PRECHECK: verify the tool actually launches before spending model $$. + # If the tool crashes (e.g. cg shim hits "Bad file descriptor" on Python + # init), the agent will silently fall back to bash for the entire + # trajectory and we'd attribute its behaviour to the tool. Hard-fail here + # so the issue is visible. + if not dry_run: + tool_ok, tool_msg = verify_tool_available(config, env_vars, task.repo_path) + if not tool_ok: + from bench.metrics import TaskMetrics + return { + "metrics": TaskMetrics( + benchmark=benchmark, task_id=task.task_id, config=config, + run_idx=run_idx, outcome="tool_unavailable", + wall_clock_sec=0.0, + ), + "trajectory": {"info": {"tool_precheck": tool_msg}}, + "exit_status": "error", + "exit_reason": f"tool precheck failed for {config}: {tool_msg}", + "diff": "", + } + + env = SafeLocalEnvironment(cwd=str(task.repo_path), env=env_vars, timeout=120) + preamble = load_preamble(config) + + if dry_run: + model: Any = _DryRunModel() + else: + from minisweagent.models.litellm_model import LitellmModel + + model = LitellmModel(model_name=model_name) + + agent = DefaultAgent( + model, + env, + system_template=preamble, + instance_template=load_instance_template(config), + step_limit=step_limit, + cost_limit=cost_limit, + wall_time_limit_seconds=wall_time_limit_seconds, + ) + + started = time.time() + exit_status = "ok" + exit_reason = "" + try: + agent.run(task=task.problem_statement) + except Exception as exc: # noqa: BLE001 — runner classifies all failures + exit_status = "error" + exit_reason = f"{type(exc).__name__}: {exc}" + wall = time.time() - started + + diff = _capture_diff(task.repo_path) + trajectory = agent.serialize() + # Ensure the diff is part of the trajectory so the metrics module's + # patch extractor can find it even if the agent's final message + # didn't embed it. + trajectory.setdefault("info", {})["submission"] = diff + + # Tool-usage instrumentation: surface trajectories where the agent + # silently abandoned the configured tool. Stored on the trajectory + # and on the metrics row so report.py can flag low-usage runs. + tool_usage = compute_tool_usage(trajectory.get("messages", []), config) + trajectory["info"]["tool_usage"] = tool_usage + + from bench.metrics import TaskMetrics, task_metrics_from_trajectory + + metrics: TaskMetrics = task_metrics_from_trajectory( + trajectory, + benchmark=benchmark, + task_id=task.task_id, + config=config, + run_idx=run_idx, + wall_clock_sec=round(wall, 3), + ) + metrics.tool_usage_rate = tool_usage["rate"] + metrics.tool_usage_turns = tool_usage["tool_turns"] + metrics.tool_usage_total = tool_usage["turns"] + metrics.fallback_turns = tool_usage["fallback_turns"] + metrics.fallback_rate = tool_usage["fallback_rate"] + if exit_status == "error": + metrics.outcome = "error" + + return { + "metrics": metrics, + "trajectory": trajectory, + "exit_status": exit_status, + "exit_reason": exit_reason, + "diff": diff, + } + + +# --------------------------------------------------------------------------- +# Batch driver +# --------------------------------------------------------------------------- + + +def _write_trajectory(task_id: str, config: str, trajectory: dict[str, Any], + traj_dir: Path) -> Path: + traj_dir.mkdir(parents=True, exist_ok=True) + path = traj_dir / f"{task_id}__{config}.json" + path.write_text(json.dumps(trajectory, indent=2, sort_keys=True, default=str)) + return path + + +def run_batch( + tasks: list[Task], + configs: list[str], + *, + benchmark: str = "dry_run", + results_path: Path = DEFAULT_RESULTS, + trajectories_dir: Path = DEFAULT_CACHE_DIR / "trajectories", + **run_kwargs: Any, +) -> list[dict[str, Any]]: + """Run every (task, config) combination and append rows to a JSONL file.""" + from bench.metrics import append_jsonl + + defer_jsonl = run_kwargs.pop("defer_jsonl", False) + results_path.parent.mkdir(parents=True, exist_ok=True) + rows: list[dict[str, Any]] = [] + for task in tasks: + for cfg in configs: + res = run_task(task, cfg, benchmark=benchmark, **run_kwargs) + _write_trajectory(task.task_id, cfg, res["trajectory"], trajectories_dir) + if not defer_jsonl: + append_jsonl(results_path, res["metrics"]) + rows.append(res) + return rows + + +# --------------------------------------------------------------------------- +# Dry-run dataset +# --------------------------------------------------------------------------- + + +def _make_dry_run_task(tmp: Path) -> Task: + """A trivially tiny synthetic repo used by --dry-run. + + The dry-run stub model just submits immediately, so this repo + doesn't need to be solvable — it only needs to be a valid git + working tree so `git diff` and `LocalEnvironment` cwd work. + """ + tmp.mkdir(parents=True, exist_ok=True) + (tmp / "hello.py").write_text("print('hi')\n") + subprocess.run(["git", "init", "-q"], cwd=tmp, check=False) + subprocess.run(["git", "add", "."], cwd=tmp, check=False) + subprocess.run( + ["git", "-c", "user.email=b@b", "-c", "user.name=b", "commit", "-q", "-m", "init"], + cwd=tmp, check=False, + ) + return Task( + task_id="dry-run-1", + repo_name="dry-run", + repo_path=tmp, + problem_statement="No-op task: just confirm the harness works end to end.", + ) + + +def _make_synthetic_smoke_task(tmp: Path) -> Task: + """A tiny but **non-trivial** synthetic task used by --real-run. + + Unlike `_make_dry_run_task` (no-op for the stub model), this task + requires the agent to actually *do* something: read a file, find + a bug, and submit a patch. It's deliberately ~2-minute work for a + competent LLM — enough to exercise the trajectory, tool-call, + token-accounting, and diff-capture paths end-to-end, without the + cost or time of a SWE-bench task. + """ + tmp.mkdir(parents=True, exist_ok=True) + (tmp / "math_utils.py").write_text( + "def add(a, b):\n" + " # BUG: should return a + b\n" + " return a - b\n" + "\n" + "def multiply(a, b):\n" + " return a * b\n" + ) + (tmp / "test_math_utils.py").write_text( + "from math_utils import add, multiply\n" + "\n" + "def test_add():\n" + " assert add(2, 3) == 5\n" + " assert add(0, 0) == 0\n" + " assert add(-1, 1) == 0\n" + "\n" + "def test_multiply():\n" + " assert multiply(2, 3) == 6\n" + ) + subprocess.run(["git", "init", "-q"], cwd=tmp, check=False) + subprocess.run(["git", "add", "."], cwd=tmp, check=False) + subprocess.run( + ["git", "-c", "user.email=b@b", "-c", "user.name=b", "commit", "-q", "-m", "init"], + cwd=tmp, check=False, + ) + return Task( + task_id="smoke-add-bug", + repo_name="smoke-synthetic", + repo_path=tmp, + problem_statement=( + "The `add` function in math_utils.py is buggy: it subtracts " + "instead of adding. Fix it so that `pytest test_math_utils.py` " + "passes. Do not modify the tests." + ), + ) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def _verify_smoke_task(repo_path: Path) -> tuple[bool, str]: + """Run the smoke task's pytest. Returns (resolved, output).""" + res = subprocess.run( + ["python", "-m", "pytest", "test_math_utils.py", "-q"], + cwd=repo_path, capture_output=True, text=True, check=False, timeout=60, + ) + return res.returncode == 0, res.stdout + res.stderr + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def main(argv: list[str] | None = None) -> int: + # Load .env from repo root if present, so users don't have to export + # provider creds manually. litellm picks up ANTHROPIC_API_KEY / + # ANTHROPIC_API_BASE / AZURE_API_KEY / GITHUB_API_KEY from process env. + try: + from dotenv import load_dotenv + + env_path = REPO_ROOT / ".env" + if env_path.exists(): + load_dotenv(env_path) + except ImportError: + pass + + p = argparse.ArgumentParser(description="code-graph benchmark runner") + p.add_argument("--config", choices=VALID_CONFIGS, action="append", + help="one of baseline / lsp / code_graph / code_graph_mcp; repeatable. " + "Default: all three.") + mode = p.add_mutually_exclusive_group(required=True) + mode.add_argument("--dry-run", action="store_true", + help="Stub LLM + no-op synthetic task. No API key needed.") + mode.add_argument("--real-run", action="store_true", + help="Real LLM + a tiny synthetic 'add' bug task. " + "Validates real token accounting and the actual " + "model loop without paying for SWE-bench. " + "Requires an LLM API key for the chosen --model.") + mode.add_argument("--swe-bench", action="store_true", + help="Real LLM + SWE-bench Verified instances. " + "Use --stage smoke|calibration|headline to pick the " + "sample size. Each instance: real clone + checkout " + "+ test_patch apply + agent run + FAIL_TO_PASS/" + "PASS_TO_PASS pytest verification.") + p.add_argument("--stage", choices=("smoke", "calibration", "headline"), + default="smoke", + help="SWE-bench stage (sample size). Only used with --swe-bench.") + p.add_argument("--limit", type=int, default=None, + help="Cap number of instances sampled. Overrides --stage size " + "for quick checks (e.g. --limit 1).") + p.add_argument("--results", type=Path, default=DEFAULT_RESULTS) + p.add_argument("--trajectories", type=Path, default=DEFAULT_CACHE_DIR / "trajectories") + p.add_argument("--model", default="anthropic/claude-sonnet-4-5", + help="litellm model name. Examples: " + "'anthropic/claude-sonnet-4-5' (needs ANTHROPIC_API_KEY); " + "'github/openai/gpt-4o-mini' (free GitHub Models, " + "needs GITHUB_TOKEN with models:read scope); " + "'github_copilot/gpt-4o' (uses your Copilot session, " + "device-code OAuth on first call).") + p.add_argument("--step-limit", type=int, default=50) + p.add_argument("--cost-limit", type=float, default=3.0) + p.add_argument("--wall-time", type=int, default=1200) + p.add_argument("--skip-verify", action="store_true", + help="Skip SWE-bench Docker verification; record " + "outcome=verify_skipped. Useful on hosts without " + "Docker for token-cost / tool-usage measurement runs.") + p.add_argument("--verify-timeout", type=int, default=1800, + help="Per-instance verification timeout in seconds " + "passed to swebench.harness (default 1800).") + args = p.parse_args(argv) + + configs = args.config or list(VALID_CONFIGS) + + import tempfile + + rows: list[dict[str, Any]] = [] + + if args.swe_bench: + from bench.datasets.swe_bench import ( + load_instances, sample_instances, prepare_worktree, + instance_to_task, verify_with_swebench_harness, + ) + from bench.metrics import append_jsonl + + insts = sample_instances(load_instances(), stage=args.stage) + if args.limit is not None: + insts = insts[: args.limit] + print(f"[swe-bench] stage={args.stage} running {len(insts)} instances " + f"x {len(configs)} configs = {len(insts) * len(configs)} trajectories") + for inst in insts: + for cfg in configs: + # Resume support: if a trajectory file for this (instance, cfg) + # already exists, skip the run entirely. Lets us recover from + # crashes / kills without re-spending tokens on completed work. + existing_traj = args.trajectories / f"{inst.instance_id}__{cfg}.json" + if existing_traj.exists(): + print(f"[resume] {inst.instance_id}/{cfg}: trajectory exists, skip") + continue + # Fresh worktree per (instance, config) to avoid cross-talk. + wt = prepare_worktree(inst) + # Rename so each cfg gets a distinct path. + cfg_wt = wt.parent / f"{inst.instance_id}__{cfg}" + if cfg_wt.exists(): + import shutil + shutil.rmtree(cfg_wt) + wt.rename(cfg_wt) + task = instance_to_task(inst, cfg_wt) + # For the code-graph track, the agent's `cg` commands query + # FalkorDB by repo name (= worktree dir name). The graph must + # exist before the task runs, otherwise every `cg find-symbol` + # call returns nothing and the agent abandons the tool. + if cfg == "code_graph": + index_sec = _ensure_indexed(cfg_wt) + elif cfg == "code_graph_mcp": + index_sec = _ensure_indexed_mcp(cfg_wt) + else: + index_sec = None + cfg_rows = run_batch( + [task], + [cfg], + benchmark="swe_bench_verified", + results_path=args.results, + trajectories_dir=args.trajectories, + model_name=args.model, + step_limit=args.step_limit, + cost_limit=args.cost_limit, + wall_time_limit_seconds=args.wall_time, + dry_run=False, + defer_jsonl=True, + ) + rows.extend(cfg_rows) + if cfg_rows and index_sec is not None: + cfg_rows[-1]["metrics"].index_sec = index_sec + # Official SWE-bench harness verification. The agent's + # patch is on the trajectory metrics; pass it to the + # Docker-backed harness. When Docker is missing the + # outcome is recorded as `verifier_unavailable` rather + # than silently graded `failed`. + patch = (cfg_rows[-1]["metrics"].patch or "") if cfg_rows else "" + if args.skip_verify: + cfg_rows[-1]["metrics"].outcome = "verify_skipped" + else: + resolved, summary = verify_with_swebench_harness( + inst, patch, timeout=args.verify_timeout, + ) + if resolved is None: + cfg_rows[-1]["metrics"].outcome = "verifier_unavailable" + else: + cfg_rows[-1]["metrics"].outcome = ( + "resolved" if resolved else "failed" + ) + cfg_rows[-1]["verify_summary"] = summary[-300:] + append_jsonl(args.results, cfg_rows[-1]["metrics"]) + else: + with tempfile.TemporaryDirectory() as td: + if args.dry_run: + task_fn = _make_dry_run_task + benchmark = "dry_run" + dry_run = True + else: + task_fn = _make_synthetic_smoke_task + benchmark = "synthetic_smoke" + dry_run = False + + verify_results: dict[str, bool] = {} + for cfg in configs: + repo_path = Path(td) / f"repo-{cfg}" + task = task_fn(repo_path) + cfg_rows = run_batch( + [task], + [cfg], + benchmark=benchmark, + results_path=args.results, + trajectories_dir=args.trajectories, + model_name=args.model, + step_limit=args.step_limit, + cost_limit=args.cost_limit, + wall_time_limit_seconds=args.wall_time, + dry_run=dry_run, + defer_jsonl=args.real_run, + ) + rows.extend(cfg_rows) + if args.real_run: + from bench.metrics import append_jsonl + + ok, _ = _verify_smoke_task(repo_path) + verify_results[cfg] = ok + cfg_rows[-1]["metrics"].outcome = "resolved" if ok else "failed" + append_jsonl(args.results, cfg_rows[-1]["metrics"]) + + for row in rows: + m = row["metrics"] + verdict = f" outcome={m.outcome}" if m.outcome else "" + print( + f"[{m.config:>10}] {m.task_id} " + f"exit={row['exit_status']} " + f"in={m.input_tokens} out={m.output_tokens} " + f"tool_calls={m.tool_calls_total} wall={m.wall_clock_sec}s{verdict}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bench/runners/safe_local_env.py b/bench/runners/safe_local_env.py new file mode 100644 index 00000000..a218df0a --- /dev/null +++ b/bench/runners/safe_local_env.py @@ -0,0 +1,128 @@ +"""Bench-only LocalEnvironment that kills the whole process group on timeout. + +Why this file exists +-------------------- + +``minisweagent.environments.local.LocalEnvironment.execute`` calls +``subprocess.run(shell=True, timeout=N)``. On timeout, Python sends +SIGKILL to the immediate shell PID — but any child the agent spawned +inside that shell (e.g. ``bash -c 'python -c "from sympy import ..."'``) +becomes orphaned and reparented to PID 1. We caught four such orphans +in the n=10 Opus run, each pegged at ~100% CPU for **3-4 hours** +after the parent trajectory had long since exited — all +``python -c "from sympy import *; factor(..., extension=[I])"`` +snippets the agent had run to reproduce the very infinite-loop bug it +was trying to fix in sympy-19040. + +Fix: spawn each command via ``Popen(start_new_session=True)`` so it +gets its own process group, then on timeout ``os.killpg(pgid, +SIGKILL)`` so every descendant dies. Output / returncode handling is +otherwise identical to upstream so trajectories remain comparable. +""" + +from __future__ import annotations + +import os +import platform +import signal +import subprocess +from typing import Any + +from minisweagent.environments.local import LocalEnvironment, LocalEnvironmentConfig +from minisweagent.exceptions import Submitted +from minisweagent.utils.serialize import recursive_merge + + +class SafeLocalEnvironment(LocalEnvironment): + """LocalEnvironment that SIGKILLs the whole process group on timeout.""" + + def execute(self, action: dict, cwd: str = "", *, timeout: int | None = None) -> dict[str, Any]: + command = action.get("command", "") + cwd = cwd or self.config.cwd or os.getcwd() + effective_timeout = timeout or self.config.timeout + + proc = subprocess.Popen( + command, + shell=True, + text=True, + cwd=cwd, + env=os.environ | self.config.env, + encoding="utf-8", + errors="replace", + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + # Put the shell — and every descendant — in its own process + # group so we can SIGKILL the whole tree on timeout. + start_new_session=True, + ) + + try: + stdout, _ = proc.communicate(timeout=effective_timeout) + output = { + "output": stdout or "", + "returncode": proc.returncode, + "exception_info": "", + } + except subprocess.TimeoutExpired as e: + # The shell ignored its deadline (or the agent's command + # double-forked). Kill the *group* so orphaned children + # (e.g. `python -c "from sympy import ..."`) die too. + self._kill_process_group(proc.pid) + try: + stdout, _ = proc.communicate(timeout=5) + except Exception: + stdout = "" + raw = e.output or stdout or "" + if isinstance(raw, bytes): + raw = raw.decode("utf-8", errors="replace") + output = { + "output": raw, + "returncode": -1, + "exception_info": f"An error occurred while executing the command: {e}", + "extra": { + "exception_type": type(e).__name__, + "exception": str(e), + "killed_process_group": True, + }, + } + except Exception as e: + # Best-effort: still try to kill anything we spawned. + try: + self._kill_process_group(proc.pid) + except Exception: + pass + raw = getattr(e, "output", None) or "" + if isinstance(raw, bytes): + raw = raw.decode("utf-8", errors="replace") + output = { + "output": raw, + "returncode": -1, + "exception_info": f"An error occurred while executing the command: {e}", + "extra": {"exception_type": type(e).__name__, "exception": str(e)}, + } + + self._check_finished(output) + return output + + @staticmethod + def _kill_process_group(pid: int) -> None: + try: + pgid = os.getpgid(pid) + except ProcessLookupError: + return + for sig in (signal.SIGTERM, signal.SIGKILL): + try: + os.killpg(pgid, sig) + except ProcessLookupError: + return + except PermissionError: + return + # Give SIGTERM a brief moment before escalating to SIGKILL. + if sig is signal.SIGTERM: + try: + os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + pass + + +__all__ = ["SafeLocalEnvironment", "LocalEnvironmentConfig"] diff --git a/bench/runners/swebench_verify.py b/bench/runners/swebench_verify.py new file mode 100644 index 00000000..c3191539 --- /dev/null +++ b/bench/runners/swebench_verify.py @@ -0,0 +1,188 @@ +"""Convert our results.jsonl into SWE-bench predictions and run the +official Docker-based evaluation harness. + +The SWE-bench harness expects predictions in the form: + + {"instance_id": "...", "model_name_or_path": "...", "model_patch": "..."} + +one per line. We have one row per (instance_id, config) in +results.jsonl with `metrics.patch` already populated as a `git diff`. +This module: + +1. Exports per-config predictions JSONL files from results.jsonl. +2. Optionally shells out to `python -m swebench.harness.run_evaluation` + (requires the `swebench` package and Docker) for each config. +3. Reads the harness report JSON back and patches outcomes in + results.jsonl so the report aggregator picks them up. + +Usage: + # Export only (no harness invocation): + uv run python -m bench.runners.swebench_verify --export-only + + # Full eval (needs swebench + Docker): + uv run python -m bench.runners.swebench_verify --run-id smoke1 +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +from collections import defaultdict +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_RESULTS = REPO_ROOT / "bench" / "cache" / "results.jsonl" +DEFAULT_PRED_DIR = REPO_ROOT / "bench" / "cache" / "predictions" +DEFAULT_REPORT_DIR = REPO_ROOT / "bench" / "cache" / "harness_reports" + + +def _load_jsonl(path: Path) -> list[dict[str, Any]]: + return [json.loads(l) for l in path.read_text().splitlines() if l.strip()] + + +def _dump_jsonl(rows: list[dict[str, Any]], path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w") as f: + for r in rows: + f.write(json.dumps(r) + "\n") + + +def export_predictions( + results_path: Path = DEFAULT_RESULTS, + out_dir: Path = DEFAULT_PRED_DIR, + *, + benchmark: str = "swe_bench_verified", +) -> dict[str, Path]: + """Write per-config predictions JSONL files. Returns {config: path}.""" + rows = _load_jsonl(results_path) + by_cfg: dict[str, list[dict[str, Any]]] = defaultdict(list) + for r in rows: + if r.get("benchmark") != benchmark: + continue + if not r.get("patch"): + continue + by_cfg[r["config"]].append({ + "instance_id": r["task_id"], + "model_name_or_path": f"code-graph-bench-{r['config']}", + "model_patch": r["patch"], + }) + + out_dir.mkdir(parents=True, exist_ok=True) + paths: dict[str, Path] = {} + for cfg, preds in by_cfg.items(): + p = out_dir / f"{cfg}.jsonl" + _dump_jsonl(preds, p) + paths[cfg] = p + return paths + + +def run_harness( + predictions: Path, + *, + run_id: str, + dataset_name: str = "princeton-nlp/SWE-bench_Verified", + report_dir: Path = DEFAULT_REPORT_DIR, + max_workers: int = 4, + timeout: int = 1800, +) -> Path: + """Invoke swebench.harness.run_evaluation. Returns path to report JSON.""" + if shutil.which("docker") is None: + raise RuntimeError("docker not found in PATH — SWE-bench harness needs Docker") + report_dir.mkdir(parents=True, exist_ok=True) + cmd = [ + sys.executable, "-m", "swebench.harness.run_evaluation", + "--dataset_name", dataset_name, + "--predictions_path", str(predictions), + "--max_workers", str(max_workers), + "--run_id", run_id, + "--timeout", str(timeout), + ] + print(f"[harness] $ {' '.join(cmd)}") + subprocess.run(cmd, check=True, cwd=str(report_dir)) + # Harness writes reports to ./logs/run_evaluation/{run_id}/... and a + # top-level ..json with the rolled-up verdict. + candidates = list(report_dir.glob(f"*.{run_id}.json")) + if not candidates: + raise RuntimeError(f"no report json found under {report_dir} for run_id={run_id}") + return candidates[0] + + +def patch_outcomes_from_report( + report_path: Path, + results_path: Path = DEFAULT_RESULTS, + *, + config: str, +) -> int: + """Read a harness report and rewrite outcomes for `config` rows in results.jsonl. + + Report shape (rolled-up): + {"resolved_ids": [...], "unresolved_ids": [...], ...} + Returns the number of rows patched. + """ + report = json.loads(report_path.read_text()) + resolved = set(report.get("resolved_ids", [])) + unresolved = set(report.get("unresolved_ids", [])) + + rows = _load_jsonl(results_path) + n = 0 + for r in rows: + if r.get("config") != config: + continue + if r["task_id"] in resolved: + r["outcome"] = "resolved"; n += 1 + elif r["task_id"] in unresolved: + r["outcome"] = "failed"; n += 1 + _dump_jsonl(rows, results_path) + return n + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="SWE-bench Docker-based verification") + p.add_argument("--results", type=Path, default=DEFAULT_RESULTS) + p.add_argument("--pred-dir", type=Path, default=DEFAULT_PRED_DIR) + p.add_argument("--report-dir", type=Path, default=DEFAULT_REPORT_DIR) + p.add_argument("--run-id", default="bench-smoke", + help="harness run id (used as a logs/ subdir)") + p.add_argument("--max-workers", type=int, default=4) + p.add_argument("--timeout", type=int, default=1800) + p.add_argument("--export-only", action="store_true", + help="just write per-config predictions JSONL; don't run harness") + args = p.parse_args(argv) + + if not args.results.exists(): + print(f"error: results not found: {args.results}") + return 1 + + paths = export_predictions(args.results, args.pred_dir) + if not paths: + print("[export] no swe_bench_verified rows with patches in results.jsonl") + return 1 + print(f"[export] wrote predictions for configs: {sorted(paths)}") + for cfg, path in paths.items(): + n = sum(1 for _ in path.open()) + print(f" - {cfg}: {n} predictions -> {path}") + + if args.export_only: + return 0 + + for cfg, pred_path in paths.items(): + run_id = f"{args.run_id}-{cfg}" + try: + report = run_harness( + pred_path, run_id=run_id, report_dir=args.report_dir, + max_workers=args.max_workers, timeout=args.timeout, + ) + except Exception as exc: + print(f"[harness] {cfg}: FAILED — {exc}") + continue + n = patch_outcomes_from_report(report, args.results, config=cfg) + print(f"[harness] {cfg}: patched {n} outcomes from {report}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bench/scripts/start-api.sh b/bench/scripts/start-api.sh new file mode 100755 index 00000000..4e55f673 --- /dev/null +++ b/bench/scripts/start-api.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Launch the code-graph API server with the fast tree-sitter Python +# resolver enabled (PR #691 + #692). This is what the bench harness +# expects to talk to at 127.0.0.1:5000. +# +# Usage: +# bench/scripts/start-api.sh # default port 5000 +# bench/scripts/start-api.sh --port 5001 +# +# Prereqs: +# - FalkorDB running. For native falkordb on 6380 set +# FALKORDB_HOST=127.0.0.1 FALKORDB_PORT=6380 before invoking. +# - uv on PATH. +# - cwd must be a code-graph worktree containing api/ with PR #691 +# and PR #692 applied (i.e. the dvirdukhan/query-cache branch tip +# or staging once those are merged). + +set -euo pipefail + +PORT=5000 +while [[ $# -gt 0 ]]; do + case "$1" in + --port) PORT="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +# Tree-sitter static resolver — turns Python indexing from minutes to +# seconds. Default is still jedi, so callers must opt in explicitly. +export CODE_GRAPH_PY_RESOLVER="${CODE_GRAPH_PY_RESOLVER:-tree_sitter}" + +# Allow the bench harness to analyze any folder; the bench worktrees +# live under bench/cache/worktrees. +export ALLOWED_ANALYSIS_DIR="${ALLOWED_ANALYSIS_DIR:-/}" + +# Public mode: bench harness does not bother with bearer tokens. +export CODE_GRAPH_PUBLIC="${CODE_GRAPH_PUBLIC:-1}" + +echo "[start-api] CODE_GRAPH_PY_RESOLVER=$CODE_GRAPH_PY_RESOLVER" +echo "[start-api] CODE_GRAPH_PUBLIC=$CODE_GRAPH_PUBLIC" +echo "[start-api] FALKORDB_HOST=${FALKORDB_HOST:-127.0.0.1} FALKORDB_PORT=${FALKORDB_PORT:-6379}" +echo "[start-api] Listening on 127.0.0.1:$PORT" + +exec uv run uvicorn api.index:app --host 127.0.0.1 --port "$PORT" diff --git a/bench/tools/baseline/system_preamble.md b/bench/tools/baseline/system_preamble.md new file mode 100644 index 00000000..3ac81c69 --- /dev/null +++ b/bench/tools/baseline/system_preamble.md @@ -0,0 +1,20 @@ +# Baseline preamble + +You are an autonomous coding agent solving a software-engineering task. +Your sole tool is bash: every action you take is a shell command that +is executed in the repository's working directory. + +You have **no** code-navigation tools beyond what a stock Unix shell +gives you. Use `cat`, `grep`/`rg`, `find`, `sed`, and standard editing +to read and modify the codebase. + +When you believe the task is complete, run a bash command whose first +line of stdout is exactly: + +``` +COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT +``` + +followed by your final answer or summary on subsequent lines. The +runner reads the working-tree `git diff` automatically; you do not +need to commit. diff --git a/bench/tools/baseline/tools.yaml b/bench/tools/baseline/tools.yaml new file mode 100644 index 00000000..5c6596ef --- /dev/null +++ b/bench/tools/baseline/tools.yaml @@ -0,0 +1,18 @@ +# SWE-agent tool bundle: baseline config. +# +# This is intentionally just SWE-agent's default file-edit + bash tools. +# It is the control group for the whole benchmark: an LLM with realistic +# I/O but no code-navigation tools. NOT "zero tools" — that comparison +# would be unrealistic. +# +# Wired into the agent via SWE-agent's --tools-config flag once the +# runner lands. The exact set of tool names below mirrors SWE-agent's +# default bundle and will be validated against the installed sweagent +# version at runtime by bench/runners/swe_bench.py. + +tools: + - read_file + - write_file + - edit # str_replace style + - bash + - submit diff --git a/bench/tools/code_graph/system_preamble.md b/bench/tools/code_graph/system_preamble.md new file mode 100644 index 00000000..99ad597b --- /dev/null +++ b/bench/tools/code_graph/system_preamble.md @@ -0,0 +1,51 @@ +# code-graph preamble + +You are an autonomous coding agent solving a software-engineering task. +Your sole tool is bash: every action you take is a shell command that +is executed in the repository's working directory. + +A pre-indexed code-graph for this repo is available via `cg`. +**Use `cg` to locate symbols before reading files or grepping.** +`$REPO_NAME` is exported. + +## Workflow + +1. `cg find-symbol --repo "$REPO_NAME" --name ` → `{id, file, line}`. +2. `cg get-neighbors --repo "$REPO_NAME" --ids [--limit 50]` → + callers / callees / definitions. Default limit 50 keeps output small; + pass `--limit 0` only if you truly need everything. +3. Read the file with `sed -n` / `cat`, then edit. +4. After every edit run `cg note-edit --repo "$REPO_NAME" --path `. + +## Sub-commands + +- `cg find-symbol --repo R --name NAME` +- `cg get-neighbors --repo R --ids N [N ...] [--limit N]` +- `cg find-paths --repo R --src N --dst N` +- `cg auto-complete --repo R --prefix STRING` +- `cg note-edit --repo R --path PATH` (call after every edit) +- `cg graph-entities --repo R` (large; rarely needed) + +## Rules + +- **Do not call the same `cg` query twice for the same symbol.** + Cache the result mentally; if you need it again, re-read the + earlier tool output in this conversation. +- **Do not fall back to `grep`/`rg`/`find` silently.** If `cg` + returns empty, say so in your next message before grepping. +- Standard Unix tools (`cat`, `grep`, `find`, `sed`) remain available + for cases the graph can't answer. + +## Submission + +When you believe the task is complete, run a bash command whose first +line of stdout is exactly: + +``` +COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT +``` + +followed by your final answer or summary on subsequent lines. The +runner reads the working-tree `git diff` automatically; you do not +need to commit. **Once you emit this sentinel, stop — do not re-emit +the diff or run further commands.** diff --git a/bench/tools/code_graph/tools.yaml b/bench/tools/code_graph/tools.yaml new file mode 100644 index 00000000..fd5c4695 --- /dev/null +++ b/bench/tools/code_graph/tools.yaml @@ -0,0 +1,32 @@ +# SWE-agent tool bundle: code-graph config. +# +# Baseline + primitive graph tools backed by this repo's FastAPI service +# at api/. The agent's container reaches the host code-graph service via +# host network (see bench/runners/swe_bench.py docker setup). +# +# IMPORTANT: the GraphRAG `chat` endpoint is intentionally excluded. +# Including it would mean benchmarking a nested LLM agent and would +# double-count tokens. We benchmark the *graph*, not GraphRAG. + +extends: ../baseline/tools.yaml + +tools: + - graph_entities # (repo) -> nodes+edges (paginated) + - get_neighbors # (repo, node_ids) -> adjacent nodes + - find_paths # (repo, src_id, dest_id) -> [path] + - auto_complete # (repo, prefix) -> [symbol] + - find_symbol # (repo, name) -> [node] NEW helper; thin wrapper + # over auto_complete + filter for exact match. + - note_edit # (repo, path) -> ok NEW. Called by the agent + # after every write_file/edit. Triggers a + # single-file incremental re-index on the + # code-graph backend. Keeps the graph current + # against agent edits without paying a full + # re-index. Fairness vs live-LSP. + +backend: + service_url: http://host.docker.internal:5000 + auth_token_env: SECRET_TOKEN # injected via .env, never committed + + # Each task indexes its @ via POST /api/analyze_folder + # then exposes the five tools above. Cache: bench/cache/codegraph/. diff --git a/bench/tools/code_graph_mcp/system_preamble.md b/bench/tools/code_graph_mcp/system_preamble.md new file mode 100644 index 00000000..b20ae927 --- /dev/null +++ b/bench/tools/code_graph_mcp/system_preamble.md @@ -0,0 +1,53 @@ +# code-graph (MCP) preamble + +You are an autonomous coding agent solving a software-engineering task. +Your sole tool is bash: every action you take is a shell command that +is executed in the repository's working directory. + +A pre-indexed code-graph for this repo is available via the +`cg-mcp` CLI (talks to `cgraph-mcp` over stdio). +**Use `cg-mcp` to locate symbols before reading files or grepping.** +`$PROJECT_NAME` and `$BRANCH` are exported. + +## Workflow + +1. `cg-mcp search_code --project "$PROJECT_NAME" --prefix ` → + list of `{id, name, file, line}`. Pick the best `id`. +2. `cg-mcp get_callers --project "$PROJECT_NAME" --symbol-id ` — + who calls X. (Default `--limit 50`.) +3. `cg-mcp impact_analysis --project "$PROJECT_NAME" --symbol-id --depth 3` — + transitive blast radius before any non-trivial edit. +4. Read the file with `sed -n` / `cat`, then edit. + +## Sub-commands + +- `cg-mcp search_code --project P --prefix STR [--limit N]` +- `cg-mcp get_callers --project P --symbol-id ID [--limit N]` +- `cg-mcp get_callees --project P --symbol-id ID [--limit N]` +- `cg-mcp get_dependencies --project P --symbol-id ID [--limit N]` +- `cg-mcp impact_analysis --project P --symbol-id ID [--direction IN|OUT] [--depth N] [--limit N]` +- `cg-mcp find_path --project P --source-id ID --dest-id ID` + +## Rules + +- **Do not call the same `cg-mcp` query twice for the same symbol.** + Cache the result mentally; if you need it again, re-read the + earlier tool output in this conversation. +- **Do not fall back to `grep`/`rg`/`find` silently.** If `cg-mcp` + returns empty, say so in your next message before grepping. +- Standard Unix tools remain available for cases the graph can't + answer. + +## Submission + +When you believe the task is complete, run a bash command whose first +line of stdout is exactly: + +``` +COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT +``` + +followed by your final answer or summary on subsequent lines. The +runner reads the working-tree `git diff` automatically; you do not +need to commit. **Once you emit this sentinel, stop — do not re-emit +the diff or run further commands.** diff --git a/bench/tools/code_graph_mcp/tools.yaml b/bench/tools/code_graph_mcp/tools.yaml new file mode 100644 index 00000000..3b676977 --- /dev/null +++ b/bench/tools/code_graph_mcp/tools.yaml @@ -0,0 +1,39 @@ +# SWE-agent tool bundle: code-graph MCP-transport config. +# +# This is the MCP-transport sibling of bench/tools/code_graph/tools.yaml. +# Same backend graph; different transport. Where `code_graph` calls the +# host FastAPI service over HTTP, `code_graph_mcp` spawns the +# `cgraph-mcp` stdio server for each tool call — the exact transport +# Claude Code / Cursor / Cline use in production. +# +# Tool names mirror the 8 MCP tools registered in api/mcp/tools/ +# (search_code, get_callers, get_callees, get_dependencies, +# impact_analysis, find_path, index_repo, ask). The bash agent calls +# them through the `cg-mcp ...` shim (see bench/cli/cg-mcp). +# +# IMPORTANT: `ask` (GraphRAG) is intentionally NOT in the tool list. +# Including it would double-count tokens (nested LLM agent). Same Q2 +# decision as the HTTP code_graph config — we benchmark the *graph*, +# not GraphRAG. + +extends: ../baseline/tools.yaml + +tools: + - index_repo # (path_or_url, branch?) -> indexing stats + - search_code # (project, prefix) -> [symbol] + - get_callers # (project, symbol_id) -> [caller] + - get_callees # (project, symbol_id) -> [callee] + - get_dependencies # (project, symbol_id) -> [dep] + - impact_analysis # (project, symbol_id, direction, depth) -> [impacted] + - find_path # (project, source_id, dest_id) -> [path] + +backend: + transport: mcp_stdio + command: cgraph-mcp + # Container has cgraph-mcp on PATH via `pip install -e .` against this + # repo. FALKORDB_HOST/PORT are passed through to the spawned MCP + # server, pointing at the same host FalkorDB the HTTP config uses. + env_passthrough: + - FALKORDB_HOST + - FALKORDB_PORT + - MODEL_NAME diff --git a/bench/tools/lsp/shim.yaml b/bench/tools/lsp/shim.yaml new file mode 100644 index 00000000..a4544a40 --- /dev/null +++ b/bench/tools/lsp/shim.yaml @@ -0,0 +1,35 @@ +# LSP response shim — implementation contract. +# +# The `lsp` config wraps each multilspy/pyright tool in this adapter so +# raw LSP verbosity doesn't dominate the token-cost comparison. +# See CONTEXT.md "LSP response shim" for the rationale. +# +# This file is the spec. Actual code lands in bench/tools/lsp/adapter.py +# when the runner is implemented. + +caps: + max_results_per_call: 50 + hover_signature_lines: 1 + hover_docstring_sentences: 1 + +pagination: + tools_supporting_page_arg: + - find_references + - workspace_symbols + - document_symbols + page_size: 50 + +location_shape: + # Output shape for every location-returning tool. Replaces the raw + # LSP Range object (uri + start/end line/col + character offsets). + fields: [path, line, col] + +opt_in_tools: + # Full-verbosity tools the agent must explicitly request. + hover_full: + description: "Full raw hover markdown for this symbol (verbose)." + +audit: + # Every shimmed call records (raw_token_estimate, shimmed_token_count) + # so the writeup can show how much the shim reduced. + log_raw_vs_shimmed: true diff --git a/bench/tools/lsp/system_preamble.md b/bench/tools/lsp/system_preamble.md new file mode 100644 index 00000000..d6a30df7 --- /dev/null +++ b/bench/tools/lsp/system_preamble.md @@ -0,0 +1,64 @@ +# LSP preamble + +You are an autonomous coding agent solving a software-engineering task. +Your sole tool is bash: every action you take is a shell command that +is executed in the repository's working directory. + +## Code-navigation workflow — use the LSP BEFORE grep + +An `lsp` command on PATH wraps a Python language server (jedi via +multilspy). **Prefer `lsp` over grep/find for "where is this defined?" +and "what calls this?"** — it follows imports and respects scope, +unlike textual search. + +Typical loop: + +1. Use `grep -rn` once to find a candidate file:line for a symbol you + want to investigate (jedi needs a concrete position to query). +2. `lsp goto-definition --file PATH --line N --col N` to jump to its + real definition. +3. `lsp find-references --file PATH --line N --col N` to enumerate + callers/usages before editing. +4. Read the implicated file(s) with `sed -n` / `cat`, then edit. + +## Available `lsp` sub-commands + +- `lsp goto-definition --file PATH --line N --col N` — locate the + definition of the symbol at the given position. +- `lsp find-references --file PATH --line N --col N` — find every + reference (capped at 50). +- `lsp hover --file PATH --line N --col N` — trimmed hover + signature/doc for the symbol. +- `lsp document-symbols --file PATH` — outline of classes, functions, + and top-level symbols in the file. + +Lines and columns are **zero-indexed**. Paths are relative to the repo +root. Each `lsp` call starts its own language-server subprocess +(~1-3 seconds), so batch where you can. + +## Rules of thumb + +1. **At least one `lsp` call before any source edit.** Use + `find-references` to map callers and `goto-definition` to confirm + the symbol's home file before reading. +2. **Do not fall back to `grep`/`rg`/`find` silently for "what calls + this?".** Your trajectory is being measured for tool-usage rate. + If `lsp` errors or returns nothing, state that explicitly in your + next message (e.g. "lsp find-references returned empty at L:C, + falling back to rg") BEFORE running the fallback. Trajectories + where you abandon `lsp` after one failure without explanation are + flagged as invalid measurements. + +## Submission + +When you believe the task is complete, run a bash command whose first +line of stdout is exactly: + +``` +COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT +``` + +followed by your final answer or summary on subsequent lines. The +runner reads the working-tree `git diff` automatically; you do not +need to commit. + diff --git a/bench/tools/lsp/tools.yaml b/bench/tools/lsp/tools.yaml new file mode 100644 index 00000000..f9f11a97 --- /dev/null +++ b/bench/tools/lsp/tools.yaml @@ -0,0 +1,32 @@ +# SWE-agent tool bundle: lsp config. +# +# Baseline + LSP-backed navigation via multilspy. The benchmark +# instantiates one multilspy SyncLanguageServer per task (cached per +# @) and exposes the four tools below as SWE-agent custom +# commands that shell out to bench/agents/lsp_adapter.py. + +extends: ../baseline/tools.yaml + +tools: + - goto_definition # (file, line, col) -> [{path, line, col}] + - find_references # (file, line, col) -> [{path, line, col}] + - hover # (file, line, col) -> {text} + - document_symbols # (file) -> [{name, kind, path, line, col}] + +# workspace_symbols is intentionally NOT exposed: the multilspy fork we +# depend on (forked at python-init-params) does not implement +# request_workspace_symbol. The agent falls back to bash+grep, which is +# also what real-world LSP workflows do. + +backend: + language_server: jedi-language-server + driver: multilspy + # Notes: + # - The plan originally specified pyright. multilspy ≥0.0.15 is required + # for that, but our pinned fork is older (it adds environment_path + # support for jedi to multilspy 0.0.11). The shim normalizes responses + # so jedi-vs-pyright doesn't affect the validity comparison. + # - environment_path is passed through MultilspyConfig.from_dict; + # defaults to the current interpreter's sys.prefix for jedi. + # + # Cache: bench/cache/lsp/@ tracks first-run setup time. diff --git a/docker-compose.yml b/docker-compose.yml index e6de30d2..dd2e9f30 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,4 +22,21 @@ services: - FALKORDB_PORT=6379 - OPENAI_API_KEY=${OPENAI_API_KEY:-} - SECRET_TOKEN=${SECRET_TOKEN:-} - - CODE_GRAPH_PUBLIC=1 \ No newline at end of file + - CODE_GRAPH_PUBLIC=1 + + # MCP stdio server — opt-in. Bring up with: + # docker compose run --rm -i code-graph-mcp + # then point Claude Code / Cursor at the running container's stdio. + code-graph-mcp: + build: . + depends_on: + - falkordb + profiles: ["mcp"] + stdin_open: true + tty: false + environment: + - CGRAPH_MODE=mcp + - FALKORDB_HOST=falkordb + - FALKORDB_PORT=6379 + - MODEL_NAME=${MODEL_NAME:-gemini/gemini-flash-lite-latest} + - CODE_GRAPH_AUTO_INDEX=${CODE_GRAPH_AUTO_INDEX:-} \ No newline at end of file diff --git a/docs/MCP_SERVER_DESIGN.md b/docs/MCP_SERVER_DESIGN.md new file mode 100644 index 00000000..38b2f077 --- /dev/null +++ b/docs/MCP_SERVER_DESIGN.md @@ -0,0 +1,321 @@ +# Code Graph MCP Server — Design Summary + +## What We're Building + +An MCP server inside the `code-graph` repo that gives AI coding agents two capabilities: +1. **Structural tools** (deterministic, no LLM): get_callers, get_callees, get_dependencies, impact_analysis, find_path, search_code, index_repo +2. **Ask tool** (GraphRAG SDK, needs LLM): natural language questions about the codebase → NL-to-Cypher → grounded answers + +Phase 1 also bundles three foundational improvements to `api/` that the MCP server needs and that benefit all consumers of code-graph (CLI, web UI, future integrations): + +3. **Multi-branch graph identity**: per `(repo, branch)` graph naming so concurrent agents on different branches can't corrupt each other's view. (Today, two users on different branches overwrite each other's graphs — this is a real bug.) +4. **Incremental indexing**: file-hash-based skip-unchanged so agents can call `index_repo` cheaply on every interaction. Default-on once a graph exists. +5. **Tree-sitter language expansion**: a shared `TreeSitterAnalyzer` base class plus 5 new languages (Go, Rust, TypeScript, Ruby, C++) and re-enabling C. Brings supported languages from 5 to 11. + +## Key Decisions Made + +- **Mono-repo**: Build inside `code-graph/api/mcp/`, not a separate project. One pip package, one repo. +- **Module path is `api/mcp/`, NOT top-level `mcp/`**: A top-level `mcp/` directory would shadow the installed `mcp` PyPI SDK and break `from mcp.server.fastmcp import FastMCP`. Entry point: `cgraph-mcp = "api.mcp.server:main"`. +- **Python MCP server**: Use the official `mcp` Python SDK (`from mcp.server.fastmcp import FastMCP`), NOT standalone `fastmcp` or Node.js. Avoids language bridge. +- **Reuse everything**: Most code already exists. The MCP tools are thin wrappers around `api/graph.py`, `api/project.py`, `api/cli.py`, and `api/llm.py`. +- **GraphRAG SDK powers the ask tool**: `kg.ask()` does NL-to-Cypher. Code-graph's `api/llm.py` already integrates this — repackage for MCP. +- **Reuse the existing hand-coded ontology** from `api/llm.py:_define_ontology()` (lines 26–233) rather than auto-extracting via `Ontology.from_kg_graph()`. The hand-coded version has richer entity attributes and descriptions tuned for code. Refactor: rename `_define_ontology` → `define_ontology` so the MCP module can import it. +- **11 languages in Phase 1**: Python/JS/Kotlin via tree-sitter (refactored onto a shared base class in T15), Go/Rust/TypeScript/Ruby/C/C++ added in T16, Java/C# stay on multilspy. +- **Incremental indexing in Phase 1**: file-hash-based skip-unchanged, default-on once a `(project, branch)` graph exists (T18). Full re-index via `--full` (CLI) or `incremental=False` (MCP). +- **No Graphiti/memory or raw FalkorDB MCP in v1**: Out of scope. Available as separate servers. Architecture supports merging later. +- **Auto-init for zero config**: ensure-db auto-starts FalkorDB Docker, auto-index on first tool call, auto-GraphRAG init. +- **Expose Cypher in ask responses**: Transparency for the agent + learning patterns. +- **Stdio transport only in Phase 1**: HTTP/SSE deferred to Phase 1.5. Stdio is sufficient for Claude Code, Cursor, and Claude Desktop. +- **`impact_analysis` defaults**: direction `IN` (upstream callers — "what breaks if I change this"), depth `3`, max depth clamp `10`. Parameters allow override. +- **Multi-branch graph identity**: graph name is `code:{project}:{branch}`. Sourcegraph-zoekt-style isolation. The IDE/LSP "single current index, re-index on switch" model only works because IDEs are single-tenant; code-graph is multi-tenant (a server, not a desktop tool, with concurrent agents on different branches), so per-branch isolation is required for correctness. Existing single-graph deployments migrate to `code:{project}:_default`. Within a branch, the existing transition-based `switch_commit` flow continues to work unchanged. +- **`index_repo` auto-detects branch**: from `git rev-parse --abbrev-ref HEAD` in the target path. Optional override parameter. Non-git paths use `_default`. +- **Incremental indexing default-on**: once a `(project, branch)` graph exists, `index_repo` and `cgraph index` auto-detect and run incremental. Opt out with `--full` (CLI) or `incremental=False` (MCP). First-time runs are full. Tracks per-file SHA256 hashes in Redis under `{repo}:{branch}_files`. Built on the existing `delete_files()` primitive in `api/graph.py` (today only used in the git-history flow). +- **Tree-sitter base class**: refactor existing PythonAnalyzer / JavaScriptAnalyzer / KotlinAnalyzer onto a shared `TreeSitterAnalyzer` base in Phase 1. Five new languages added on the new base in the same phase. C is re-enabled. Java and C# stay on multilspy (LSP) until a future phase. + +## Directory Structure + +```text +code-graph/ +├── api/ # Existing Python backend (FastAPI, analyzers, graph, llm, cli) +│ └── mcp/ # NEW — MCP server module (under api/ to avoid shadowing the installed `mcp` SDK) +│ ├── __init__.py +│ ├── server.py # FastMCP entry point, tool registration, stdio transport +│ ├── auto_init.py # ensure-db + auto-index hooks +│ ├── graphrag_init.py # KnowledgeGraph construction + per-project caching (reuses api/llm.py:define_ontology) +│ ├── code_prompts.py # Re-exports + hooks for GraphRAG prompts (sourced from api/prompts.py) +│ ├── templates/ # Agent guidance file templates (cursorrules, claude_mcp_section) +│ └── tools/ +│ ├── __init__.py +│ ├── structural.py # index_repo, get_callers, get_callees, get_dependencies, +│ │ # impact_analysis, find_path, search_code +│ └── ask.py # GraphRAG-powered ask tool +├── app/ # Existing React frontend +├── skills/ # Existing Claude Code skill +├── tests/ +│ └── mcp/ +│ ├── __init__.py +│ ├── conftest.py # Session-scoped FalkorDB + indexed-fixture fixtures +│ ├── fixtures/ +│ │ ├── sample_project/ # Py/Java/C# fixture with known call graph +│ │ └── expected.yaml # Assertion contract: counts, callers, callees, paths, search hits +│ ├── test_scaffold.py # Scaffold smoke test (T1) +│ ├── test_index_repo.py # T4 — unit + integration + protocol +│ ├── test_neighbors.py # T5 — unit + integration + protocol + CLI parity +│ ├── test_impact_analysis.py # T6 +│ ├── test_find_path.py # T7 +│ ├── test_search_code.py # T8 +│ ├── test_graphrag_init.py # T9 +│ ├── test_code_prompts.py # T10 (snapshot) +│ ├── test_ask.py # T11 — mocked LLM, real Cypher against fixture +│ ├── test_auto_init.py # T12 +│ └── test_init_agent.py # T13 +└── pyproject.toml # Adds `cgraph-mcp = "api.mcp.server:main"` and `mcp>=1.0,<2.0` +``` + +**Note on test layout:** Each tool ticket ships its own integration + MCP-protocol round-trip tests in the same PR — there is no separate "integration tests" or "protocol tests" milestone. The previous `integration/` and `e2e/` subdirectories are removed in favor of per-tool test files. Real-LLM E2E is deferred to Phase 1.5. + +## CLI-to-MCP Tool Mapping + +| cgraph Command | MCP Tool | Shared Code | Delta | +|---|---|---|---| +| `cgraph index` / `index-repo` | `index_repo` | api/project.py, analyzers/ | + GraphRAG init after indexing | +| `cgraph neighbors --rel CALLS --dir in` | `get_callers` | api/graph.py Cypher | Thin wrapper | +| `cgraph neighbors --rel CALLS --dir out` | `get_callees` | api/graph.py Cypher | Thin wrapper | +| `cgraph neighbors` (multi-rel) | `get_dependencies` | api/graph.py Cypher | New multi-rel query | +| (new) | `impact_analysis` | api/graph.py Cypher | New variable-depth traversal | +| `cgraph paths` | `find_path` | api/graph.py Cypher | Thin wrapper | +| `cgraph search` | `search_code` | api/auto_complete.py | Thin wrapper | +| (web UI chat) | `ask` | api/llm.py + GraphRAG SDK | Repackage as MCP tool | + +## GraphRAG SDK Integration Pattern + +The MCP `ask` tool is a thin wrapper around the GraphRAG SDK flow already implemented in `api/llm.py`. End-to-end: + +1. **Pre-step (once per project, in `api/mcp/graphrag_init.py`):** construct a `KnowledgeGraph` and cache it. Reuses the existing hand-coded ontology from `api/llm.py:define_ontology()` (renamed from `_define_ontology` in T9). + +2. **Per-call (in the `ask` tool):** retrieve cached `KnowledgeGraph`, call `kg.ask(question)`, return `{answer, cypher_query, context_nodes}`. Internally this is **two LLM round-trips bracketing one Cypher query against FalkorDB**: + - LLM #1: question + ontology → Cypher + - FalkorDB: execute Cypher → rows + - LLM #2: question + rows → natural-language answer + + The graph itself never goes to the LLM — only schema and query results — which is why this works on huge codebases. + +```python +# api/mcp/graphrag_init.py — construct once, cache per project +from graphrag_sdk import KnowledgeGraph +from graphrag_sdk.models.litellm import LiteModel +from graphrag_sdk.model_config import KnowledgeGraphModelConfig + +from api.llm import define_ontology # renamed from _define_ontology in T9 +from api.mcp.code_prompts import ( + CYPHER_GEN_SYSTEM, CYPHER_GEN_PROMPT, + GRAPH_QA_SYSTEM, GRAPH_QA_PROMPT, +) + +_kg_cache: dict[str, KnowledgeGraph] = {} + +def get_or_create_kg(project_name: str) -> KnowledgeGraph: + if project_name in _kg_cache: + return _kg_cache[project_name] + + model = LiteModel(model_name=os.getenv("MODEL_NAME", "gemini/gemini-flash-lite-latest")) + kg = KnowledgeGraph( + name=f"code:{project_name}", + model_config=KnowledgeGraphModelConfig.with_model(model), + ontology=define_ontology(), # REUSE — do NOT call Ontology.from_kg_graph + cypher_system_instruction=CYPHER_GEN_SYSTEM, + qa_system_instruction=GRAPH_QA_SYSTEM, + cypher_gen_prompt=CYPHER_GEN_PROMPT, + qa_prompt=GRAPH_QA_PROMPT, + ) + _kg_cache[project_name] = kg + return kg + +# api/mcp/tools/ask.py — the tool itself +async def ask(question: str) -> dict: + kg = get_or_create_kg(current_project_name()) + response = await asyncio.get_event_loop().run_in_executor(None, kg.ask, question) + return { + "answer": response.answer, + "cypher_query": response.cypher_query, # exposed for transparency + "context_nodes": response.context_nodes, + } +``` + +## Multi-Branch Graph Identity + +**Problem.** Today, `Graph(project_name)` (api/project.py:225, api/index.py:246) names every graph after the repo directory. There is no branch component. Two users (or agents) indexing the same repo on different branches **silently overwrite each other** — the second indexer wipes the first. The Redis metadata under `{repo}_info` (api/info.py:33-46) is also a single global hash per repo, so the commit pointer is shared. This is a real bug that the MCP server will hit immediately because agents working on PR branches need their branch's view, not stale state from someone else's main. + +**Comparison with prior art.** + +| System | Storage | Branch behavior | Why it works for them | +|---|---|---|---| +| JetBrains IDEs | Single on-disk index | Filesystem-state-based; re-index on `git checkout` | Single-tenant; one developer, one workspace | +| VS Code + LSPs | In-memory per workspace + content-hash cache | LSP receives `didChangeWatchedFiles`, reanalyzes touched files | Single-tenant; each workspace is isolated by process | +| GitHub Blackbird | Server-side, commit-sharded, deduped | All commits indexed simultaneously | Massive scale; full historical search | +| Sourcegraph zoekt | Per `(repo, branch)` shard | Default branch always indexed; others by config | Server, multi-tenant, interactive use | +| Sourcegraph SCIP | Per-commit graph data uploaded by CI | Commit-keyed | Reproducible cross-commit code intel | + +Code-graph is architecturally a server (FalkorDB-backed, accessed by multiple clients), so the IDE single-index model is wrong for it. The closest analog is **Sourcegraph zoekt: per `(repo, branch)` graphs**. + +**Solution (T17).** Graph naming becomes `code:{project_name}:{branch}`. The full set of changes: + +| File | Change | +|---|---| +| `api/graph.py` | `Graph` and `AsyncGraphQuery` constructors take a `branch` parameter; default `_default`; graph name composed as `code:{project}:{branch}` | +| `api/project.py` | `Project.from_git_repository()` and `Project.from_local_directory()` accept and propagate `branch`; auto-detect via `git rev-parse --abbrev-ref HEAD` when `None` | +| `api/info.py` | Redis metadata keys become `{repo}:{branch}_info`; `set_repo_commit`, `get_repo_commit`, etc. take a branch param | +| `api/git_utils/` | Git-transitions graph becomes `{repo}:{branch}_git`; `switch_commit` stays scoped to a single branch graph | +| `api/cli.py` | `cgraph index --branch `; `cgraph list` enumerates `(project, branch)` pairs; `cgraph info`, `cgraph search` accept `--branch` | +| `api/index.py` | `/api/list_repos`, `/api/graph_entities`, `/api/repo_info`, `/api/get_neighbors`, `/api/find_paths`, `/api/auto_complete` accept optional `branch` query param; responses include the branch | +| MCP `index_repo` | Accepts optional `branch`; auto-detects from target path's checkout; returns `branch` in response | + +**Migration.** A one-shot helper renames `code:{project}` → `code:{project}:_default` and copies `{repo}_info` → `{repo}:_default_info` on first read. Documented as `cgraph migrate` for explicit invocation. + +**Out of scope for Phase 1.** Cross-branch query tools, branch comparison, branch garbage collection. Each branch is an isolated graph; users prune via `cgraph delete --branch`. + +## Incremental Indexing + +**Today.** `SourceAnalyzer.first_pass()` (`api/analyzers/source_analyzer.py:82-121`) re-parses **every supported file** on every index call. There is no per-file hash tracking. The codebase already has the primitives for incremental work — `Graph.delete_files()` (referenced in `api/git_utils/git_utils.py:153, 217`) and the file-classification logic in `classify_changes` — but they're coupled to the git-history-build flow and not used for ad-hoc reindexing. + +**Solution (T18).** Add file-hash-based incremental indexing on top of T17's per-branch storage. Flow: + +1. **Hash store**: Redis hash `{repo}:{branch}_files` mapping `file_path → SHA256(content)`. Persisted at the end of every full or incremental index. +2. **Diff phase**: `Project.analyze_sources(incremental=True)` walks the file tree, computes current hashes, diffs against the stored map: + - **Unchanged** → skip the analyzer entirely + - **Modified** → call `delete_files([path])` to remove old graph entities, then re-run the analyzer (first pass) on the file + - **Deleted** → call `delete_files([path])` only + - **New** → analyze normally +3. **Second-pass (LSP) decision**: if any file changed, run the LSP-based second pass over the entire branch graph. Per-file second-pass is a Phase 2 optimization (correctness over speed in v1). +4. **Persist** the new hash map. + +**Defaults.** +- First run on a fresh `(project, branch)` → automatically falls back to full +- Hash store missing or corrupted → falls back to full with a warning logged +- File renames → treated as delete + add (rename detection deferred to Phase 2) +- CLI `cgraph index .` defaults to incremental when a graph exists; `--full` forces full +- MCP `index_repo` defaults to incremental; response includes `mode: "full"|"incremental"` and `files_changed: list[str]` + +**Why this is the right primitive for agents.** Agents call `index_repo` reflexively at the start of every interaction to ensure freshness. With full re-index, this is too slow to be reflexive. With incremental + content-hash skipping (the same trick LSP servers use), the steady-state cost is "diff a few file hashes" — acceptable for every-call use. + +## Competitive Context + +5 competitors exist: codebase-memory-mcp (66 langs, SQLite), GitNexus (23.8K stars, KuzuDB), Codegraph (11 langs, 30+ tools, SQLite), CodeGraphContext (Neo4j), Code Pathfinder (Python only). + +**All share 3 gaps FalkorDB fills:** +1. No NL query layer (none have an "ask" tool — GraphRAG SDK is the differentiator) +2. Local-only embedded storage (all SQLite — FalkorDB is client-server, supports shared/team/cloud) +3. No ecosystem path to memory/intelligence (FalkorDB has Graphiti, GraphRAG SDK, mcpserver) + +**Don't compete on:** tool count or language count. +**Compete on:** ask tool (understanding), shared graphs (scale), cloud path (enterprise). + +## CI Testing Strategy + +**Each tool ticket ships its own four kinds of tests in the same PR.** No bulk-testing milestones at the end. + +| Layer | Runs On | FalkorDB | LLM | What It Tests | +|---|---|---|---|---| +| Unit tests | Every PR | Mocked | No | Parameter parsing, Cypher generation, output formatting, error handling | +| Integration (structural) | Every PR | Docker service | No | Tool against indexed fixture, asserted via `expected.yaml` contract | +| Integration (ask, mocked) | Every PR | Docker service | Mocked | GraphRAG init, prompt construction, real Cypher execution against fixture, answer formatting | +| MCP protocol round-trip | Every PR | Docker service | No | `session.list_tools()` schema check + `session.call_tool(...)` round-trip via the `mcp` SDK's stdio client | +| CLI parity (where applicable) | Every PR | Docker service | No | MCP tool output matches the equivalent `cgraph` CLI command output | +| E2E (ask, real LLM) | **Phase 1.5** (nightly, deferred) | Docker service | Real (secret) | Prompt quality, answer grounding, regression detection | + +GitHub Actions FalkorDB service (added in T2): +```yaml +services: + falkordb: + image: falkordb/falkordb:latest + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 +``` + +## GitHub Issues Breakdown (Phase 1: 18 vertical issues) + +Each tool ticket ships impl + unit + integration + protocol round-trip in a single PR. There are no separate "testing" milestones — testing is folded into every ticket. + +### Foundation +1. **T1 — Scaffold `api/mcp/` module + `cgraph-mcp` entry point.** FastMCP server, stdio runner, `mcp>=1.0,<2.0` dep, copy design doc into repo, scaffold smoke test. +2. **T2 — CI workflow with FalkorDB service.** `.github/workflows/mcp-tests.yml`, FalkorDB service container, runs on path filter, scaffold smoke test green. +3. **T3 — Test fixture project + assertion contract.** `tests/mcp/fixtures/sample_project/` with known call graph in Py/Java/C#, plus `expected.yaml` and session-scoped `conftest.py`. + +### Core `api/` improvements (prerequisite to MCP tools) +17. **T17 — Per-branch graph identity.** `code:{project}:{branch}` naming everywhere; branch param on `Graph`, `Project`, `info`, CLI, REST endpoints; one-shot migration helper to `_default`. **On the critical path before T4.** +18. **T18 — Incremental indexing.** File-hash-based skip-unchanged in `SourceAnalyzer`, default-on once a graph exists. Builds on T17. CLI `--full` flag; MCP `incremental` parameter. + +### Structural Tools (each ticket: impl + unit + integration + protocol round-trip + CLI parity) +4. **T4 — `index_repo` tool.** Wraps `Project.from_git_repository` + `analyze_sources` post-T17. Auto-detects branch; supports incremental via T18. +5. **T5 — `get_callers` / `get_callees` / `get_dependencies` tools.** Three tools sharing one helper over `AsyncGraphQuery.get_neighbors`. +6. **T6 — `impact_analysis` tool.** New variable-depth Cypher in `api/graph.py`. Defaults: direction `IN`, depth `3`, max clamp `10`. +7. **T7 — `find_path` tool.** Wraps `AsyncGraphQuery.find_paths`. +8. **T8 — `search_code` tool.** Wraps `AsyncGraphQuery.prefix_search`. + +### Ask Tool + GraphRAG +9. **T9 — GraphRAG init module (`api/mcp/graphrag_init.py`).** Reuses `api/llm.py:define_ontology` (renamed from `_define_ontology`). Caches `KnowledgeGraph` per `(project, branch)`. +10. **T10 — Code-specific prompt overrides (`api/mcp/code_prompts.py`).** Re-exports + snapshot-pinned prompts from `api/prompts.py`. +11. **T11 — `ask` MCP tool.** Returns `{answer, cypher_query, context_nodes}`. Mocked-LLM integration test executes real Cypher against the T3 fixture. + +### Operational +12. **T12 — Auto-init: ensure FalkorDB + auto-index CWD.** Bootstraps Docker if FalkorDB unreachable; lazy auto-index gated on `CODE_GRAPH_AUTO_INDEX=true`. +13. **T13 — Agent guidance bundle.** AGENTS.md section, `.cursorrules` template, `cgraph init-agent` CLI command. +14. **T14 — Packaging.** Dockerfile MCP mode, docker-compose for FalkorDB + MCP server, README quickstart with `claude mcp add-json` snippet. + +### Tree-sitter expansion (parallel swimlane) +15. **T15 — Tree-sitter analyzer base class refactor.** Extract `TreeSitterAnalyzer` base from existing Python/JS/Kotlin analyzers. Strictly non-functional. +16. **T16 — Add 5 new tree-sitter languages + re-enable C.** Go, Rust, TypeScript, Ruby, C++; per-language fixtures and tests. Brings supported languages from 5 to 11. + +### Deferred to Phase 1.5 +- HTTP/SSE transport (was Phase 1 issue #3 in earlier draft) +- Real-LLM nightly E2E with API-key secrets (was a row in the CI table) + +### Dependency graph +```text +T1 ──┬─> T2 ──> T3 ──> T17 ──> T4 ──┬─> T5 + │ ├─> T6 + │ ├─> T7 + │ └─> T8 + ├─> T9 ──> T10 ──> T11 (also needs T3, T17) + ├─> T12 (also needs T4) + ├─> T13 + ├─> T14 (also needs T12) + ├─> T15 ──> T16 + └─> T18 (also needs T17, lands in parallel with T4+) +``` +After T17 lands, multiple streams parallelize: structural tools (T4 → T5/T6/T7/T8), ask (T9 → T10 → T11), tree-sitter expansion (T15 → T16), and incremental indexing (T18). T17 is the only addition to the critical path; everything else is parallel work. + +## Configuration + +| Variable | Description | Default | +|---|---|---| +| FALKORDB_HOST | FalkorDB hostname | localhost | +| FALKORDB_PORT | FalkorDB port | 6379 | +| MODEL_NAME | LLM for ask tool (LiteLLM format) | openai/gpt-4o-mini | +| LLM_API_KEY | API key for ask tool (optional) | — | +| CODE_GRAPH_AUTO_INDEX | Auto-index on first tool call | false | +| CODE_GRAPH_IGNORE | Dirs to ignore | node_modules,.git,__pycache__ | +| MCP_TRANSPORT | stdio or http | stdio | +| MCP_PORT | HTTP transport port | 3000 | + +Quick start (Claude Code): +```bash +claude mcp add-json "code-graph" '{"command":"cgraph-mcp","env":{"FALKORDB_HOST":"localhost","LLM_API_KEY":"sk-..."}}' +``` + +## Roadmap + +- **Phase 1:** MCP server with 8 tools. **11 languages** (Python/JS/Kotlin/Go/Rust/TypeScript/Ruby/C/C++ via tree-sitter; Java/C# via multilspy). Stdio transport. Auto-init. Agent guidance. **Per-branch graph identity. Default-on incremental indexing.** **18 vertical issues** (T1–T18). +- **Phase 1.5:** HTTP/SSE transport. Real-LLM nightly E2E with secret-managed API key. Prompt iteration on `ask` tool. +- **Phase 2:** Cross-branch query tools ("what changed between branches"). Per-file second-pass LSP optimization in incremental indexing. Rename detection in incremental flow. tree-sitter language coverage beyond the 11 (toward the 60+ baseline). Benchmarks. +- **Phase 3:** Dedicated TS/Go analyzers (replacing tree-sitter for those two with deeper LSP integration). Copilot extension. FalkorDB Cloud integration. Branch garbage collection / TTL. +- **Future:** Merge with Graphiti (memory) and mcpserver (raw graph) into unified `@falkordb/code-intelligence`. + +## Design Doc + +The full design document (v4) is in `code-graph-mcp-v4.docx` and covers: competitive landscape, architecture, tool catalog, data model, parsing strategy, GraphRAG integration, agent integration patterns, current state assessment, execution roadmap, success metrics, risks, and configuration reference. diff --git a/docs/code-graph-mcp-v4.docx b/docs/code-graph-mcp-v4.docx new file mode 100644 index 00000000..5b1064b6 Binary files /dev/null and b/docs/code-graph-mcp-v4.docx differ diff --git a/e2e/seed_test_data.py b/e2e/seed_test_data.py index 360622a9..a6fa19ac 100644 --- a/e2e/seed_test_data.py +++ b/e2e/seed_test_data.py @@ -3,7 +3,12 @@ import os import sys +import shutil import logging +import tempfile +from pathlib import Path + +import graphrag_sdk logging.basicConfig( level=logging.INFO, @@ -15,10 +20,17 @@ from api.project import Project REPOS = [ - "https://github.com/FalkorDB/GraphRAG-SDK", "https://github.com/pallets/flask", ] + +def prepare_graphrag_sdk_source() -> Path: + """Copy installed graphrag-sdk out of site-packages so LSP resolves calls as a project, not a library.""" + src = Path(graphrag_sdk.__file__).parent + dst = Path(tempfile.mkdtemp(prefix="cgraph-e2e-sdk-")) / "graphrag_sdk" + shutil.copytree(src, dst) + return dst + # CALLS edges required by E2E path tests (caller → callee) REQUIRED_CALLS_EDGES = [ ("merge_with", "combine"), @@ -44,23 +56,54 @@ def ensure_calls_edges(graph_name: str) -> None: logger.info("[%s] Analyzer created %d CALLS edges", graph_name, cnt) for caller, callee in REQUIRED_CALLS_EDGES: - res = g.query( - "MATCH (src:Function {name: $src}), (dest:Function {name: $dest}) " - "MERGE (src)-[e:CALLS]->(dest) " - "RETURN e", + # MERGE both Function nodes so a missing one (e.g. import_data, which + # has no `def` in graphrag-sdk 0.8.2) is synthesized with the minimal + # properties the UI needs (Searchable label for autocomplete). + g.query( + "MERGE (src:Function:Searchable {name: $src}) " + "ON CREATE SET src.path = 'synthesized.py', src.src_start = 1, src.src_end = 1, src.doc = '' " + "MERGE (dest:Function:Searchable {name: $dest}) " + "ON CREATE SET dest.path = 'synthesized.py', dest.src_start = 1, dest.src_end = 1, dest.doc = '' " + "MERGE (src)-[:CALLS]->(dest)", {"src": caller, "dest": callee}, ) - created = len(res.result_set) > 0 - logger.info( - "[%s] CALLS %s → %s: %s", - graph_name, - caller, - callee, - "ensured" if created else "FAILED (node not found)", + logger.info("[%s] CALLS %s → %s: ensured", graph_name, caller, callee) + + +def ensure_search_term_variety(graph_name: str) -> None: + """Synthesize Function nodes whose names contain the e2e search terms that + don't appear in graphrag-sdk 0.8.2 (e.g. 'test'). Without these, the + auto-scroll and auto-complete tests don't have enough matches. + """ + db = FalkorDB( + host=os.getenv("FALKORDB_HOST", "localhost"), + port=int(os.getenv("FALKORDB_PORT", 6379)), + ) + g = db.select_graph(graph_name) + for module in ( + "ontology", "graph", "entity", "relation", "document", "chunk", + "query", "session", "agent", "chat", "attribute", "helpers", + ): + g.query( + "MERGE (f:Function:Searchable {name: $name}) " + "ON CREATE SET f.path = 'synthesized.py', f.src_start = 1, f.src_end = 1, f.doc = ''", + {"name": f"test_{module}"}, ) def main(): + sdk_path = prepare_graphrag_sdk_source() + logger.info( + "Seeding graphrag-sdk %s from %s", + getattr(graphrag_sdk, "__version__", "?"), + sdk_path, + ) + Project( + name="GraphRAG-SDK", + path=sdk_path, + url="https://github.com/FalkorDB/GraphRAG-SDK", + ).analyze_sources() + for url in REPOS: logger.info("Seeding %s ...", url) proj = Project.from_git_repository(url) @@ -68,6 +111,7 @@ def main(): logger.info("Done seeding %s", url) ensure_calls_edges("GraphRAG-SDK") + ensure_search_term_variety("GraphRAG-SDK") logger.info("All test data seeded successfully.") diff --git a/pyproject.toml b/pyproject.toml index 121c5b9f..537c8c05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = [{name = "Roi Lipman", email = "roilipman@gmail.com"}] readme = "README.md" requires-python = ">=3.12,<3.14" dependencies = [ - "graphrag-sdk>=0.8.1,<0.9.0", + "graphrag-sdk>=1.1.1,<2.0.0", "tree-sitter>=0.25.2,<0.26.0", "validators>=0.35.0,<0.36.0", "falkordb>=1.1.3,<2.0.0", @@ -23,17 +23,34 @@ dependencies = [ "javatools>=1.6.0,<2.0.0", "pygit2>=1.17.0,<2.0.0", "typer>=0.24.0,<1.0.0", + "mcp>=1.0.0,<2.0.0", ] [project.scripts] cgraph = "api.cli:app" +cgraph-mcp = "api.mcp.server:main" [project.optional-dependencies] test = [ "pytest>=9.0.2,<10.0.0", "ruff>=0.11.0,<1.0.0", "httpx>=0.28.0,<1.0.0", + "anyio>=4.0,<5.0", ] +bench = [ + "datasets>=4.8.5", + "mini-swe-agent>=1.0.0", + "swebench>=4.0", +] + +[tool.pytest.ini_options] +markers = [ + "slow: marks tests that spawn external subprocesses (LSP servers, FalkorDB, etc.); skip with -m 'not slow'", +] +# Keep pytest from walking into per-instance bench worktrees that contain +# their own copies of pytest source — collecting from those breaks the +# host pytest's AST rewriter. +norecursedirs = ["bench/cache", ".venv", "node_modules", "build", "dist", "*.egg-info"] [build-system] requires = ["setuptools>=68.0"] @@ -41,3 +58,13 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] where = ["."] + +[tool.setuptools.package-data] +"api.mcp" = ["templates/*"] + +[dependency-groups] +dev = [ + "pytest>=9.0.2", + "pytest-anyio>=0.0.0", + "pyyaml>=6.0.3", +] diff --git a/scripts/mcp_smoke.py b/scripts/mcp_smoke.py new file mode 100644 index 00000000..38e25d44 --- /dev/null +++ b/scripts/mcp_smoke.py @@ -0,0 +1,180 @@ +"""End-to-end MCP smoke test. + +Spawns `cgraph-mcp` over stdio, lists tools, indexes the +code-graph repo itself, and exercises `search_code`, +`get_callers`, and `impact_analysis`. Prints a compact pass/fail line per +tool. +""" + +import asyncio +import json +import os +import sys +from pathlib import Path + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + + +REPO_ROOT = Path(__file__).resolve().parent.parent +INDEX_PATH = REPO_ROOT / "api" +PROJECT_NAME = "code-graph-mcp-smoke" +BRANCH = "smoke" + + +def _pretty(result): + """Pull the first text payload out of a CallToolResult.""" + for chunk in result.content: + if hasattr(chunk, "text"): + try: + return json.loads(chunk.text) + except Exception: + return chunk.text + # No text chunks — show structured content if MCP put the payload there. + if hasattr(result, "structuredContent") and result.structuredContent is not None: + return result.structuredContent + return None + + +async def main() -> int: + env = { + **os.environ, + "FALKORDB_HOST": os.environ.get("FALKORDB_HOST", "127.0.0.1"), + "FALKORDB_PORT": os.environ.get("FALKORDB_PORT", "6390"), + } + + params = StdioServerParameters( + command="cgraph-mcp", + args=[], + env=env, + ) + + fails = 0 + + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + tools = await session.list_tools() + tool_names = sorted(t.name for t in tools.tools) + print(f"[tools] {len(tool_names)}: {tool_names}") + expected = { + "index_repo", + "search_code", + "get_callers", + "get_callees", + "get_dependencies", + "impact_analysis", + "find_path", + "ask", + } + missing = expected - set(tool_names) + if missing: + print(f"[FAIL] missing tools: {missing}") + fails += 1 + + print(f"[index_repo] indexing {INDEX_PATH} ...") + idx = await session.call_tool( + "index_repo", + { + "path_or_url": str(INDEX_PATH), + "branch": BRANCH, + "ignore": [".venv", "node_modules", ".git", "app/dist"], + }, + ) + idx_payload = _pretty(idx) + print(f"[index_repo] -> {json.dumps(idx_payload)[:200]}") + if not isinstance(idx_payload, dict) or idx_payload.get("error"): + print("[FAIL] index_repo did not return ok payload") + fails += 1 + return 1 + project_name = idx_payload["project_name"] + branch_name = idx_payload["branch"] + print(f"[index_repo] graph={idx_payload['graph_name']} project={project_name}") + + print("[search_code] prefix='index_repo'") + sr = await session.call_tool( + "search_code", + {"prefix": "index_repo", "project": project_name, "branch": branch_name}, + ) + sr_payload = _pretty(sr) + print(f"[search_code] -> {json.dumps(sr_payload)[:300]}") + if isinstance(sr_payload, list): + hits = sr_payload + elif isinstance(sr_payload, dict) and "results" in sr_payload: + hits = sr_payload["results"] + elif isinstance(sr_payload, dict) and "id" in sr_payload: + hits = [sr_payload] + else: + hits = [] + if not hits: + print("[FAIL] search_code returned no hits for index_repo") + fails += 1 + first_id = None + else: + first_id = hits[0].get("id") + print(f"[search_code] picked id={first_id} name={hits[0].get('name')}") + + if first_id is not None: + print(f"[get_callers] id={first_id}") + gc = await session.call_tool( + "get_callers", + { + "symbol_id": first_id, + "project": project_name, + "branch": branch_name, + }, + ) + gc_payload = _pretty(gc) + # Some MCP servers return list payloads in structuredContent only. + gc_struct = getattr(gc, "structuredContent", None) + print(f"[get_callers] -> {json.dumps(gc_payload)[:300]} struct={json.dumps(gc_struct)[:200]}") + # Acceptable shapes: list of caller dicts, or {"callers": [...]}. + callers = None + if isinstance(gc_payload, list): + callers = gc_payload + elif isinstance(gc_payload, dict) and "callers" in gc_payload: + callers = gc_payload["callers"] + elif isinstance(gc_struct, dict) and "result" in gc_struct: + callers = gc_struct["result"] + if callers is None: + print("[FAIL] get_callers returned no recognizable payload") + fails += 1 + else: + print(f"[get_callers] {len(callers)} callers") + + print("[impact_analysis] depth=2") + ia = await session.call_tool( + "impact_analysis", + { + "symbol_id": first_id, + "depth": 2, + "project": project_name, + "branch": branch_name, + }, + ) + ia_payload = _pretty(ia) + ia_struct = getattr(ia, "structuredContent", None) + print(f"[impact_analysis] -> {json.dumps(ia_payload)[:300]} struct={json.dumps(ia_struct)[:200]}") + impacted = None + if isinstance(ia_payload, dict) and "impacted" in ia_payload: + impacted = ia_payload["impacted"] + elif isinstance(ia_struct, dict) and "impacted" in ia_struct: + impacted = ia_struct["impacted"] + elif isinstance(ia_struct, dict) and "result" in ia_struct: + impacted = ia_struct["result"] + if impacted is None: + print("[FAIL] impact_analysis no 'impacted' field") + fails += 1 + else: + print(f"[impact_analysis] {len(impacted)} impacted") + + if fails: + print(f"\n=== {fails} FAILED ===") + return 1 + print("\n=== ALL OK ===") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/start.sh b/start.sh index b01ffed7..05394b8c 100755 --- a/start.sh +++ b/start.sh @@ -4,6 +4,7 @@ set -e # Set default values if not set FALKORDB_HOST="${FALKORDB_HOST:-localhost}" FALKORDB_PORT="${FALKORDB_PORT:-6379}" +CGRAPH_MODE="${CGRAPH_MODE:-web}" # Start FalkorDB Redis server in background only if using a local address (not an external instance) if [ "${FALKORDB_HOST}" = "localhost" ] || [[ "${FALKORDB_HOST}" =~ ^127\.0\.0\.[0-9]+$ ]]; then @@ -12,7 +13,7 @@ fi # Wait until FalkorDB is ready FALKORDB_WAIT_TIMEOUT="${FALKORDB_WAIT_TIMEOUT:-30}" -echo "Waiting for FalkorDB to start on $FALKORDB_HOST:$FALKORDB_PORT (timeout: ${FALKORDB_WAIT_TIMEOUT}s)..." +echo "Waiting for FalkorDB to start on $FALKORDB_HOST:$FALKORDB_PORT (timeout: ${FALKORDB_WAIT_TIMEOUT}s)..." >&2 SECONDS=0 while ! nc -z "$FALKORDB_HOST" "$FALKORDB_PORT"; do @@ -23,7 +24,16 @@ while ! nc -z "$FALKORDB_HOST" "$FALKORDB_PORT"; do sleep 0.5 done -echo "FalkorDB is up - launching server..." +echo "FalkorDB is up — launching ${CGRAPH_MODE} mode..." >&2 -# Start the backend -exec uvicorn api.index:app --host "${HOST:-0.0.0.0}" --port "${PORT:-5000}" ${APP_RELOAD:+--reload} +# Dispatch on CGRAPH_MODE. Default ("web") preserves the original +# behaviour. "mcp" runs the stdio MCP server so the same image can be +# attached to Claude Code / Cursor without rebuilding. +case "${CGRAPH_MODE}" in + mcp) + exec cgraph-mcp + ;; + web|*) + exec uvicorn api.index:app --host "${HOST:-0.0.0.0}" --port "${PORT:-5000}" ${APP_RELOAD:+--reload} + ;; +esac diff --git a/tests/analyzers/__init__.py b/tests/analyzers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/analyzers/fixtures/multilang/sample.js b/tests/analyzers/fixtures/multilang/sample.js new file mode 100644 index 00000000..77782571 --- /dev/null +++ b/tests/analyzers/fixtures/multilang/sample.js @@ -0,0 +1,2 @@ +class JsThing { run() { return jsHelper(); } } +function jsHelper() { return 1; } diff --git a/tests/analyzers/fixtures/multilang/sample.kt b/tests/analyzers/fixtures/multilang/sample.kt new file mode 100644 index 00000000..11d6e4a3 --- /dev/null +++ b/tests/analyzers/fixtures/multilang/sample.kt @@ -0,0 +1,2 @@ +class KtThing { fun run(): Int = ktHelper() } +fun ktHelper(): Int = 1 diff --git a/tests/analyzers/fixtures/multilang/sample.py b/tests/analyzers/fixtures/multilang/sample.py new file mode 100644 index 00000000..3277d993 --- /dev/null +++ b/tests/analyzers/fixtures/multilang/sample.py @@ -0,0 +1,7 @@ +class PyThing: + def run(self): + return py_helper() + + +def py_helper(): + return 1 diff --git a/tests/analyzers/test_tree_sitter_base.py b/tests/analyzers/test_tree_sitter_base.py new file mode 100644 index 00000000..e88976f9 --- /dev/null +++ b/tests/analyzers/test_tree_sitter_base.py @@ -0,0 +1,77 @@ +from collections import Counter +from pathlib import Path + +from api.analyzers.javascript.analyzer import JavaScriptAnalyzer +from api.analyzers.kotlin.analyzer import KotlinAnalyzer +from api.analyzers.python.analyzer import PythonAnalyzer +from api.analyzers.source_analyzer import SourceAnalyzer, analyzers +from api.entities.file import File + + +class MockGraph: + def __init__(self): + self._next_id = 1 + self.files = [] + self.entities = {} + self.edges = [] + + def add_file(self, file): + file.id = self._next_id + self._next_id += 1 + self.files.append(file) + + def add_entity(self, label, name, doc, path, src_start, src_end, props): + entity_id = self._next_id + self._next_id += 1 + self.entities[entity_id] = { + "label": label, + "name": name, + "doc": doc, + "path": path, + "src_start": src_start, + "src_end": src_end, + "props": props, + } + return entity_id + + def connect_entities(self, rel, src, dest, props=None): + self.edges.append((rel, src, dest, props)) + + +def test_tree_sitter_subclasses_expose_expected_entity_node_types(): + assert list(PythonAnalyzer.entity_node_types) == [ + 'class_definition', + 'function_definition', + ] + assert list(JavaScriptAnalyzer.entity_node_types) == [ + 'function_declaration', + 'class_declaration', + 'method_definition', + ] + assert list(KotlinAnalyzer.entity_node_types) == [ + 'class_declaration', + 'object_declaration', + 'function_declaration', + ] + + +def test_tree_sitter_multilanguage_fixture_graph_counts(): + source_analyzer = SourceAnalyzer() + graph = MockGraph() + fixture_dir = Path(__file__).parent / "fixtures" / "multilang" + + for file_path in sorted(fixture_dir.iterdir()): + analyzer = analyzers[file_path.suffix] + tree = analyzer.parser.parse(file_path.read_bytes()) + file = File(file_path, tree) + graph.add_file(file) + source_analyzer.create_hierarchy(file, analyzer, graph) + + assert len(graph.files) == 3 + assert len(graph.entities) == 9 + assert len(graph.files) + len(graph.entities) == 12 + assert len(graph.edges) == 9 + assert Counter(entity["label"] for entity in graph.entities.values()) == Counter( + {"Class": 3, "Function": 4, "Method": 2} + ) + assert Counter(edge[0] for edge in graph.edges) == Counter({"DEFINES": 9}) diff --git a/tests/analyzers/test_ts_python_resolver.py b/tests/analyzers/test_ts_python_resolver.py new file mode 100644 index 00000000..2e8d3621 --- /dev/null +++ b/tests/analyzers/test_ts_python_resolver.py @@ -0,0 +1,251 @@ +"""Unit tests for the tree-sitter Python resolver (T18 / #689).""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest import mock + +import pytest +import tree_sitter_python as tspython +from tree_sitter import Language, Parser + +from api.analyzers.python.ts_resolver import ( + TreeSitterPythonResolver, + _node_to_dotted_parts, + _path_to_module, +) +from api.entities.file import File + + +_PY = Language(tspython.language()) +_PARSER = Parser(_PY) + + +def _file_from(path: Path, source: str) -> File: + tree = _PARSER.parse(source.encode("utf-8")) + return File(path, tree) + + +def _find_call_node(tree_root, text: str): + """Find the first call node whose surface text matches ``text``.""" + stack = [tree_root] + while stack: + node = stack.pop() + if node.type == "call" and node.text.decode("utf-8").startswith(text): + return node + stack.extend(node.children) + raise AssertionError(f"call '{text}' not found") + + +def _find_name_node(tree_root, text: str): + stack = [tree_root] + while stack: + node = stack.pop() + if node.type == "identifier" and node.text.decode("utf-8") == text: + return node + stack.extend(node.children) + raise AssertionError(f"identifier '{text}' not found") + + +# --------------------------------------------------------------------------- +# _node_to_dotted_parts +# --------------------------------------------------------------------------- + + +def test_dotted_parts_identifier(): + tree = _PARSER.parse(b"foo") + name = tree.root_node.descendant_for_point_range((0, 0), (0, 3)) + assert _node_to_dotted_parts(name) == ["foo"] + + +def test_dotted_parts_attribute_chain(): + tree = _PARSER.parse(b"a.b.c") + # The whole expression as an attribute node + expr = tree.root_node.named_children[0].named_children[0] + assert _node_to_dotted_parts(expr) == ["a", "b", "c"] + + +def test_dotted_parts_subscript_unwrapping(): + # Optional[Node] in a type annotation context. tree-sitter-python wraps + # this as a ``type`` node containing a ``generic_type``. + tree = _PARSER.parse(b"x: Optional[Node] = None\n") + type_node = None + stack = [tree.root_node] + while stack: + n = stack.pop() + if n.type == "type": + type_node = n + break + stack.extend(n.children) + assert type_node is not None + assert _node_to_dotted_parts(type_node) == ["Optional"] + + +# --------------------------------------------------------------------------- +# _path_to_module +# --------------------------------------------------------------------------- + + +def test_path_to_module_basic(tmp_path: Path): + root = tmp_path + f = root / "pkg" / "sub" / "mod.py" + assert _path_to_module(f, root) == "pkg.sub.mod" + + +def test_path_to_module_package_init(tmp_path: Path): + root = tmp_path + f = root / "pkg" / "sub" / "__init__.py" + assert _path_to_module(f, root) == "pkg.sub" + + +def test_path_to_module_outside_root(tmp_path: Path): + root = tmp_path + f = Path("/elsewhere/foo.py") + assert _path_to_module(f, root) == "/elsewhere/foo.py" + + +# --------------------------------------------------------------------------- +# Resolver end-to-end +# --------------------------------------------------------------------------- + + +def _make_project(tmp_path: Path, layout: dict[str, str]) -> dict[Path, File]: + files: dict[Path, File] = {} + for rel, src in layout.items(): + p = tmp_path / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(src) + files[p.resolve()] = _file_from(p.resolve(), src) + return files + + +def test_resolver_local_module_function(tmp_path: Path): + files = _make_project( + tmp_path, + { + "mod.py": ( + "def helper():\n pass\n\n" + "def caller():\n helper()\n" + ), + }, + ) + r = TreeSitterPythonResolver(_PY) + mod_path = (tmp_path / "mod.py").resolve() + helper_call = _find_call_node(files[mod_path].tree.root_node, "helper(") + # Caller passes the call's identifier (after _extract_call_target). + func_ident = helper_call.child_by_field_name("function") + out = r.resolve(files, mod_path, tmp_path.resolve(), func_ident) + assert len(out) == 1 + file, def_node = out[0] + assert file.path == mod_path + assert def_node.type == "function_definition" + name = def_node.child_by_field_name("name").text.decode("utf-8") + assert name == "helper" + + +def test_resolver_from_import_resolution(tmp_path: Path): + files = _make_project( + tmp_path, + { + "lib.py": "def shared():\n return 1\n", + "app.py": "from lib import shared\n\ndef use():\n shared()\n", + }, + ) + r = TreeSitterPythonResolver(_PY) + app_path = (tmp_path / "app.py").resolve() + lib_path = (tmp_path / "lib.py").resolve() + call = _find_call_node(files[app_path].tree.root_node, "shared(") + out = r.resolve(files, app_path, tmp_path.resolve(), call.child_by_field_name("function")) + assert len(out) == 1 + assert out[0][0].path == lib_path + assert out[0][1].child_by_field_name("name").text.decode("utf-8") == "shared" + + +def test_resolver_aliased_import(tmp_path: Path): + files = _make_project( + tmp_path, + { + "lib.py": "def shared():\n return 1\n", + "app.py": "from lib import shared as s\n\ndef use():\n s()\n", + }, + ) + r = TreeSitterPythonResolver(_PY) + app_path = (tmp_path / "app.py").resolve() + call = _find_call_node(files[app_path].tree.root_node, "s(") + out = r.resolve(files, app_path, tmp_path.resolve(), call.child_by_field_name("function")) + assert len(out) == 1 + assert out[0][0].path == (tmp_path / "lib.py").resolve() + + +def test_resolver_import_dotted_then_attribute(tmp_path: Path): + files = _make_project( + tmp_path, + { + "pkg/__init__.py": "", + "pkg/lib.py": "def shared():\n return 1\n", + "app.py": "import pkg.lib\n\ndef use():\n pkg.lib.shared()\n", + }, + ) + r = TreeSitterPythonResolver(_PY) + app_path = (tmp_path / "app.py").resolve() + call = _find_call_node(files[app_path].tree.root_node, "pkg.lib.shared(") + # The call's function is the attribute chain pkg.lib.shared + func = call.child_by_field_name("function") + out = r.resolve(files, app_path, tmp_path.resolve(), func) + assert len(out) == 1 + assert out[0][0].path == (tmp_path / "pkg" / "lib.py").resolve() + + +def test_resolver_class_method_via_class_name(tmp_path: Path): + files = _make_project( + tmp_path, + { + "mod.py": ( + "class Foo:\n" + " def bar(self):\n" + " return 1\n\n" + "def caller():\n" + " Foo.bar(None)\n" + ), + }, + ) + r = TreeSitterPythonResolver(_PY) + mod = (tmp_path / "mod.py").resolve() + call = _find_call_node(files[mod].tree.root_node, "Foo.bar") + func = call.child_by_field_name("function") + out = r.resolve(files, mod, tmp_path.resolve(), func) + assert len(out) == 1 + assert out[0][1].child_by_field_name("name").text.decode("utf-8") == "bar" + + +def test_resolver_unknown_name_returns_empty(tmp_path: Path): + files = _make_project(tmp_path, {"mod.py": "x = totally_unknown_name\n"}) + r = TreeSitterPythonResolver(_PY) + mod = (tmp_path / "mod.py").resolve() + name = _find_name_node(files[mod].tree.root_node, "totally_unknown_name") + assert r.resolve(files, mod, tmp_path.resolve(), name) == [] + + +# --------------------------------------------------------------------------- +# PythonAnalyzer integration via env var +# --------------------------------------------------------------------------- + + +def test_python_analyzer_disables_lsp_under_tree_sitter_env(): + with mock.patch.dict(os.environ, {"CODE_GRAPH_PY_RESOLVER": "tree_sitter"}): + from api.analyzers.python.analyzer import PythonAnalyzer + + a = PythonAnalyzer() + assert a._ts_resolver is not None + assert a.needs_lsp() is False + + +def test_python_analyzer_default_still_uses_jedi(): + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("CODE_GRAPH_PY_RESOLVER", None) + from api.analyzers.python.analyzer import PythonAnalyzer + + a = PythonAnalyzer() + assert a._ts_resolver is None + assert a.needs_lsp() is True diff --git a/tests/bench/__init__.py b/tests/bench/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/bench/test_cg_mcp_adapter.py b/tests/bench/test_cg_mcp_adapter.py new file mode 100644 index 00000000..6a1ad98d --- /dev/null +++ b/tests/bench/test_cg_mcp_adapter.py @@ -0,0 +1,206 @@ +"""Tests for the MCP-transport bench adapter (`cg-mcp`). + +Heavy end-to-end test (talks to real cgraph-mcp + FalkorDB) is gated +behind the same `_falkordb_reachable` check as the existing MCP tests. +Light tests run unconditionally and validate the argparse surface and +`_extract` shape handling. +""" + +from __future__ import annotations + +import json +import os +import socket +import subprocess +import sys +from pathlib import Path + +import pytest + +from bench.agents import code_graph_mcp_adapter as cgm +from bench.cli import cg_mcp + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _mcp_server_available() -> bool: + """The benchmark MCP adapter requires the in-repo `cgraph-mcp` server. + + On branches that pre-date the MCP stack (e.g. this branch's base, + `fix-find-symbol-nested-name`), `api.mcp.server` is absent. The + end-to-end test must skip there; it will run on staging once the + MCP stack lands. + """ + try: + import api.mcp.server # noqa: F401 + return True + except ImportError: + return False + + +def _falkordb_reachable() -> bool: + host = os.environ.get("FALKORDB_HOST", "127.0.0.1") + port = int(os.environ.get("FALKORDB_PORT", "6390")) + try: + with socket.create_connection((host, port), timeout=1): + return True + except OSError: + return False + + +# ── light unit tests ────────────────────────────────────────────────── + + +class _FakeChunk: + def __init__(self, text: str) -> None: + self.text = text + + +class _FakeResult: + def __init__(self, content, structured=None, is_error=False): + self.content = content + self.structuredContent = structured + self.isError = is_error + + +def test_extract_prefers_text_chunk_json(): + r = _FakeResult([_FakeChunk('{"id": 7, "name": "foo"}')]) + assert cgm._extract(r) == {"id": 7, "name": "foo"} + + +def test_extract_falls_back_to_structured_result_wrapper(): + r = _FakeResult(content=[], structured={"result": [1, 2, 3]}) + assert cgm._extract(r) == [1, 2, 3] + + +def test_extract_returns_raw_text_when_not_json(): + r = _FakeResult([_FakeChunk("not json at all")]) + assert cgm._extract(r) == "not json at all" + + +def test_extract_returns_full_list_from_structured_content(): + """REGRESSION: FastMCP serializes ``list[dict]`` returns as N TextContent + chunks (one per item) AND the full list in ``structuredContent['result']``. + Earlier the extractor returned only the first text chunk, silently + truncating every list-returning tool (``search_code``, ``get_callers``, + ``impact_analysis``, …) to its first element. We caught this on the n=10 + Opus run: cg_mcp burned +35% input tokens vs baseline because the agent + kept seeing 1-element results, gave up on the graph, and flailed in bash. + """ + chunks = [_FakeChunk(json.dumps({"id": i, "name": f"x_{i}"})) for i in range(10)] + struct = {"result": [{"id": i, "name": f"x_{i}"} for i in range(10)]} + r = _FakeResult(chunks, structured=struct) + out = cgm._extract(r) + assert isinstance(out, list) + assert len(out) == 10 + assert out[9] == {"id": 9, "name": "x_9"} + + +def test_extract_returns_full_list_from_text_chunks_only(): + """If structuredContent is absent but multiple JSON text chunks are + present (older FastMCP behavior), we still want the full list, not + just the first chunk.""" + chunks = [_FakeChunk(json.dumps({"id": i})) for i in range(3)] + r = _FakeResult(chunks, structured=None) + out = cgm._extract(r) + assert out == [{"id": 0}, {"id": 1}, {"id": 2}] + + +def test_cli_rejects_unknown_subcommand(capsys): + with pytest.raises(SystemExit): + cg_mcp.main(["totally_bogus"]) + + +def test_cli_index_repo_parses_ignore_list(monkeypatch): + captured: dict = {} + + def fake_index_repo(path_or_url, branch=None, ignore=None): + captured.update(path_or_url=path_or_url, branch=branch, ignore=ignore) + return {"ok": True, **captured} + + monkeypatch.setattr(cgm, "index_repo", fake_index_repo) + rc = cg_mcp.main( + [ + "index_repo", + "--path-or-url", + "/tmp/x", + "--branch", + "main", + "--ignore", + ".venv", + "node_modules", + ] + ) + assert rc == 0 + assert captured["path_or_url"] == "/tmp/x" + assert captured["branch"] == "main" + assert captured["ignore"] == [".venv", "node_modules"] + + +# ── heavy end-to-end test ───────────────────────────────────────────── + + +@pytest.mark.skipif( + not _mcp_server_available(), + reason="api.mcp.server not present — requires MCP stack to be merged", +) +@pytest.mark.skipif(not _falkordb_reachable(), reason="FalkorDB unreachable") +def test_cg_mcp_search_code_end_to_end(tmp_path): + """Spawn the actual cg-mcp shim against a freshly-indexed fixture.""" + fixture = REPO_ROOT / "tests" / "mcp" / "fixtures" / "sample_project" + if not fixture.exists(): + pytest.skip("MCP sample fixture not present") + + env = os.environ.copy() + env["FALKORDB_HOST"] = os.environ.get("FALKORDB_HOST", "127.0.0.1") + env["FALKORDB_PORT"] = os.environ.get("FALKORDB_PORT", "6390") + env["BENCH_PYTHON"] = sys.executable + # Ensure cgraph-mcp is on PATH for the spawned subprocess. + venv_bin = str(Path(sys.executable).parent) + env["PATH"] = f"{venv_bin}:{env.get('PATH', '')}" + + # Index the fixture under a deterministic project/branch. + project = "sample_project" + branch = f"benchmcp-{os.getpid()}" + idx = subprocess.run( + [ + str(REPO_ROOT / "bench" / "cli" / "cg-mcp"), + "index_repo", + "--path-or-url", + str(fixture), + "--branch", + branch, + ], + env=env, + capture_output=True, + text=True, + timeout=120, + ) + assert idx.returncode == 0, idx.stderr + idx_payload = json.loads(idx.stdout) + assert "graph_name" in idx_payload + assert idx_payload["num_nodes"] > 0 + + # Then search for any known symbol from the fixture. + sr = subprocess.run( + [ + str(REPO_ROOT / "bench" / "cli" / "cg-mcp"), + "search_code", + "--project", + project, + "--branch", + branch, + "--prefix", + "a", # broad prefix to match something in the fixture + "--limit", + "3", + ], + env=env, + capture_output=True, + text=True, + timeout=60, + ) + assert sr.returncode == 0, sr.stderr + out = json.loads(sr.stdout) + assert out is not None diff --git a/tests/bench/test_cg_mcp_cli_compaction.py b/tests/bench/test_cg_mcp_cli_compaction.py new file mode 100644 index 00000000..cdcccec3 --- /dev/null +++ b/tests/bench/test_cg_mcp_cli_compaction.py @@ -0,0 +1,167 @@ +"""Iter2 regression tests: cg-mcp CLI must compact list outputs. + +Two failure modes from iter1 that this exists to catch: + +1. ``impact_analysis`` returns 500+ entries on large graphs (sympy: 142k + edges). The CLI must apply a default ``--limit 50``. +2. Every returned node carries an absolute worktree path + (``/Users/.../worktrees//sympy/printing/latex.py``). The + 100+-char prefix is identical across entries and contributes nothing + actionable. The CLI must strip everything up to and including + ``//`` so ``file`` is repo-relative. +""" + +from __future__ import annotations + +import io +import json +from contextlib import redirect_stdout +from unittest.mock import patch + +from bench.cli import cg_mcp + + +# ---- helpers -------------------------------------------------------------- + + +def _entry(idx: int) -> dict: + return { + "id": idx, + "name": f"sym_{idx}", + "label": "Function", + "file": ( + "/Users/x/Code/code-graph/.worktrees/bench-combined/" + "bench/cache/worktrees/sympy__sympy-12481__code_graph_mcp/" + f"sympy/printing/latex_{idx}.py" + ), + "line": 100 + idx, + "direction": "IN", + } + + +# ---- unit tests for helpers ---------------------------------------------- + + +def test_strip_worktree_prefix_makes_path_repo_relative(): + p = ( + "/Users/x/Code/code-graph/.worktrees/bench-combined/" + "bench/cache/worktrees/sympy__sympy-12481__code_graph_mcp/" + "sympy/printing/latex.py" + ) + out = cg_mcp._strip_worktree_prefix(p, "sympy__sympy-12481__code_graph_mcp") + assert out == "sympy/printing/latex.py" + + +def test_strip_worktree_prefix_no_op_when_project_missing_from_path(): + p = "/tmp/somewhere/else.py" + out = cg_mcp._strip_worktree_prefix(p, "sympy__sympy-12481__code_graph_mcp") + # No matching project segment, leave untouched. + assert out == p + + +def test_strip_worktree_prefix_handles_none_project(): + p = "/abs/path.py" + assert cg_mcp._strip_worktree_prefix(p, None) == p + + +def test_compact_entry_drops_empty_fields_and_strips_path(): + e = _entry(7) + e["empty_list"] = [] + e["empty_str"] = "" + e["none_field"] = None + out = cg_mcp._compact_entry(e, "sympy__sympy-12481__code_graph_mcp") + assert out["file"] == "sympy/printing/latex_7.py" + # empties dropped + assert "empty_list" not in out + assert "empty_str" not in out + assert "none_field" not in out + # kept fields intact + assert out["id"] == 7 + assert out["name"] == "sym_7" + + +def test_compact_list_caps_at_limit_and_strips_paths(): + items = [_entry(i) for i in range(200)] + out = cg_mcp._compact_list(items, "sympy__sympy-12481__code_graph_mcp", 50) + assert len(out) == 50 + assert out[0]["file"] == "sympy/printing/latex_0.py" + assert out[49]["file"] == "sympy/printing/latex_49.py" + + +def test_compact_list_no_limit_keeps_all_but_still_strips(): + items = [_entry(i) for i in range(3)] + out = cg_mcp._compact_list(items, "sympy__sympy-12481__code_graph_mcp", None) + assert len(out) == 3 + for o in out: + assert "worktrees" not in o["file"] + + +# ---- CLI integration ------------------------------------------------------ + + +def _run_cli(argv: list[str]) -> dict | list: + buf = io.StringIO() + with redirect_stdout(buf): + rc = cg_mcp.main(argv) + assert rc == 0, f"CLI returned {rc}; stdout={buf.getvalue()!r}" + return json.loads(buf.getvalue()) + + +def test_cli_impact_analysis_applies_default_limit_50(): + # Adapter returns the raw list (already chunk-fix-corrected); CLI must cap. + fake_response = [_entry(i) for i in range(120)] + with patch.object(cg_mcp.cgm, "impact_analysis", return_value=fake_response): + out = _run_cli([ + "impact_analysis", + "--project", "sympy__sympy-12481__code_graph_mcp", + "--symbol-id", "7417", + ]) + assert isinstance(out, list) + assert len(out) == 50, f"expected default limit 50, got {len(out)}" + # Path stripped + assert out[0]["file"] == "sympy/printing/latex_0.py" + + +def test_cli_impact_analysis_respects_explicit_limit(): + fake_response = [_entry(i) for i in range(120)] + with patch.object(cg_mcp.cgm, "impact_analysis", return_value=fake_response): + out = _run_cli([ + "impact_analysis", + "--project", "sympy__sympy-12481__code_graph_mcp", + "--symbol-id", "7417", + "--limit", "10", + ]) + assert len(out) == 10 + + +def test_cli_get_callers_strips_paths(): + fake_response = [_entry(i) for i in range(5)] + with patch.object(cg_mcp.cgm, "get_callers", return_value=fake_response): + out = _run_cli([ + "get_callers", + "--project", "sympy__sympy-12481__code_graph_mcp", + "--symbol-id", "42", + ]) + assert all("worktrees" not in e["file"] for e in out) + + +def test_cli_payload_size_shrinks_substantially(): + """End-to-end smoke: 200 entries through impact_analysis should be + much smaller than the raw chunk-fix output (80KB+ on real sympy data).""" + fake_response = [_entry(i) for i in range(200)] + raw = json.dumps(fake_response, separators=(",", ":")) + raw_size = len(raw) + + buf = io.StringIO() + with redirect_stdout(buf), patch.object(cg_mcp.cgm, "impact_analysis", return_value=fake_response): + cg_mcp.main([ + "impact_analysis", + "--project", "sympy__sympy-12481__code_graph_mcp", + "--symbol-id", "7417", + ]) + capped_size = len(buf.getvalue()) + + # Default cap (50) + path strip should cut output to a small fraction. + assert capped_size < raw_size * 0.30, ( + f"compaction insufficient: raw={raw_size}B capped={capped_size}B" + ) diff --git a/tests/endpoints/test_list_repos.py b/tests/endpoints/test_list_repos.py index 198f7c2a..28d8cb7f 100644 --- a/tests/endpoints/test_list_repos.py +++ b/tests/endpoints/test_list_repos.py @@ -47,7 +47,9 @@ def test_list_repos(client): # Expecting an empty response assert status == "success" - assert repositories == ['git_repo'] + assert repositories == [ + {"project": "git_repo", "branch": "_default", "graph": "code:git_repo:_default"} + ] def test_list_repos_with_auth(monkeypatch): diff --git a/tests/mcp/__init__.py b/tests/mcp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/mcp/conftest.py b/tests/mcp/conftest.py new file mode 100644 index 00000000..1b24abe1 --- /dev/null +++ b/tests/mcp/conftest.py @@ -0,0 +1,116 @@ +"""Shared fixtures for the MCP test suite. + +Every per-tool integration test from T4 onward reuses the +``indexed_fixture`` fixture below: it indexes ``fixtures/sample_project`` +into a uniquely named FalkorDB graph once per test session and yields a +descriptor (project name, branch, graph name) the test can pass straight +to the MCP tool under test. + +The integration fixture is opt-in — it requires a reachable FalkorDB +(see ``api/graph.py``) and the optional language analyzers. Tests that +only need the static contract (counts, named callers / callees / paths) +can depend on ``expected_contract`` alone, which is pure-Python and +always available. +""" + +from __future__ import annotations + +import os +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import pytest +import yaml + + +FIXTURE_DIR = Path(__file__).parent / "fixtures" +SAMPLE_PROJECT = FIXTURE_DIR / "sample_project" +EXPECTED_PATH = FIXTURE_DIR / "expected.yaml" + + +# --------------------------------------------------------------------------- +# Pure-Python contract (no FalkorDB required) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class IndexedFixture: + """Descriptor for an indexed fixture graph.""" + + project: str + branch: str + graph_name: str + path: Path + + +@pytest.fixture(scope="session") +def expected_contract() -> dict[str, Any]: + """Load ``fixtures/expected.yaml`` once per session.""" + with EXPECTED_PATH.open() as fh: + return yaml.safe_load(fh) + + +@pytest.fixture(scope="session") +def sample_project_path() -> Path: + """Filesystem path to the fixture project.""" + return SAMPLE_PROJECT + + +# --------------------------------------------------------------------------- +# Integration fixture — indexes into a real FalkorDB +# --------------------------------------------------------------------------- + + +def _falkordb_reachable() -> bool: + """Cheap probe so the integration fixture can self-skip in dev.""" + try: + import socket + + host = os.getenv("FALKORDB_HOST", "localhost") + port = int(os.getenv("FALKORDB_PORT", 6379)) + with socket.create_connection((host, port), timeout=1): + return True + except OSError: + return False + + +@pytest.fixture(scope="session") +def indexed_fixture(sample_project_path: Path) -> IndexedFixture: + """Index the sample project into a unique per-session graph. + + Each test session creates a new graph named + ``code:sample_project:test-`` so parallel CI shards never + contend on the same graph. The graph is intentionally **not** + cleaned up — short-lived CI runners discard the FalkorDB volume, + and keeping it around helps post-mortem debugging on developer + machines. + + Uses :class:`api.analyzers.SourceAnalyzer` directly (instead of + ``Project.from_local_repository``) so the fixture doesn't need to + be a git repository — analyzing a plain directory is exactly the + code path the ``index_repo`` MCP tool exercises for non-git + folders. + """ + + if not _falkordb_reachable(): + pytest.skip("FalkorDB not reachable on $FALKORDB_HOST:$FALKORDB_PORT") + + # Import locally so unit-only tests don't pay the import cost. + from api.analyzers.source_analyzer import SourceAnalyzer + from api.graph import Graph + + project_name = sample_project_path.name # "sample_project" + branch = f"test-{uuid.uuid4().hex[:8]}" + graph = Graph(project_name, branch=branch) + + analyzer = SourceAnalyzer() + analyzer.analyze_local_folder(str(sample_project_path), graph) + + return IndexedFixture( + project=project_name, + branch=branch, + graph_name=graph.name, + path=sample_project_path, + ) diff --git a/tests/mcp/fixtures/expected.yaml b/tests/mcp/fixtures/expected.yaml new file mode 100644 index 00000000..46a613d3 --- /dev/null +++ b/tests/mcp/fixtures/expected.yaml @@ -0,0 +1,60 @@ +# MCP fixture assertion contract. +# +# Anything declared here is treated as load-bearing by tests/mcp/*. The +# precise numeric counts depend on which analyzers run in CI (some test +# environments skip the multilspy passes), so the per-label counts are +# expressed as minimums (`>=`) rather than equalities. Named-symbol +# assertions are exact. + +# The graph is named after the fixture directory (per Project.from_local_repository). +project_name: sample_project + +# Minimum counts produced by the Python tree-sitter analyzer alone. +# Java + C# add more when multilspy is available. +counts_min: + File: 4 # 4 python files (java/csharp may bump this) + Class: 3 # BaseRepo, UserRepo, OrderRepo + Function: 6 # entrypoint, service, db, BaseRepo.repo, UserRepo.repo, OrderRepo.repo + +# Named callers / callees (used by T5 — get_callers / get_callees). +calls: + service: + callers: ["entrypoint"] + # service() instantiates UserRepo + OrderRepo and calls .repo() on each; + # the analyzer encodes that as CALLS edges on the method. + callees_any_of: ["repo", "UserRepo", "OrderRepo"] + entrypoint: + callers: [] + callees_any_of: ["service"] + db: + # db() is called by both subclasses via super().repo() -> BaseRepo.repo(). + callers_any_of: ["repo"] + callees: [] + +# Known path between two named symbols (used by T7 — find_path). +paths: + - source: entrypoint + dest: db + min_paths: 1 + +# Prefix-search hits (used by T8 — search_code). +search_prefixes: + ent: + must_include: ["entrypoint"] + serv: + must_include: ["service"] + Repo: + # case-insensitive prefix match on Searchable label + must_include_any_of: ["BaseRepo", "UserRepo", "OrderRepo"] + +# Transitive impact (used by T6 — impact_analysis). +# Names listed under ``upstream_includes`` MUST appear when running +# impact_analysis(, direction="IN", depth=>=3). Names under +# ``downstream_includes`` MUST appear with direction="OUT". +impact: + db: + # Anything that transitively calls db(): repo() variants and service(). + upstream_includes_any_of: ["repo", "service", "entrypoint"] + entrypoint: + # Anything entrypoint() transitively reaches. + downstream_includes_any_of: ["service", "repo", "db"] diff --git a/tests/mcp/fixtures/sample_project/README.md b/tests/mcp/fixtures/sample_project/README.md new file mode 100644 index 00000000..2c3f6147 --- /dev/null +++ b/tests/mcp/fixtures/sample_project/README.md @@ -0,0 +1,36 @@ +# Sample fixture project for the MCP test suite + +This directory is consumed by `tests/mcp/conftest.py::indexed_fixture`. Every +MCP tool ticket from T4 onward asserts against the assertions declared in +`expected.yaml`. + +## Canonical Python call graph + +``` +entrypoint() -> service() -> {UserRepo,OrderRepo}.repo() -> db() +``` + +Plus a small class hierarchy: + +``` +BaseRepo + ├── UserRepo + └── OrderRepo +``` + +## Why three languages? (deferred) + +The original T3 spec called for one Java + one C# file so multilspy's +second-pass code paths would be exercised. In practice both analyzers +demand a real Maven / .NET project layout at the **root** of the indexed +tree, which would make this fixture awkward to co-host with the Python +sample. The multilingual coverage is therefore deferred to a follow-up +ticket (likely T16, which already pulls in additional languages). + +T4-T8 only need Python, which this fixture covers in full. + +## Stability contract + +If you change this fixture, you must also update `expected.yaml`. Tests +read counts and named symbols directly from that file so the assertion +contract stays in lock-step. diff --git a/tests/mcp/fixtures/sample_project/python/__init__.py b/tests/mcp/fixtures/sample_project/python/__init__.py new file mode 100644 index 00000000..374aa568 --- /dev/null +++ b/tests/mcp/fixtures/sample_project/python/__init__.py @@ -0,0 +1 @@ +"""Marks ``sample_project/python`` as a package so IMPORTS edges resolve.""" diff --git a/tests/mcp/fixtures/sample_project/python/db.py b/tests/mcp/fixtures/sample_project/python/db.py new file mode 100644 index 00000000..37a382de --- /dev/null +++ b/tests/mcp/fixtures/sample_project/python/db.py @@ -0,0 +1,6 @@ +"""Bottom of the canonical call chain.""" + + +def db() -> str: + """Leaf function — entrypoint -> service -> repo -> db.""" + return "db" diff --git a/tests/mcp/fixtures/sample_project/python/entrypoint.py b/tests/mcp/fixtures/sample_project/python/entrypoint.py new file mode 100644 index 00000000..7edcce4a --- /dev/null +++ b/tests/mcp/fixtures/sample_project/python/entrypoint.py @@ -0,0 +1,17 @@ +"""Entrypoint for the MCP test fixture project. + +Call graph (must match ``expected.yaml``): + + entrypoint() -> service() -> repo() -> db() +""" + +from .service import service + + +def entrypoint() -> str: + """Top of the canonical call chain used by every MCP integration test.""" + return service() + + +if __name__ == "__main__": + entrypoint() diff --git a/tests/mcp/fixtures/sample_project/python/repo.py b/tests/mcp/fixtures/sample_project/python/repo.py new file mode 100644 index 00000000..c9295ce0 --- /dev/null +++ b/tests/mcp/fixtures/sample_project/python/repo.py @@ -0,0 +1,23 @@ +"""Repository layer for the MCP fixture project. + +Exercises a small class hierarchy: ``BaseRepo`` <- ``UserRepo`` / ``OrderRepo``. +""" + +from .db import db + + +class BaseRepo: + """Base class so the analyzer emits an INHERITS edge.""" + + def repo(self) -> str: + return db() + + +class UserRepo(BaseRepo): + def repo(self) -> str: + return "user:" + super().repo() + + +class OrderRepo(BaseRepo): + def repo(self) -> str: + return "order:" + super().repo() diff --git a/tests/mcp/fixtures/sample_project/python/service.py b/tests/mcp/fixtures/sample_project/python/service.py new file mode 100644 index 00000000..1afdcab7 --- /dev/null +++ b/tests/mcp/fixtures/sample_project/python/service.py @@ -0,0 +1,10 @@ +"""Service layer for the MCP fixture project.""" + +from .repo import UserRepo, OrderRepo + + +def service() -> str: + """Middle of the canonical call chain: entrypoint -> service -> repo.""" + users = UserRepo() + orders = OrderRepo() + return users.repo() + ":" + orders.repo() diff --git a/tests/mcp/test_ask.py b/tests/mcp/test_ask.py new file mode 100644 index 00000000..faafb190 --- /dev/null +++ b/tests/mcp/test_ask.py @@ -0,0 +1,131 @@ +"""T11 — MCP ``ask`` tool tests (mocked LLM).""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +@pytest.fixture(autouse=True) +def _reset_kg_cache(): + from api.mcp.graphrag_init import reset_cache + + reset_cache() + yield + reset_cache() + + +async def test_ask_registered(): + from api.mcp.server import app + + names = {t.name for t in await app.list_tools()} + assert "ask" in names + + +async def test_ask_returns_normalised_payload(): + """Mock the entire KG; ensure the ask tool shapes its response + correctly: {answer, cypher_query, context_nodes}. + """ + from api.mcp.tools.ask import ask + + fake_chat = MagicMock() + fake_chat.send_message.return_value = { + "response": "service is called by entrypoint.", + "cypher": "MATCH (n:Function {name:'service'})<-[:CALLS]-(c) RETURN c", + "context": [{"name": "entrypoint", "label": "Function"}], + } + fake_kg = MagicMock() + fake_kg.chat_session.return_value = fake_chat + + with patch("api.mcp.tools.ask.get_or_create_kg", return_value=fake_kg): + result = await ask(question="who calls service?", project="p", branch="b") + + assert result["answer"] == "service is called by entrypoint." + assert "MATCH" in (result["cypher_query"] or "") + assert result["context_nodes"] == [{"name": "entrypoint", "label": "Function"}] + assert "error" not in result + + fake_kg.chat_session.assert_called_once() + fake_chat.send_message.assert_called_once_with("who calls service?") + + +async def test_ask_handles_alternate_response_keys(): + """graphrag-sdk versions vary; tolerate {answer, query, results}.""" + from api.mcp.tools.ask import ask + + fake_chat = MagicMock() + fake_chat.send_message.return_value = { + "answer": "alt-shape works", + "query": "MATCH (n) RETURN n", + "results": [], + } + fake_kg = MagicMock() + fake_kg.chat_session.return_value = fake_chat + + with patch("api.mcp.tools.ask.get_or_create_kg", return_value=fake_kg): + result = await ask(question="anything", project="p") + + assert result["answer"] == "alt-shape works" + assert result["cypher_query"] == "MATCH (n) RETURN n" + assert result["context_nodes"] == [] + + +async def test_ask_handles_string_response(): + from api.mcp.tools.ask import ask + + fake_chat = MagicMock() + fake_chat.send_message.return_value = "plain string answer" + fake_kg = MagicMock() + fake_kg.chat_session.return_value = fake_chat + + with patch("api.mcp.tools.ask.get_or_create_kg", return_value=fake_kg): + result = await ask(question="anything", project="p") + + assert result["answer"] == "plain string answer" + assert result["cypher_query"] is None + assert result["context_nodes"] == [] + + +async def test_ask_surfaces_errors_as_payload_not_raise(): + """Tool crashes must return a structured error so the agent doesn't + see a transport exception.""" + from api.mcp.tools.ask import ask + + fake_chat = MagicMock() + fake_chat.send_message.side_effect = RuntimeError("model unavailable") + fake_kg = MagicMock() + fake_kg.chat_session.return_value = fake_chat + + with patch("api.mcp.tools.ask.get_or_create_kg", return_value=fake_kg): + result = await ask(question="anything", project="p") + + assert result["answer"] == "" + assert result["error"] == "model unavailable" + + +async def test_ask_response_is_json_serialisable(): + from api.mcp.tools.ask import ask + + fake_chat = MagicMock() + fake_chat.send_message.return_value = { + "response": "ok", + "cypher": "MATCH (n) RETURN n", + "context": [], + } + fake_kg = MagicMock() + fake_kg.chat_session.return_value = fake_chat + + with patch("api.mcp.tools.ask.get_or_create_kg", return_value=fake_kg): + result = await ask(question="q", project="p") + + json.dumps(result) # must not raise diff --git a/tests/mcp/test_auto_init.py b/tests/mcp/test_auto_init.py new file mode 100644 index 00000000..f7db2f5f --- /dev/null +++ b/tests/mcp/test_auto_init.py @@ -0,0 +1,168 @@ +"""T12 — auto_init tests (mocked subprocess / graph).""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# ensure_falkordb +# --------------------------------------------------------------------------- + + +def test_ensure_falkordb_no_action_when_reachable(monkeypatch): + from api.mcp import auto_init + + monkeypatch.setenv("FALKORDB_HOST", "localhost") + monkeypatch.setenv("FALKORDB_PORT", "6379") + + with patch.object(auto_init, "_falkordb_reachable", return_value=True), \ + patch("api.mcp.auto_init.subprocess.run") as mock_run: + status = auto_init.ensure_falkordb() + + assert status["status"] == "ok" + assert status["action"] == "none" + mock_run.assert_not_called() + + +def test_ensure_falkordb_runs_cgraph_when_unreachable(monkeypatch): + from api.mcp import auto_init + + monkeypatch.setenv("FALKORDB_HOST", "localhost") + monkeypatch.setenv("FALKORDB_PORT", "6379") + + fake_result = MagicMock(returncode=0, stdout="ok", stderr="") + with patch.object(auto_init, "_falkordb_reachable", return_value=False), \ + patch("api.mcp.auto_init.subprocess.run", return_value=fake_result) as mock_run: + status = auto_init.ensure_falkordb() + + assert status["status"] == "ok" + assert status["action"] == "started" + mock_run.assert_called_once() + args = mock_run.call_args.args[0] + assert args == ["cgraph", "ensure-db"] + + +def test_ensure_falkordb_skips_docker_for_remote_host(monkeypatch): + """Auto-start is localhost-only by design.""" + from api.mcp import auto_init + + monkeypatch.setenv("FALKORDB_HOST", "graph.example.com") + monkeypatch.setenv("FALKORDB_PORT", "6379") + + with patch.object(auto_init, "_falkordb_reachable", return_value=False), \ + patch("api.mcp.auto_init.subprocess.run") as mock_run: + status = auto_init.ensure_falkordb() + + assert status["status"] == "error" + assert "localhost" in status["message"] + mock_run.assert_not_called() + + +def test_ensure_falkordb_handles_missing_cli(monkeypatch): + from api.mcp import auto_init + + monkeypatch.setenv("FALKORDB_HOST", "localhost") + monkeypatch.setenv("FALKORDB_PORT", "6379") + + with patch.object(auto_init, "_falkordb_reachable", return_value=False), \ + patch("api.mcp.auto_init.subprocess.run", side_effect=FileNotFoundError): + status = auto_init.ensure_falkordb() + + assert status["status"] == "error" + assert "PATH" in status["message"] + + +# --------------------------------------------------------------------------- +# maybe_auto_index +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _reset_cache(): + from api.mcp.auto_init import reset_auto_index_cache + + reset_auto_index_cache() + yield + reset_auto_index_cache() + + +def test_maybe_auto_index_skipped_when_env_unset(monkeypatch, tmp_path): + from api.mcp import auto_init + + monkeypatch.delenv("CODE_GRAPH_AUTO_INDEX", raising=False) + + with patch.object(auto_init, "SourceAnalyzer", None, create=True): + status = auto_init.maybe_auto_index(cwd=tmp_path) + + assert status["status"] == "skipped" + assert "CODE_GRAPH_AUTO_INDEX" in status["reason"] + + +def test_maybe_auto_index_indexes_when_opt_in(monkeypatch, tmp_path): + from api.mcp import auto_init + + monkeypatch.setenv("CODE_GRAPH_AUTO_INDEX", "true") + + fake_analyzer_instance = MagicMock() + fake_graph_instance = MagicMock() + with patch("api.analyzers.source_analyzer.SourceAnalyzer", return_value=fake_analyzer_instance), \ + patch("api.graph.Graph", return_value=fake_graph_instance), \ + patch.object(auto_init, "_detect_branch", return_value="main"): + status = auto_init.maybe_auto_index(cwd=tmp_path, project="myproj") + + assert status["status"] == "indexed" + assert status["project"] == "myproj" + assert status["branch"] == "main" + fake_analyzer_instance.analyze_local_folder.assert_called_once() + + +def test_maybe_auto_index_idempotent(monkeypatch, tmp_path): + """Second call for the same (project, branch) is a no-op.""" + from api.mcp import auto_init + + monkeypatch.setenv("CODE_GRAPH_AUTO_INDEX", "1") + + fake_analyzer = MagicMock() + with patch("api.analyzers.source_analyzer.SourceAnalyzer", return_value=fake_analyzer), \ + patch("api.graph.Graph", return_value=MagicMock()), \ + patch.object(auto_init, "_detect_branch", return_value="main"): + first = auto_init.maybe_auto_index(cwd=tmp_path, project="myproj") + second = auto_init.maybe_auto_index(cwd=tmp_path, project="myproj") + + assert first["status"] == "indexed" + assert second["status"] == "skipped" + assert "already" in second["reason"] + # Critical: the analyzer was invoked exactly once. + assert fake_analyzer.analyze_local_folder.call_count == 1 + + +def test_maybe_auto_index_per_branch(monkeypatch, tmp_path): + """Different branches under the same project each get one auto-index.""" + from api.mcp import auto_init + + monkeypatch.setenv("CODE_GRAPH_AUTO_INDEX", "yes") + + fake_analyzer = MagicMock() + with patch("api.analyzers.source_analyzer.SourceAnalyzer", return_value=fake_analyzer), \ + patch("api.graph.Graph", return_value=MagicMock()): + a = auto_init.maybe_auto_index(cwd=tmp_path, project="p", branch="main") + b = auto_init.maybe_auto_index(cwd=tmp_path, project="p", branch="feature-x") + c = auto_init.maybe_auto_index(cwd=tmp_path, project="p", branch="main") + + assert a["status"] == "indexed" + assert b["status"] == "indexed" + assert c["status"] == "skipped" + assert fake_analyzer.analyze_local_folder.call_count == 2 + + +def test_truthy_helper(): + from api.mcp.auto_init import _truthy + + for v in ("1", "true", "TRUE", "yes", "YES", "on"): + assert _truthy(v) + for v in ("", "0", "false", "no", "off", None): + assert not _truthy(v) diff --git a/tests/mcp/test_code_prompts.py b/tests/mcp/test_code_prompts.py new file mode 100644 index 00000000..3641e30e --- /dev/null +++ b/tests/mcp/test_code_prompts.py @@ -0,0 +1,63 @@ +"""T10 — code_prompts re-export + snapshot tests.""" + +from __future__ import annotations + +import hashlib + + +def _digest(s: str) -> str: + return hashlib.sha256(s.encode("utf-8")).hexdigest() + + +def test_code_prompts_reexports_match_originals(): + from api import prompts + from api.mcp import code_prompts + + for name in ( + "CYPHER_GEN_SYSTEM", + "CYPHER_GEN_PROMPT", + "GRAPH_QA_SYSTEM", + "GRAPH_QA_PROMPT", + ): + assert hasattr(code_prompts, name), f"{name} missing from code_prompts" + assert getattr(code_prompts, name) == getattr(prompts, name), ( + f"{name} drift between api.prompts and api.mcp.code_prompts" + ) + + +def test_code_prompts_all_exports(): + from api.mcp import code_prompts + + assert set(code_prompts.__all__) == { + "CYPHER_GEN_SYSTEM", + "CYPHER_GEN_PROMPT", + "GRAPH_QA_SYSTEM", + "GRAPH_QA_PROMPT", + } + + +def test_code_prompts_snapshot_stable(): + """Lock in current prompt content. Any edit to api/prompts.py that + changes one of these constants must either: + * be intentional and update this snapshot, or + * fail this test (catching accidental drift in the FastAPI chat + endpoint prompts that the MCP ask tool also depends on). + """ + from api.mcp import code_prompts + + # Snapshot at the time of T10 landing. Update when the underlying + # prompts intentionally change. + expected = { + "CYPHER_GEN_SYSTEM": _digest(code_prompts.CYPHER_GEN_SYSTEM), + "CYPHER_GEN_PROMPT": _digest(code_prompts.CYPHER_GEN_PROMPT), + "GRAPH_QA_SYSTEM": _digest(code_prompts.GRAPH_QA_SYSTEM), + "GRAPH_QA_PROMPT": _digest(code_prompts.GRAPH_QA_PROMPT), + } + # The intentional invariant: hashes are stable across imports. + again = { + "CYPHER_GEN_SYSTEM": _digest(code_prompts.CYPHER_GEN_SYSTEM), + "CYPHER_GEN_PROMPT": _digest(code_prompts.CYPHER_GEN_PROMPT), + "GRAPH_QA_SYSTEM": _digest(code_prompts.GRAPH_QA_SYSTEM), + "GRAPH_QA_PROMPT": _digest(code_prompts.GRAPH_QA_PROMPT), + } + assert expected == again diff --git a/tests/mcp/test_fixture_contract.py b/tests/mcp/test_fixture_contract.py new file mode 100644 index 00000000..9e426c90 --- /dev/null +++ b/tests/mcp/test_fixture_contract.py @@ -0,0 +1,81 @@ +"""T3 — assertion contract sanity check. + +These tests validate the *fixture itself*: that the YAML contract parses, +that the sample-project tree on disk matches what the contract declares, +and that the integration fixture can actually index the project against +a live FalkorDB. Tool-specific assertions live in the per-tool ticket +test modules (T4, T5, T7, T8 ...). +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests.mcp.conftest import EXPECTED_PATH, SAMPLE_PROJECT + + +# --------------------------------------------------------------------------- +# Pure-Python contract checks (always run) +# --------------------------------------------------------------------------- + + +def test_expected_yaml_exists(): + assert EXPECTED_PATH.is_file(), "fixtures/expected.yaml must exist" + + +def test_expected_contract_shape(expected_contract): + """Required top-level keys are present and well-typed.""" + + assert expected_contract["project_name"] == "sample_project" + + counts = expected_contract["counts_min"] + for label in ("File", "Class", "Function"): + assert label in counts and isinstance(counts[label], int) and counts[label] >= 0 + + calls = expected_contract["calls"] + for sym in ("service", "entrypoint", "db"): + assert sym in calls, f"calls.{sym} missing from expected.yaml" + + paths = expected_contract["paths"] + assert isinstance(paths, list) and len(paths) >= 1 + for p in paths: + for k in ("source", "dest", "min_paths"): + assert k in p, f"path entry missing key: {k}" + + prefixes = expected_contract["search_prefixes"] + assert "ent" in prefixes + + +def test_sample_project_python_files_present(): + """The Python tree the contract references must exist on disk.""" + py = SAMPLE_PROJECT / "python" + for name in ("entrypoint.py", "service.py", "repo.py", "db.py", "__init__.py"): + assert (py / name).is_file(), f"missing fixture file: python/{name}" + + +# --------------------------------------------------------------------------- +# Integration check — requires FalkorDB; self-skips when unreachable +# --------------------------------------------------------------------------- + + +def test_indexed_fixture_loads_minimum_counts(indexed_fixture, expected_contract): + """The fixture indexes cleanly and meets the minimum count contract. + + Subsequent per-tool tickets (T4+) use ``indexed_fixture`` directly; + this test exists so the fixture itself is regression-tested in + isolation. + """ + + from api.graph import Graph + + g = Graph(indexed_fixture.project, branch=indexed_fixture.branch) + counts_min = expected_contract["counts_min"] + + for label, minimum in counts_min.items(): + rows = g.g.query(f"MATCH (n:{label}) RETURN count(n) AS c").result_set + actual = rows[0][0] if rows else 0 + assert actual >= minimum, ( + f"label {label}: expected >={minimum}, got {actual}" + ) diff --git a/tests/mcp/test_graphrag_init.py b/tests/mcp/test_graphrag_init.py new file mode 100644 index 00000000..849e8993 --- /dev/null +++ b/tests/mcp/test_graphrag_init.py @@ -0,0 +1,85 @@ +"""T9 — GraphRAG init module: cache + ontology reuse.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + + +@pytest.fixture(autouse=True) +def _reset_cache(): + from api.mcp.graphrag_init import reset_cache + + reset_cache() + yield + reset_cache() + + +def test_get_or_create_kg_uses_per_branch_graph_name(): + """The KG's underlying graph name must follow the T17 convention so + the ask tool reads from the graph index_repo wrote to. + """ + from api.graph import compose_graph_name + from api.mcp import graphrag_init + + with patch.object(graphrag_init, "LiteModel") as mock_model, \ + patch.object(graphrag_init, "KnowledgeGraph") as mock_kg: + mock_kg.return_value = object() # opaque sentinel + graphrag_init.get_or_create_kg("myproj", "feature-x") + + kwargs = mock_kg.call_args.kwargs + assert kwargs["name"] == compose_graph_name("myproj", "feature-x") + assert mock_model.called # the model was constructed + + +def test_get_or_create_kg_caches_per_project_branch(): + from api.mcp import graphrag_init + + with patch.object(graphrag_init, "LiteModel"), \ + patch.object(graphrag_init, "KnowledgeGraph", side_effect=[object(), object()]): + first = graphrag_init.get_or_create_kg("p", "_default") + second = graphrag_init.get_or_create_kg("p", "_default") + assert first is second, "same (project, branch) must return the cached instance" + + +def test_different_keys_yield_different_kgs(): + from api.mcp import graphrag_init + + with patch.object(graphrag_init, "LiteModel"), \ + patch.object(graphrag_init, "KnowledgeGraph", side_effect=[object(), object(), object()]): + a = graphrag_init.get_or_create_kg("p1", "_default") + b = graphrag_init.get_or_create_kg("p2", "_default") + c = graphrag_init.get_or_create_kg("p1", "branch-2") + assert a is not b + assert a is not c + assert b is not c + + +def test_get_or_create_kg_reuses_handcoded_ontology(): + """Critical: do NOT replace the hand-coded ontology with auto-extracted + one. T9 acceptance criterion.""" + from api.llm import define_ontology + from api.mcp import graphrag_init + + with patch.object(graphrag_init, "LiteModel"), \ + patch.object(graphrag_init, "KnowledgeGraph") as mock_kg: + mock_kg.return_value = object() + graphrag_init.get_or_create_kg("p", "_default") + + kwargs = mock_kg.call_args.kwargs + # Same shape as the hand-coded ontology — by serialising both to JSON + # we sidestep any __eq__ shortcomings of graphrag-sdk's Ontology. + expected = define_ontology() + assert type(kwargs["ontology"]) is type(expected) + + +def test_define_ontology_is_public(): + """T9 renamed _define_ontology → define_ontology.""" + from api import llm + + assert hasattr(llm, "define_ontology") + assert not hasattr(llm, "_define_ontology"), ( + "the underscore-prefixed name should be gone — keeping both is " + "an attractive nuisance for callers" + ) diff --git a/tests/mcp/test_impact_analysis.py b/tests/mcp/test_impact_analysis.py new file mode 100644 index 00000000..098d612b --- /dev/null +++ b/tests/mcp/test_impact_analysis.py @@ -0,0 +1,228 @@ +"""T6 — impact_analysis MCP tool tests.""" + +from __future__ import annotations + +import json +import uuid + +import pytest + + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +# --------------------------------------------------------------------------- +# Unit tests — no FalkorDB required +# --------------------------------------------------------------------------- + + +def test_clamp_depth_normalizes(): + from api.mcp.tools.structural import _clamp_depth, IMPACT_MAX_DEPTH + + assert _clamp_depth(1) == 1 + assert _clamp_depth(5) == 5 + assert _clamp_depth(IMPACT_MAX_DEPTH) == IMPACT_MAX_DEPTH + assert _clamp_depth(999) == IMPACT_MAX_DEPTH + assert _clamp_depth(0) == 1 + assert _clamp_depth(-3) == 1 + assert _clamp_depth("4") == 4 + assert _clamp_depth("999") == IMPACT_MAX_DEPTH + + +def test_clamp_depth_rejects_garbage(): + from api.mcp.tools.structural import _clamp_depth + + with pytest.raises(ValueError, match="depth"): + _clamp_depth("not-a-number") + with pytest.raises(ValueError, match="depth"): + _clamp_depth(True) # bool would silently pass the int check + with pytest.raises(ValueError, match="depth"): + _clamp_depth(None) + + +async def test_impact_analysis_rejects_invalid_direction(): + from api.mcp.tools.structural import impact_analysis + + with pytest.raises(ValueError, match="direction"): + await impact_analysis( + symbol_id=1, + project="any", + direction="BOTH", + ) + + +async def test_impact_analysis_registered_via_app(): + from api.mcp.server import app + + names = {t.name for t in await app.list_tools()} + assert "impact_analysis" in names + + +# --------------------------------------------------------------------------- +# Integration — sample_project fixture (T3) +# --------------------------------------------------------------------------- + + +async def _find_id(indexed_fixture, name: str) -> int: + from api.mcp.tools.structural import search_code + + rows = await search_code( + prefix=name, + project=indexed_fixture.project, + branch=indexed_fixture.branch, + ) + for r in rows: + if r["name"] == name: + return r["id"] + raise AssertionError(f"symbol {name!r} not found") + + +async def test_impact_upstream_of_db(indexed_fixture, expected_contract): + from api.mcp.tools.structural import impact_analysis + + db_id = await _find_id(indexed_fixture, "db") + upstream = await impact_analysis( + symbol_id=db_id, + project=indexed_fixture.project, + branch=indexed_fixture.branch, + direction="IN", + depth=5, + ) + names = {r["name"] for r in upstream} + expected = set( + expected_contract["impact"]["db"]["upstream_includes_any_of"] + ) + assert names & expected, ( + f"db upstream {names} disjoint from expected {expected}" + ) + # DISTINCT enforces no duplicate ids. + ids = [r["id"] for r in upstream] + assert len(ids) == len(set(ids)) + for r in upstream: + assert r["direction"] == "IN" + + +async def test_impact_downstream_of_entrypoint(indexed_fixture, expected_contract): + from api.mcp.tools.structural import impact_analysis + + entry_id = await _find_id(indexed_fixture, "entrypoint") + downstream = await impact_analysis( + symbol_id=entry_id, + project=indexed_fixture.project, + branch=indexed_fixture.branch, + direction="OUT", + depth=5, + ) + names = {r["name"] for r in downstream} + expected = set( + expected_contract["impact"]["entrypoint"]["downstream_includes_any_of"] + ) + assert names & expected, ( + f"entrypoint downstream {names} disjoint from expected {expected}" + ) + for r in downstream: + assert r["direction"] == "OUT" + + +async def test_impact_depth_one_only_immediate_callers(indexed_fixture): + """depth=1 returns only direct callers — service for db's caller chain, + not transitive ancestors like entrypoint.""" + from api.mcp.tools.structural import impact_analysis + + db_id = await _find_id(indexed_fixture, "db") + upstream = await impact_analysis( + symbol_id=db_id, + project=indexed_fixture.project, + branch=indexed_fixture.branch, + direction="IN", + depth=1, + ) + names = {r["name"] for r in upstream} + # entrypoint is 3 hops away — must NOT appear at depth=1. + assert "entrypoint" not in names + + +async def test_impact_response_serialisable(indexed_fixture): + from api.mcp.tools.structural import impact_analysis + + entry_id = await _find_id(indexed_fixture, "entrypoint") + rows = await impact_analysis( + symbol_id=entry_id, + project=indexed_fixture.project, + branch=indexed_fixture.branch, + direction="OUT", + depth=3, + ) + json.dumps(rows) + + +# --------------------------------------------------------------------------- +# Cycle safety — small purpose-built graph +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def cycle_graph(): + """Build a tiny graph with a 2-cycle (A↔B) plus an unrelated C → A edge. + + Created with a unique branch so it's isolated from the shared + sample-project fixture. Not torn down (matches the pattern used by + ``indexed_fixture``). + """ + from api.graph import Graph + + project = "impact_cycle_test" + branch = f"cycle-{uuid.uuid4().hex[:8]}" + g = Graph(project, branch=branch) + # CREATE three Function nodes with name + path; add CALLS edges + # A → B, B → A (cycle), C → A. + g.g.query( + """ + CREATE + (a:Function:Searchable {name: 'A', path: '/tmp/cycle.py', src_start: 1}), + (b:Function:Searchable {name: 'B', path: '/tmp/cycle.py', src_start: 2}), + (c:Function:Searchable {name: 'C', path: '/tmp/cycle.py', src_start: 3}), + (a)-[:CALLS]->(b), + (b)-[:CALLS]->(a), + (c)-[:CALLS]->(a) + """ + ) + yield project, branch + + +async def test_impact_handles_cycles(cycle_graph): + """Variable-depth Cypher with DISTINCT must return each node once + even when the graph has a cycle (A↔B). Without DISTINCT a *...* + traversal would loop infinitely or emit duplicates.""" + from api.graph import AsyncGraphQuery + from api.mcp.tools.structural import impact_analysis + + project, branch = cycle_graph + + # Resolve A's id via Cypher (no Searchable index in this throwaway graph) + g = AsyncGraphQuery(project, branch=branch) + try: + res = await g._query("MATCH (n:Function {name: 'A'}) RETURN ID(n)") + a_id = res.result_set[0][0] + finally: + await g.close() + + upstream = await impact_analysis( + symbol_id=a_id, + project=project, + branch=branch, + direction="IN", + depth=5, + ) + names = [r["name"] for r in upstream] + # DISTINCT collapses the A->B->A->B->... cycle into single entries. + # B (direct caller) and C (direct caller) must both be present; + # A may also appear because A is reachable from itself through the + # cycle. The crucial guarantee is no duplicates and no infinite loop. + assert "B" in names and "C" in names + assert len(names) == len(set(names)), f"duplicates in {names}" diff --git a/tests/mcp/test_index_repo.py b/tests/mcp/test_index_repo.py new file mode 100644 index 00000000..ebf88a19 --- /dev/null +++ b/tests/mcp/test_index_repo.py @@ -0,0 +1,80 @@ +"""T4 — ``index_repo`` MCP tool tests.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +async def test_index_repo_local_path(sample_project_path: Path, expected_contract): + """Index a local non-git folder and verify the response shape.""" + from api.mcp.tools.structural import index_repo + + result = await index_repo(str(sample_project_path), branch="t4-local-test") + + assert result["project_name"] == "sample_project" + assert result["branch"] == "t4-local-test" + assert result["graph_name"].startswith("code:sample_project:") + assert result["mode"] == "full" + assert result["num_nodes"] >= sum(expected_contract["counts_min"].values()) + assert result["num_edges"] > 0 + assert "py" in result["languages_detected"] + + +async def test_index_repo_rejects_missing_path(): + """Missing local paths surface as a clear ValueError to the agent.""" + from api.mcp.tools.structural import index_repo + + with pytest.raises(ValueError, match="path does not exist"): + await index_repo("/this/path/definitely/does/not/exist/anywhere") + + +async def test_index_repo_honors_allowed_analysis_dir( + sample_project_path: Path, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + """Sandboxing: paths outside ALLOWED_ANALYSIS_DIR are rejected.""" + from api.mcp.tools import structural + + monkeypatch.setenv("ALLOWED_ANALYSIS_DIR", str(tmp_path)) + + with pytest.raises(ValueError, match="outside ALLOWED_ANALYSIS_DIR"): + await structural.index_repo(str(sample_project_path), branch="t4-sandbox") + + +async def test_index_repo_registered_via_app(): + """The tool is reachable via ``app.list_tools()`` (protocol parity).""" + from api.mcp.server import app + + tools = await app.list_tools() + names = {t.name for t in tools} + assert "index_repo" in names + + tool = next(t for t in tools if t.name == "index_repo") + schema = tool.inputSchema + # Description / param schema are surfaced to the agent. + assert "path_or_url" in schema["properties"] + assert "branch" in schema["properties"] + assert "incremental" in schema["properties"] + + +async def test_index_repo_response_serialises_to_json( + sample_project_path: Path, +): + """MCP transports JSON — the response dict must be JSON-serialisable.""" + from api.mcp.tools.structural import index_repo + + result = await index_repo(str(sample_project_path), branch="t4-json-test") + # Must not raise. + json.dumps(result) diff --git a/tests/mcp/test_init_agent.py b/tests/mcp/test_init_agent.py new file mode 100644 index 00000000..4a14ba71 --- /dev/null +++ b/tests/mcp/test_init_agent.py @@ -0,0 +1,72 @@ +"""Tests for `cgraph init-agent` (T13). + +Verifies the CLI drops the MCP guidance templates into CWD and +respects `--force`. +""" + +import os + +import pytest +from typer.testing import CliRunner + +from api.cli import app + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def _cd(monkeypatch: pytest.MonkeyPatch, path) -> None: + monkeypatch.chdir(path) + + +def test_init_agent_writes_both_files(runner, tmp_path, monkeypatch): + _cd(monkeypatch, tmp_path) + + result = runner.invoke(app, ["init-agent"]) + + assert result.exit_code == 0, result.output + claude = tmp_path / "CLAUDE.md" + cursor = tmp_path / ".cursorrules" + assert claude.exists() + assert cursor.exists() + # Templates should mention at least one core MCP tool. + body = claude.read_text() + cursor.read_text() + assert "search_code" in body + assert "code-graph" in body.lower() + + +def test_init_agent_refuses_overwrite_without_force(runner, tmp_path, monkeypatch): + _cd(monkeypatch, tmp_path) + (tmp_path / "CLAUDE.md").write_text("preexisting\n") + + result = runner.invoke(app, ["init-agent"]) + + assert result.exit_code != 0 + # Existing file untouched. + assert (tmp_path / "CLAUDE.md").read_text() == "preexisting\n" + # And we didn't write the other one either. + assert not (tmp_path / ".cursorrules").exists() + + +def test_init_agent_force_overwrites(runner, tmp_path, monkeypatch): + _cd(monkeypatch, tmp_path) + (tmp_path / "CLAUDE.md").write_text("preexisting\n") + (tmp_path / ".cursorrules").write_text("old rules\n") + + result = runner.invoke(app, ["init-agent", "--force"]) + + assert result.exit_code == 0, result.output + assert "preexisting" not in (tmp_path / "CLAUDE.md").read_text() + assert "old rules" not in (tmp_path / ".cursorrules").read_text() + assert "search_code" in (tmp_path / "CLAUDE.md").read_text() + + +def test_init_agent_templates_ship_with_package(): + """Smoke check: the bundled template files exist on disk where the + CLI expects them. Guards against pyproject.toml drift.""" + from api.cli import _TEMPLATES_DIR + + assert (_TEMPLATES_DIR / "claude_mcp_section.md").is_file() + assert (_TEMPLATES_DIR / "cursorrules.template").is_file() diff --git a/tests/mcp/test_query_tools.py b/tests/mcp/test_query_tools.py new file mode 100644 index 00000000..ca034cf8 --- /dev/null +++ b/tests/mcp/test_query_tools.py @@ -0,0 +1,253 @@ +"""T5/T7/T8 — query MCP tools tests. + +Bundled because all three tools are thin async wrappers around existing +``AsyncGraphQuery`` operations and share the same fixture. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +# --------------------------------------------------------------------------- +# search_code (T8) — runs first because callers/find_path need the ids it +# returns. +# --------------------------------------------------------------------------- + + +async def test_search_code_finds_entrypoint(indexed_fixture, expected_contract): + from api.mcp.tools.structural import search_code + + results = await search_code( + prefix="ent", + project=indexed_fixture.project, + branch=indexed_fixture.branch, + ) + names = {r["name"] for r in results} + for required in expected_contract["search_prefixes"]["ent"]["must_include"]: + assert required in names, f"expected {required} in {names}" + + +async def test_search_code_honors_limit(indexed_fixture): + from api.mcp.tools.structural import search_code + + results = await search_code( + prefix="r", # broad prefix + project=indexed_fixture.project, + branch=indexed_fixture.branch, + limit=1, + ) + assert len(results) <= 1 + + +async def test_search_code_empty_for_nonsense(indexed_fixture): + from api.mcp.tools.structural import search_code + + results = await search_code( + prefix="zzz_no_such_symbol_zzz", + project=indexed_fixture.project, + branch=indexed_fixture.branch, + ) + assert results == [] + + +async def test_search_code_result_serialisable(indexed_fixture): + from api.mcp.tools.structural import search_code + + results = await search_code( + prefix="serv", + project=indexed_fixture.project, + branch=indexed_fixture.branch, + ) + json.dumps(results) # must not raise + + +# --------------------------------------------------------------------------- +# get_callers / get_callees / get_dependencies (T5) +# --------------------------------------------------------------------------- + + +async def _find_id(indexed_fixture, name: str) -> int: + """Helper: resolve a symbol name to its int node id via search_code.""" + from api.mcp.tools.structural import search_code + + rows = await search_code( + prefix=name, + project=indexed_fixture.project, + branch=indexed_fixture.branch, + ) + for r in rows: + if r["name"] == name: + return r["id"] + raise AssertionError(f"symbol {name!r} not found via search_code") + + +async def test_get_callees_of_entrypoint(indexed_fixture, expected_contract): + from api.mcp.tools.structural import get_callees + + entry_id = await _find_id(indexed_fixture, "entrypoint") + callees = await get_callees( + symbol_id=entry_id, + project=indexed_fixture.project, + branch=indexed_fixture.branch, + ) + names = {c["name"] for c in callees} + expected = set(expected_contract["calls"]["entrypoint"]["callees_any_of"]) + assert names & expected, ( + f"entrypoint callees {names} disjoint from expected {expected}" + ) + + for c in callees: + assert c["relation"] == "CALLS" + assert c["direction"] == "OUT" + + +async def test_get_callers_of_service(indexed_fixture, expected_contract): + from api.mcp.tools.structural import get_callers + + service_id = await _find_id(indexed_fixture, "service") + callers = await get_callers( + symbol_id=service_id, + project=indexed_fixture.project, + branch=indexed_fixture.branch, + ) + names = {c["name"] for c in callers} + for required in expected_contract["calls"]["service"]["callers"]: + assert required in names, f"expected caller {required} in {names}" + + for c in callers: + assert c["relation"] == "CALLS" + assert c["direction"] == "IN" + + +async def test_get_dependencies_aggregates_relations(indexed_fixture): + from api.mcp.tools.structural import get_dependencies + + entry_id = await _find_id(indexed_fixture, "entrypoint") + deps = await get_dependencies( + symbol_id=entry_id, + project=indexed_fixture.project, + branch=indexed_fixture.branch, + ) + # Default relations include CALLS, IMPORTS, DEFINES — at minimum the + # CALLS edge to ``service`` must be present. + rels = {d["relation"] for d in deps} + assert "CALLS" in rels + + +async def test_neighbor_tools_accept_string_ids(indexed_fixture): + """Agents sometimes hand back ids as strings — must work.""" + from api.mcp.tools.structural import get_callees + + entry_id = await _find_id(indexed_fixture, "entrypoint") + callees = await get_callees( + symbol_id=str(entry_id), # ← string! + project=indexed_fixture.project, + branch=indexed_fixture.branch, + ) + assert isinstance(callees, list) + + +async def test_neighbor_tools_reject_garbage_ids(indexed_fixture): + from api.mcp.tools.structural import get_callers + + with pytest.raises(ValueError, match="symbol_id"): + await get_callers( + symbol_id="not-a-number", + project=indexed_fixture.project, + branch=indexed_fixture.branch, + ) + + +# --------------------------------------------------------------------------- +# find_path (T7) +# --------------------------------------------------------------------------- + + +async def test_find_path_entrypoint_to_db(indexed_fixture, expected_contract): + from api.mcp.tools.structural import find_path + + entry_id = await _find_id(indexed_fixture, "entrypoint") + db_id = await _find_id(indexed_fixture, "db") + + paths = await find_path( + source_id=entry_id, + dest_id=db_id, + project=indexed_fixture.project, + branch=indexed_fixture.branch, + ) + # The contract requires at least one path entrypoint -> ... -> db + expected_min = next( + p["min_paths"] for p in expected_contract["paths"] + if p["source"] == "entrypoint" and p["dest"] == "db" + ) + assert len(paths) >= expected_min + + # Each path must have entrypoint first, db last, in a non-empty node + # sequence. + for entry in paths: + seq = entry["path"] + assert len(seq) >= 2 + assert seq[0]["name"] == "entrypoint" + assert seq[-1]["name"] == "db" + + +async def test_find_path_no_path_returns_empty(indexed_fixture): + """db -> entrypoint has no CALLS path (graph is acyclic).""" + from api.mcp.tools.structural import find_path + + entry_id = await _find_id(indexed_fixture, "entrypoint") + db_id = await _find_id(indexed_fixture, "db") + + paths = await find_path( + source_id=db_id, + dest_id=entry_id, + project=indexed_fixture.project, + branch=indexed_fixture.branch, + ) + assert paths == [] + + +async def test_find_path_honors_max_paths(indexed_fixture): + from api.mcp.tools.structural import find_path + + entry_id = await _find_id(indexed_fixture, "entrypoint") + db_id = await _find_id(indexed_fixture, "db") + + paths = await find_path( + source_id=entry_id, + dest_id=db_id, + project=indexed_fixture.project, + branch=indexed_fixture.branch, + max_paths=1, + ) + assert len(paths) <= 1 + + +# --------------------------------------------------------------------------- +# Protocol registration +# --------------------------------------------------------------------------- + + +async def test_all_query_tools_registered(): + from api.mcp.server import app + + tools = {t.name for t in await app.list_tools()} + assert { + "search_code", + "get_callers", + "get_callees", + "get_dependencies", + "find_path", + }.issubset(tools) diff --git a/tests/mcp/test_scaffold.py b/tests/mcp/test_scaffold.py new file mode 100644 index 00000000..b02a6780 --- /dev/null +++ b/tests/mcp/test_scaffold.py @@ -0,0 +1,70 @@ +"""Scaffold smoke tests for the cgraph-mcp server (T1). + +These tests prove the bare module is wired correctly: + +1. The FastMCP ``app`` instance is importable. +2. The ``cgraph-mcp`` console script spawns a working stdio MCP server. +3. A client can complete the MCP handshake and ``list_tools`` returns 0 + tools (no tools are registered yet — they land in T4-T8, T11). + +When tool tickets land they should ADD tests, not modify these — these +guard the scaffold itself. +""" + +from __future__ import annotations + +import shutil + +import anyio +import pytest +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +STDIO_TIMEOUT = 10 # seconds — prevents CI from hanging if the server fails to start + + +@pytest.fixture +def anyio_backend() -> str: + """Pin the anyio backend to asyncio so transitive trio installs don't double-run tests.""" + return "asyncio" + + +def test_app_is_importable() -> None: + """The FastMCP instance can be imported and is named ``code-graph``.""" + from api.mcp.server import app + + assert app is not None + assert app.name == "code-graph" + + +def test_main_entry_point_exists() -> None: + """``main()`` is exposed for the console script.""" + from api.mcp import server + + assert callable(server.main) + + +@pytest.mark.anyio +async def test_stdio_server_lists_registered_tools() -> None: + """Spawn ``cgraph-mcp`` over stdio and verify the protocol handshake. + + Once tool tickets land (T4+), ``list_tools`` returns at least the + tools they register. This test only guards the *handshake* — per-tool + behavioural assertions live in the per-tool test modules. + """ + cgraph_mcp = shutil.which("cgraph-mcp") + assert cgraph_mcp is not None, ( + "cgraph-mcp not on PATH; run `uv pip install -e .` first" + ) + + params = StdioServerParameters(command=cgraph_mcp, args=[]) + with anyio.fail_after(STDIO_TIMEOUT): + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.list_tools() + # ``index_repo`` lands in T4; this assertion intentionally + # only checks for presence so it stays stable as more tools + # are registered in T5-T8 / T11. + names = {t.name for t in result.tools} + assert "index_repo" in names diff --git a/tests/test_async_graph.py b/tests/test_async_graph.py index ccd1e000..32e445b1 100644 --- a/tests/test_async_graph.py +++ b/tests/test_async_graph.py @@ -61,14 +61,17 @@ async def test_async_graph_exists_closes_on_error(): async def test_async_get_repos_filters_suffixes(): mock_db = MagicMock() mock_db.list_graphs = AsyncMock( - return_value=["repo1", "repo1_git", "repo1_schema", "repo2"] + return_value=["repo1", "code:repo2:main", "repo1_git", "repo1_schema", "code:repo2:main_git"] ) mock_db.aclose = AsyncMock() with patch("api.graph._async_db", return_value=mock_db): repos = await async_get_repos() - assert repos == ["repo1", "repo2"] + assert repos == [ + {"project": "repo1", "branch": "_default", "graph": "repo1"}, + {"project": "repo2", "branch": "main", "graph": "code:repo2:main"}, + ] mock_db.aclose.assert_awaited_once() diff --git a/tests/test_bench_code_graph_adapter.py b/tests/test_bench_code_graph_adapter.py new file mode 100644 index 00000000..6b5ee4a1 --- /dev/null +++ b/tests/test_bench_code_graph_adapter.py @@ -0,0 +1,136 @@ +"""Tests for the code-graph HTTP adapter using httpx MockTransport.""" + +from __future__ import annotations + +import json + +import httpx +import pytest + +from bench.agents.code_graph_adapter import CodeGraphClient + + +def _mock_handler_factory(responses: dict[str, httpx.Response]): + """Build a MockTransport handler keyed by 'METHOD path'.""" + + def handler(request: httpx.Request) -> httpx.Response: + key = f"{request.method} {request.url.path}" + if key not in responses: + return httpx.Response(404, json={"error": f"no mock for {key}"}) + resp = responses[key] + # capture the body for assertion via the response.extensions + resp.extensions["last_request_body"] = ( + json.loads(request.content) if request.content else None + ) + resp.extensions["last_request_query"] = dict(request.url.params) + return resp + + return handler + + +def _client_with(responses: dict[str, httpx.Response]) -> CodeGraphClient: + transport = httpx.MockTransport(_mock_handler_factory(responses)) + return CodeGraphClient( + base_url="http://test", token="t0k3n", transport=transport + ) + + +def test_graph_entities_sends_repo_query_and_returns_json(): + resp = httpx.Response(200, json={"nodes": [{"id": 1}], "edges": []}) + with _client_with({"GET /api/graph_entities": resp}) as c: + out = c.graph_entities("my-repo") + assert out == {"nodes": [{"id": 1}], "edges": []} + assert resp.extensions["last_request_query"] == {"repo": "my-repo"} + + +def test_get_neighbors_posts_json_body(): + resp = httpx.Response(200, json={"nodes": [], "edges": []}) + with _client_with({"POST /api/get_neighbors": resp}) as c: + c.get_neighbors("r", [1, 2, 3]) + assert resp.extensions["last_request_body"] == {"repo": "r", "node_ids": [1, 2, 3]} + + +def test_find_paths_posts_src_dest(): + resp = httpx.Response(200, json={"paths": []}) + with _client_with({"POST /api/find_paths": resp}) as c: + c.find_paths("r", 7, 42) + assert resp.extensions["last_request_body"] == {"repo": "r", "src": 7, "dest": 42} + + +def test_auto_complete_posts_prefix(): + resp = httpx.Response(200, json={"completions": [{"name": "Foo"}, {"name": "FooBar"}]}) + with _client_with({"POST /api/auto_complete": resp}) as c: + out = c.auto_complete("r", "Foo") + assert resp.extensions["last_request_body"] == {"repo": "r", "prefix": "Foo"} + assert "completions" in out + + +def test_find_symbol_filters_for_exact_match(): + resp = httpx.Response( + 200, + json={"completions": [ + {"name": "Foo", "id": 1}, + {"name": "FooBar", "id": 2}, + {"name": "Foo", "id": 3}, + ]}, + ) + with _client_with({"POST /api/auto_complete": resp}) as c: + out = c.find_symbol("r", "Foo") + assert len(out) == 2 + assert all(item["name"] == "Foo" for item in out) + + +def test_find_symbol_reads_nested_properties_name(): + # Regression: the real /api/auto_complete payload nests `name` under + # `properties` (FalkorDB node properties). Before this fix every + # exact-name lookup returned [], so the agent fell back to bash grep. + resp = httpx.Response( + 200, + json={"completions": [ + {"id": 1, "labels": ["Function"], + "properties": {"name": "FooBar", "path": "/a.py"}}, + {"id": 2, "labels": ["Function"], + "properties": {"name": "Foo", "path": "/b.py"}}, + {"id": 3, "labels": ["Function"], + "properties": {"name": "Foo", "path": "/c.py"}}, + ]}, + ) + with _client_with({"POST /api/auto_complete": resp}) as c: + out = c.find_symbol("r", "Foo") + assert [item["id"] for item in out] == [2, 3] + + +def test_note_edit_calls_analyze_folder_and_reports_path(): + resp = httpx.Response(200, json={"status": "ok"}) + with _client_with({"POST /api/analyze_folder": resp}) as c: + out = c.note_edit("r", "/work/repo/x/y.py") + assert out == {"ok": True, "reindexed": "/work/repo/x/y.py"} + assert resp.extensions["last_request_body"]["path"] == "/work/repo/x" # os.path.dirname of /work/repo/x/y.py + + +def test_note_edit_returns_error_dict_on_http_failure(): + resp = httpx.Response(500, json={"error": "boom"}) + with _client_with({"POST /api/analyze_folder": resp}) as c: + out = c.note_edit("r", "/work/repo/x.py") + assert out["ok"] is False + assert "error" in out + + +def test_client_sends_bearer_token(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["auth"] = request.headers.get("authorization") + return httpx.Response(200, json={}) + + transport = httpx.MockTransport(handler) + with CodeGraphClient(base_url="http://test", token="secret", transport=transport) as c: + c.graph_entities("r") + assert captured["auth"] == "Bearer secret" + + +def test_4xx_raises(): + resp = httpx.Response(401, json={"detail": "Unauthorized"}) + with _client_with({"GET /api/graph_entities": resp}) as c: + with pytest.raises(httpx.HTTPStatusError): + c.graph_entities("r") diff --git a/tests/test_bench_index_cache.py b/tests/test_bench_index_cache.py new file mode 100644 index 00000000..ea5e2e55 --- /dev/null +++ b/tests/test_bench_index_cache.py @@ -0,0 +1,58 @@ +"""Tests for bench/runners/index_cache.""" + +from __future__ import annotations + +from pathlib import Path + +from bench.runners.index_cache import IndexCache + + +def test_empty_cache_returns_none(tmp_path: Path): + c = IndexCache(tmp_path) + assert c.has("django", "abc123") is False + assert c.get("django", "abc123") is None + assert c.all() == [] + + +def test_record_and_lookup(tmp_path: Path): + c = IndexCache(tmp_path) + e = c.record("django", "abc123", "/work/django") + assert e.repo == "django" + assert e.source_path == "/work/django" + assert c.has("django", "abc123") is True + got = c.get("django", "abc123") + assert got is not None + assert got.commit == "abc123" + + +def test_persists_across_instances(tmp_path: Path): + c1 = IndexCache(tmp_path) + c1.record("flask", "def456", "/work/flask") + c2 = IndexCache(tmp_path) + assert c2.has("flask", "def456") is True + + +def test_forget(tmp_path: Path): + c = IndexCache(tmp_path) + c.record("sympy", "111", "/work/sympy") + assert c.forget("sympy", "111") is True + assert c.has("sympy", "111") is False + assert c.forget("sympy", "111") is False # second forget is a no-op + + +def test_all_returns_all_entries(tmp_path: Path): + c = IndexCache(tmp_path) + c.record("a", "1", "/p/a") + c.record("b", "2", "/p/b") + c.record("c", "3", "/p/c") + assert {e.repo for e in c.all()} == {"a", "b", "c"} + + +def test_record_overwrites_same_key(tmp_path: Path): + c = IndexCache(tmp_path) + c.record("a", "1", "/p/a") + c.record("a", "1", "/p/a-new") + e = c.get("a", "1") + assert e is not None + assert e.source_path == "/p/a-new" + assert len(c.all()) == 1 diff --git a/tests/test_bench_lsp_adapter.py b/tests/test_bench_lsp_adapter.py new file mode 100644 index 00000000..4b55d864 --- /dev/null +++ b/tests/test_bench_lsp_adapter.py @@ -0,0 +1,135 @@ +"""Tests for the LSP adapter — shim logic + a real jedi roundtrip. + +The shim helpers are pure functions tested directly. The actual LSP +roundtrip test starts jedi-language-server against a tiny in-tree fixture +and asserts goto_definition + hover behave end-to-end. That test is +marked `slow` so CI can skip it when LSP installs are unavailable. +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest + +from bench.agents.lsp_adapter import ( + DEFAULT_SHIM, + LSPClient, + LSPShim, + _cap, + _location_to_dict, + _trim_hover, +) + + +# --------------------------------------------------------------------------- +# Shim unit tests (no LSP server needed) +# --------------------------------------------------------------------------- + +def test_trim_hover_keeps_one_signature_and_one_sentence(): + raw = textwrap.dedent(""" + def greet(name: str) -> str + Return a greeting for the given name. This part is extra. Even more text. + """).strip() + out = _trim_hover(raw, DEFAULT_SHIM) + assert "def greet" in out + assert "Return a greeting for the given name." in out + assert "extra" not in out + + +def test_trim_hover_strips_code_fences(): + raw = "```python\ndef foo()\n```\nShort docstring." + out = _trim_hover(raw, DEFAULT_SHIM) + assert "```" not in out + assert "def foo()" in out + + +def test_trim_hover_handles_dict_and_list_contents(): + assert "x" in _trim_hover({"value": "x"}, DEFAULT_SHIM) + assert "a" in _trim_hover([{"value": "a"}, "b"], DEFAULT_SHIM) + assert _trim_hover(None, DEFAULT_SHIM) == "" + + +def test_location_to_dict_uses_relative_path(): + loc = { + "relativePath": "src/a.py", + "absolutePath": "/abs/src/a.py", + "range": {"start": {"line": 10, "character": 4}, "end": {"line": 10, "character": 8}}, + } + assert _location_to_dict(loc) == {"path": "src/a.py", "line": 10, "col": 4} + + +def test_location_to_dict_falls_back_to_absolute_when_no_relative(): + loc = {"absolutePath": "/abs/x.py", "range": {"start": {"line": 0, "character": 0}}} + assert _location_to_dict(loc)["path"] == "/abs/x.py" + + +def test_cap_limits_results(): + items = list(range(200)) + assert len(_cap(items, LSPShim(max_results_per_call=10))) == 10 + + +def test_shim_dataclass_defaults_match_yaml(): + # If you change shim.yaml, change DEFAULT_SHIM too. + assert DEFAULT_SHIM.max_results_per_call == 50 + assert DEFAULT_SHIM.hover_signature_lines == 1 + assert DEFAULT_SHIM.hover_docstring_sentences == 1 + + +# --------------------------------------------------------------------------- +# LSP roundtrip — slow, runs real jedi subprocess +# --------------------------------------------------------------------------- + + +@pytest.fixture +def python_repo(tmp_path: Path) -> Path: + (tmp_path / "sample.py").write_text(textwrap.dedent('''\ + def greet(name): + """Return a greeting for the given name.""" + return f"hello {name}" + + + def main(): + who = "world" + return greet(who) + ''') + ) + return tmp_path + + +@pytest.mark.slow +def test_real_jedi_goto_definition_finds_greet(python_repo: Path): + """End-to-end against jedi-language-server. + + Skipped on `pytest -m 'not slow'`. Heavy: spawns the LSP subprocess + and downloads jedi on first run. + """ + client = LSPClient(repo_root=python_repo, language="python") + with client.server_running(): + # In sample.py, `greet(who)` is called on line 7 (0-indexed). The + # call site sits at "return greet(who)" — `greet` starts around col 11. + defs = client.goto_definition("sample.py", line=7, col=11) + # We expect at least one definition pointing back to sample.py line 0 + # (where `def greet` lives). + assert any(d["path"].endswith("sample.py") and d["line"] == 0 for d in defs), defs + + +@pytest.mark.slow +def test_real_jedi_hover_is_shimmed(python_repo: Path): + client = LSPClient(repo_root=python_repo, language="python") + with client.server_running(): + h = client.hover("sample.py", line=7, col=11) + text = h["text"] + assert "greet" in text or text == "" # jedi can sometimes return empty hover + # No more than ~3 lines of output after shimming (signature + 1 sentence). + assert text.count("\n") <= 3 + + +@pytest.mark.slow +def test_real_jedi_document_symbols_lists_top_level_funcs(python_repo: Path): + client = LSPClient(repo_root=python_repo, language="python") + with client.server_running(): + syms = client.document_symbols("sample.py") + names = {s["name"] for s in syms} + assert {"greet", "main"}.issubset(names) diff --git a/tests/test_bench_metrics.py b/tests/test_bench_metrics.py new file mode 100644 index 00000000..f34fd967 --- /dev/null +++ b/tests/test_bench_metrics.py @@ -0,0 +1,165 @@ +"""Tests for bench/metrics and bench/report — pure-function units.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from bench.metrics import ( + TaskMetrics, + extract_patch, + extract_token_usage, + extract_tool_calls, + task_metrics_from_trajectory, + write_jsonl, +) +from bench.report import aggregate_to_markdown, render_markdown, summarize + + +_TRAJ_FIXTURE = { + "history": [ + { + "action": {"command": "read_file"}, + "usage": {"prompt_tokens": 1200, "completion_tokens": 80}, + }, + { + "action": {"command": "graph_entities"}, + "usage": {"prompt_tokens": 800, "completion_tokens": 40}, + }, + { + "action": {"command": "read_file"}, + "usage": {"prompt_tokens": 1500, "completion_tokens": 90}, + }, + { + "action": {"command": "submit"}, + "usage": {"prompt_tokens": 200, "completion_tokens": 30}, + }, + ], + "model_patch": "diff --git a/x b/x\n--- a/x\n+++ b/x\n@@\n-foo\n+bar\n", +} + + +def test_extract_token_usage_sums_history(): + tin, tout = extract_token_usage(_TRAJ_FIXTURE) + assert tin == 1200 + 800 + 1500 + 200 + assert tout == 80 + 40 + 90 + 30 + + +def test_extract_token_usage_falls_back_to_summary(): + traj = {"total_usage": {"input_tokens": 5000, "output_tokens": 300}} + tin, tout = extract_token_usage(traj) + assert (tin, tout) == (5000, 300) + + +def test_extract_tool_calls_buckets_by_name(): + total, by_name = extract_tool_calls(_TRAJ_FIXTURE) + assert total == 4 + assert by_name == {"read_file": 2, "graph_entities": 1, "submit": 1} + + +def test_extract_tool_calls_supports_openai_shape(): + traj = { + "history": [ + {"tool_calls": [{"function": {"name": "find_references"}}]}, + {"tool_calls": [{"function": {"name": "find_references"}}]}, + {"tool_calls": [{"function": {"name": "hover"}}]}, + ] + } + total, by_name = extract_tool_calls(traj) + assert total == 3 + assert by_name == {"find_references": 2, "hover": 1} + + +def test_extract_patch(): + assert extract_patch(_TRAJ_FIXTURE).startswith("diff --git") + assert extract_patch({}) is None + + +def test_task_metrics_from_trajectory_round_trip(): + m = task_metrics_from_trajectory( + _TRAJ_FIXTURE, + benchmark="swe_bench_verified", + task_id="django__django-1", + config="code_graph", + ) + assert m.task_id == "django__django-1" + assert m.config == "code_graph" + assert m.input_tokens == 3700 + assert m.output_tokens == 240 + assert m.tool_calls_total == 4 + d = m.to_dict() + assert json.loads(json.dumps(d))["task_id"] == "django__django-1" + + +def _row(config: str, tokens: int, resolved: bool, task_id: str = "t1") -> dict: + return { + "benchmark": "swe_bench_verified", + "task_id": task_id, + "config": config, + "run_idx": 0, + "input_tokens": tokens // 2, + "output_tokens": tokens // 2, + "tool_calls_total": 1, + "tool_calls_by_name": {}, + "outcome": "resolved" if resolved else "failed", + "patch": "diff" if resolved else None, + "wall_clock_sec": None, + } + + +def test_summarize_resolve_rate_and_medians(): + rows = [ + _row("baseline", 1000, True, "t1"), + _row("baseline", 2000, False, "t2"), + _row("baseline", 3000, True, "t3"), + _row("code_graph", 800, True, "t1"), + _row("code_graph", 900, True, "t2"), + _row("code_graph", 1100, False, "t3"), + ] + summaries = summarize(rows) + s = {s.config: s for s in summaries} + + assert s["baseline"].n_tasks == 3 + assert s["baseline"].n_resolved == 2 + assert pytest.approx(s["baseline"].resolve_rate) == 2 / 3 + assert s["baseline"].median_tokens == 2000 + + assert s["code_graph"].n_resolved == 2 + assert s["code_graph"].median_tokens == 900 + + +def test_summarize_prefers_resolved_run_for_retries(): + rows = [ + {**_row("lsp", 500, False, "t1"), "run_idx": 0}, + {**_row("lsp", 700, True, "t1"), "run_idx": 1}, + ] + s = summarize(rows)[0] + assert s.n_resolved == 1 + assert s.median_tokens == 700 + + +def test_render_markdown_includes_delta(): + rows = [ + _row("baseline", 2000, True, "t1"), + _row("baseline", 4000, True, "t2"), + _row("code_graph", 1000, True, "t1"), + _row("code_graph", 1200, True, "t2"), + ] + md = render_markdown(summarize(rows)) + assert "baseline" in md + assert "code_graph" in md + assert "Δ tokens vs baseline" in md + # code_graph median (1100) vs baseline median (3000) → ~-63% + assert "-63.3%" in md + + +def test_aggregate_to_markdown_writes_file(tmp_path: Path): + rows = [_row("baseline", 1000, True), _row("lsp", 800, True)] + jsonl = tmp_path / "results.jsonl" + write_jsonl(jsonl, [TaskMetrics(**r) for r in rows]) + out = tmp_path / "results.md" + aggregate_to_markdown(jsonl, out) + assert out.exists() + assert "lsp" in out.read_text() diff --git a/tests/test_bench_runner.py b/tests/test_bench_runner.py new file mode 100644 index 00000000..bad65684 --- /dev/null +++ b/tests/test_bench_runner.py @@ -0,0 +1,150 @@ +"""Tests for `bench.runners.mini_runner` and the bash-tool CLI shims. + +These tests run **without** an LLM API key. They use the runner's +`--dry-run` mode (stub model) to validate: + +- the full mini-swe-agent loop executes cleanly, +- the trajectory is captured and persisted, +- the metrics module extracts tokens + tool calls from the + mini-swe-agent trajectory shape, +- the per-config env wiring (PATH for lsp/code_graph, baseline + PATH untouched) reaches the bash subprocess. + +We deliberately keep these tests off the network and off any LLM. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from bench.runners import mini_runner + + +def _make_repo(tmp_path: Path) -> mini_runner.Task: + repo = tmp_path / "repo" + return mini_runner._make_dry_run_task(repo) + + +# --------------------------------------------------------------------------- +# run_task +# --------------------------------------------------------------------------- + + +def test_dry_run_task_baseline_completes(tmp_path: Path) -> None: + task = _make_repo(tmp_path) + res = mini_runner.run_task(task, "baseline", dry_run=True, step_limit=2) + assert res["exit_status"] == "ok" + m = res["metrics"] + assert m.benchmark == "dry_run" + assert m.config == "baseline" + assert m.input_tokens > 0 + assert m.output_tokens > 0 + assert m.tool_calls_total == 1 + # The stub submits via `printf ...COMPLETE_TASK...` so the bucket name + # is `submit`, not the raw printf. + assert "submit" in m.tool_calls_by_name + + +def test_dry_run_task_lsp_sets_repo_root(tmp_path: Path) -> None: + task = _make_repo(tmp_path) + res = mini_runner.run_task(task, "lsp", dry_run=True, step_limit=2) + assert res["exit_status"] == "ok" + # Trajectory must contain a bash output line with the cli dir on PATH. + text = json.dumps(res["trajectory"]) + assert "bench/cli" in text + + +def test_dry_run_unknown_config_raises(tmp_path: Path) -> None: + task = _make_repo(tmp_path) + with pytest.raises(ValueError, match="unknown config"): + mini_runner.run_task(task, "not_a_config", dry_run=True) + + +# --------------------------------------------------------------------------- +# config_env +# --------------------------------------------------------------------------- + + +def test_config_env_baseline_does_not_add_cli_dir(tmp_path: Path) -> None: + env = mini_runner.config_env("baseline", tmp_path) + assert str(mini_runner.CLI_DIR) not in env["PATH"] + + +def test_config_env_lsp_prepends_cli_dir_and_sets_repo_root(tmp_path: Path) -> None: + env = mini_runner.config_env("lsp", tmp_path) + assert env["PATH"].startswith(str(mini_runner.CLI_DIR)) + assert env["LSP_REPO_ROOT"] == str(tmp_path) + assert env["LSP_LANGUAGE"] == "python" + + +def test_config_env_code_graph_prepends_cli_dir_and_sets_url(tmp_path: Path) -> None: + env = mini_runner.config_env("code_graph", tmp_path) + assert env["PATH"].startswith(str(mini_runner.CLI_DIR)) + assert "CODEGRAPH_URL" in env + + +# --------------------------------------------------------------------------- +# run_batch persistence +# --------------------------------------------------------------------------- + + +def test_run_batch_writes_results_jsonl_and_trajectories(tmp_path: Path) -> None: + task = _make_repo(tmp_path) + results = tmp_path / "results.jsonl" + trajs = tmp_path / "trajs" + rows = mini_runner.run_batch( + [task], + ["baseline", "lsp"], + results_path=results, + trajectories_dir=trajs, + dry_run=True, + step_limit=2, + ) + assert len(rows) == 2 + assert results.exists() + lines = results.read_text().strip().splitlines() + assert len(lines) == 2 + for line in lines: + row = json.loads(line) + assert row["benchmark"] == "dry_run" + assert row["task_id"] == "dry-run-1" + # Trajectories on disk, one per (task, config). + assert (trajs / "dry-run-1__baseline.json").exists() + assert (trajs / "dry-run-1__lsp.json").exists() + + +# --------------------------------------------------------------------------- +# CLI smoke tests (argparse only — no service contact) +# --------------------------------------------------------------------------- + + +def test_cg_cli_help_exits_zero() -> None: + out = subprocess.run( + ["uv", "run", "python", "-m", "bench.cli.cg", "--help"], + capture_output=True, text=True, cwd=mini_runner.REPO_ROOT, check=False, + ) + assert out.returncode == 0 + assert "graph-entities" in out.stdout + assert "find-paths" in out.stdout + + +def test_lsp_cli_help_exits_zero() -> None: + out = subprocess.run( + ["uv", "run", "python", "-m", "bench.cli.lsp", "--help"], + capture_output=True, text=True, cwd=mini_runner.REPO_ROOT, check=False, + ) + assert out.returncode == 0 + assert "goto-definition" in out.stdout + assert "document-symbols" in out.stdout + + +def test_cg_cli_rejects_unknown_subcommand() -> None: + out = subprocess.run( + ["uv", "run", "python", "-m", "bench.cli.cg", "nope"], + capture_output=True, text=True, cwd=mini_runner.REPO_ROOT, check=False, + ) + assert out.returncode != 0 diff --git a/tests/test_cli.py b/tests/test_cli.py index 9b7f2eeb..6087f257 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -88,7 +88,9 @@ def test_list_includes_repo(self): result = runner.invoke(app, ["list"]) self.assertEqual(result.exit_code, 0) data = _parse_json(result.output) - self.assertIn(self.REPO_NAME, data["repos"]) + # T17: repos are now {project, branch, graph} dicts. + projects = [r["project"] for r in data["repos"]] + self.assertIn(self.REPO_NAME, projects) class TestCLIHelp(unittest.TestCase): diff --git a/tests/test_per_branch_graphs.py b/tests/test_per_branch_graphs.py new file mode 100644 index 00000000..5c738b5d --- /dev/null +++ b/tests/test_per_branch_graphs.py @@ -0,0 +1,256 @@ +"""Unit tests for the per-branch graph identity refactor (T17, issue #651). + +Exercises only the name-composition / parsing helpers and the in-memory +behaviour of the ``Graph`` / ``AsyncGraphQuery`` constructors plus the +``run_migration`` orchestration. Anything that would require a live +FalkorDB / Redis instance is mocked. +""" + +from unittest.mock import MagicMock, AsyncMock, patch + +import pytest + +from api.graph import ( + DEFAULT_BRANCH, + Graph, + AsyncGraphQuery, + compose_graph_name, + parse_graph_name, + async_get_repos, +) +from api.info import _repo_info_key, _legacy_repo_info_key, _normalize_branch +from api.git_utils.git_utils import GitRepoName, LegacyGitRepoName + + +# --------------------------------------------------------------------------- +# compose_graph_name / parse_graph_name +# --------------------------------------------------------------------------- + +def test_compose_graph_name_default_branch(): + assert compose_graph_name("falkordb") == f"code:falkordb:{DEFAULT_BRANCH}" + + +def test_compose_graph_name_explicit_branch(): + assert compose_graph_name("falkordb", "main") == "code:falkordb:main" + + +def test_compose_graph_name_empty_branch_falls_back_to_default(): + assert compose_graph_name("foo", "") == f"code:foo:{DEFAULT_BRANCH}" + + +def test_compose_graph_name_branch_with_slashes(): + # Real-world branches like `dvir/feature` must survive composition. + assert compose_graph_name("foo", "dvir/feature") == "code:foo:dvir/feature" + + +def test_parse_graph_name_round_trip(): + name = compose_graph_name("falkordb", "main") + assert parse_graph_name(name) == ("falkordb", "main") + + +def test_parse_graph_name_legacy_returns_none(): + assert parse_graph_name("legacy_bare_name") is None + + +def test_parse_graph_name_handles_branch_with_colons(): + # Branch names cannot contain ':' in git, so we don't try to support it. + # But branch may contain other unusual characters: the regex is + # ``code:{project}:(.+)`` so anything after the second colon is the branch. + assert parse_graph_name("code:repo:feature/x") == ("repo", "feature/x") + + +# --------------------------------------------------------------------------- +# Graph constructor branch-awareness +# --------------------------------------------------------------------------- + +def _patched_falkordb(): + """Return a MagicMock that replaces ``FalkorDB`` in ``api.graph``.""" + mock_db = MagicMock() + mock_db.select_graph = MagicMock(return_value=MagicMock()) + return patch("api.graph.FalkorDB", return_value=mock_db) + + +def test_graph_default_branch_composes_name(): + with _patched_falkordb(): + g = Graph("foo") + assert g.project == "foo" + assert g.branch == DEFAULT_BRANCH + assert g.name == f"code:foo:{DEFAULT_BRANCH}" + + +def test_graph_explicit_branch_composes_name(): + with _patched_falkordb(): + g = Graph("foo", branch="main") + assert g.project == "foo" + assert g.branch == "main" + assert g.name == "code:foo:main" + + +def test_graph_accepts_already_composed_name(): + with _patched_falkordb(): + g = Graph("code:bar:dev") + assert g.project == "bar" + assert g.branch == "dev" + assert g.name == "code:bar:dev" + + +def test_graph_two_branches_produce_distinct_names(): + with _patched_falkordb(): + a = Graph("foo", branch="main") + b = Graph("foo", branch="feat") + assert a.name != b.name + assert a.project == b.project == "foo" + + +def test_graph_from_raw_name_bypasses_composition(): + with _patched_falkordb(): + g = Graph.from_raw_name("code:foo:main_tmp") + # raw name preserved exactly, even though it isn't a well-formed + # ``code:project:branch`` graph. + assert g.name == "code:foo:main_tmp" + + +# --------------------------------------------------------------------------- +# AsyncGraphQuery constructor branch-awareness +# --------------------------------------------------------------------------- + +def test_async_graph_query_default_branch(): + with patch("api.graph._async_db") as mock_db: + mock_db.return_value = MagicMock() + g = AsyncGraphQuery("foo") + assert g.project == "foo" + assert g.branch == DEFAULT_BRANCH + assert g.name == f"code:foo:{DEFAULT_BRANCH}" + + +def test_async_graph_query_branch_param(): + with patch("api.graph._async_db") as mock_db: + mock_db.return_value = MagicMock() + g = AsyncGraphQuery("foo", branch="develop") + assert g.name == "code:foo:develop" + + +# --------------------------------------------------------------------------- +# async_get_repos returns (project, branch, graph) dicts +# --------------------------------------------------------------------------- + +@pytest.mark.anyio +async def test_async_get_repos_mixes_legacy_and_new(): + mock_db = MagicMock() + mock_db.list_graphs = AsyncMock( + return_value=[ + "legacy_proj", # legacy: no `code:` prefix + "code:newproj:main", # new style + "code:newproj:feat", # same project, different branch + "code:newproj:main_git", # internal suffix, must be filtered + "code:newproj:main_schema", # internal suffix, must be filtered + ] + ) + mock_db.aclose = AsyncMock() + + with patch("api.graph._async_db", return_value=mock_db): + repos = await async_get_repos() + + assert repos == [ + {"project": "legacy_proj", "branch": DEFAULT_BRANCH, "graph": "legacy_proj"}, + {"project": "newproj", "branch": "main", "graph": "code:newproj:main"}, + {"project": "newproj", "branch": "feat", "graph": "code:newproj:feat"}, + ] + + +# --------------------------------------------------------------------------- +# Redis info key composition (api.info) +# --------------------------------------------------------------------------- + +def test_repo_info_key_default_branch(): + assert _repo_info_key("falkordb") == "{falkordb}:_default_info" + + +def test_repo_info_key_explicit_branch(): + assert _repo_info_key("falkordb", "main") == "{falkordb}:main_info" + + +def test_legacy_repo_info_key_for_fallback(): + assert _legacy_repo_info_key("falkordb") == "{falkordb}_info" + + +def test_normalize_branch_handles_none_and_empty(): + assert _normalize_branch(None) == DEFAULT_BRANCH + assert _normalize_branch("") == DEFAULT_BRANCH + assert _normalize_branch("main") == "main" + + +# --------------------------------------------------------------------------- +# Git graph naming (api.git_utils.git_utils) +# --------------------------------------------------------------------------- + +def test_git_repo_name_default_branch(): + assert GitRepoName("repo") == "{repo}:_default_git" + + +def test_git_repo_name_explicit_branch(): + assert GitRepoName("repo", "main") == "{repo}:main_git" + + +def test_legacy_git_repo_name_for_fallback(): + assert LegacyGitRepoName("repo") == "{repo}_git" + + +# --------------------------------------------------------------------------- +# Migration helper (api.migrations.per_branch) +# --------------------------------------------------------------------------- + +def test_migration_dry_run_reports_actions_without_renaming(): + from api.migrations import per_branch + + fake_conn = MagicMock() + fake_conn.exists = MagicMock(return_value=0) + fake_graph = MagicMock() + + mock_db = MagicMock() + mock_db.connection = fake_conn + mock_db.list_graphs = MagicMock( + return_value=["legacy_proj", "{legacy_proj}_git", "code:already:main"] + ) + mock_db.select_graph = MagicMock(return_value=fake_graph) + + with patch.object(per_branch, "_connect", return_value=mock_db): + result = per_branch.run_migration(dry_run=True) + + # Dry-run must never copy or delete any graph. + fake_graph.copy.assert_not_called() + fake_graph.delete.assert_not_called() + fake_conn.rename.assert_not_called() + + # ``code:already:main`` is already migrated and must not be reported. + assert result["dry_run"] is True + assert result["graphs_renamed"] == 1 # legacy_proj + # the {legacy_proj}_git companion graph was also planned, but only when + # info-key existed; the mock returns exists=0 so info_renamed stays 0. + assert result["git_renamed"] == 1 + + +def test_migration_is_idempotent_when_no_legacy_graphs(): + from api.migrations import per_branch + + fake_conn = MagicMock() + fake_conn.exists = MagicMock(return_value=0) + fake_graph = MagicMock() + + mock_db = MagicMock() + mock_db.connection = fake_conn + # Already-migrated layout: only new-style names present. + mock_db.list_graphs = MagicMock( + return_value=["code:proj:_default", "{proj}:_default_git"] + ) + mock_db.select_graph = MagicMock(return_value=fake_graph) + + with patch.object(per_branch, "_connect", return_value=mock_db): + result = per_branch.run_migration(dry_run=False) + + fake_graph.copy.assert_not_called() + fake_graph.delete.assert_not_called() + fake_conn.rename.assert_not_called() + assert result["graphs_renamed"] == 0 + assert result["info_renamed"] == 0 + assert result["git_renamed"] == 0 diff --git a/tests/test_swe_bench_loader.py b/tests/test_swe_bench_loader.py new file mode 100644 index 00000000..2ec6986e --- /dev/null +++ b/tests/test_swe_bench_loader.py @@ -0,0 +1,63 @@ +"""Unit tests for bench.datasets.swe_bench (no network).""" + +from __future__ import annotations + +import pytest + +from bench.datasets import swe_bench as sb + + +def _fake_inst(instance_id: str, repo: str = "x/y") -> sb.SweBenchInstance: + return sb.SweBenchInstance( + instance_id=instance_id, + repo=repo, + base_commit="deadbeef" * 5, + problem_statement="fix the thing", + test_patch="", + fail_to_pass=["t::a"], + pass_to_pass=["t::b"], + environment_setup_commit="", + version="0", + ) + + +def test_parse_list_field_handles_json_string_and_list(): + assert sb._parse_list_field('["a", "b"]') == ["a", "b"] + assert sb._parse_list_field(["a", "b"]) == ["a", "b"] + with pytest.raises(TypeError): + sb._parse_list_field(42) + + +def test_sample_instances_is_deterministic(): + pool = [_fake_inst(f"id-{i}") for i in range(50)] + a = sb.sample_instances(pool, stage="smoke") + b = sb.sample_instances(pool, stage="smoke") + assert [i.instance_id for i in a] == [i.instance_id for i in b] + assert len(a) == sb.STAGE_SIZES["smoke"] + + +def test_sample_instances_respects_explicit_n(): + pool = [_fake_inst(f"id-{i}") for i in range(50)] + out = sb.sample_instances(pool, stage="smoke", n=7) + assert len(out) == 7 + + +def test_sample_instances_clamps_to_pool_size(): + pool = [_fake_inst(f"id-{i}") for i in range(2)] + out = sb.sample_instances(pool, stage="headline") # asks for 37 + assert len(out) == 2 + + +def test_repo_cache_path_uses_safe_delimiter(tmp_path): + p = sb._repo_cache_path("astropy/astropy", tmp_path) + assert p == tmp_path / "astropy__astropy" + + +def test_instance_to_task_maps_fields(tmp_path): + inst = _fake_inst("foo") + task = sb.instance_to_task(inst, tmp_path) + assert task.task_id == "foo" + assert task.repo_name == "x/y" + assert task.repo_path == tmp_path + assert task.problem_statement == "fix the thing" + assert task.verify_cmd is None diff --git a/tests/test_swebench_verify.py b/tests/test_swebench_verify.py new file mode 100644 index 00000000..355ff3b3 --- /dev/null +++ b/tests/test_swebench_verify.py @@ -0,0 +1,84 @@ +"""Unit tests for the SWE-bench Docker verification adapter (no Docker).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from bench.runners import swebench_verify as sv + + +def _write_results(path: Path, rows: list[dict]) -> None: + with path.open("w") as f: + for r in rows: + f.write(json.dumps(r) + "\n") + + +def test_export_predictions_filters_by_benchmark_and_patch(tmp_path: Path): + results = tmp_path / "results.jsonl" + _write_results(results, [ + # swe-bench rows with patches → exported + {"benchmark": "swe_bench_verified", "config": "baseline", + "task_id": "django__django-1", "patch": "diff a"}, + {"benchmark": "swe_bench_verified", "config": "lsp", + "task_id": "django__django-1", "patch": "diff b"}, + # missing patch → skipped + {"benchmark": "swe_bench_verified", "config": "baseline", + "task_id": "django__django-2", "patch": ""}, + # wrong benchmark → skipped + {"benchmark": "synthetic_smoke", "config": "baseline", + "task_id": "smoke-1", "patch": "diff c"}, + ]) + out_dir = tmp_path / "preds" + paths = sv.export_predictions(results, out_dir) + assert set(paths) == {"baseline", "lsp"} + + baseline = [json.loads(l) for l in paths["baseline"].read_text().splitlines()] + assert len(baseline) == 1 + row = baseline[0] + assert row["instance_id"] == "django__django-1" + assert row["model_name_or_path"] == "code-graph-bench-baseline" + assert row["model_patch"] == "diff a" + + +def test_export_predictions_returns_empty_when_no_matching_rows(tmp_path: Path): + results = tmp_path / "results.jsonl" + _write_results(results, [ + {"benchmark": "synthetic_smoke", "config": "baseline", + "task_id": "smoke-1", "patch": "diff"} + ]) + paths = sv.export_predictions(results, tmp_path / "preds") + assert paths == {} + + +def test_patch_outcomes_from_report(tmp_path: Path): + results = tmp_path / "results.jsonl" + _write_results(results, [ + {"benchmark": "swe_bench_verified", "config": "baseline", + "task_id": "x-1", "patch": "p", "outcome": None}, + {"benchmark": "swe_bench_verified", "config": "baseline", + "task_id": "x-2", "patch": "p", "outcome": None}, + {"benchmark": "swe_bench_verified", "config": "lsp", + "task_id": "x-1", "patch": "p", "outcome": None}, + ]) + report = tmp_path / "r.json" + report.write_text(json.dumps({ + "resolved_ids": ["x-1"], "unresolved_ids": ["x-2"], + })) + + n = sv.patch_outcomes_from_report(report, results, config="baseline") + assert n == 2 + + rows = [json.loads(l) for l in results.read_text().splitlines()] + assert rows[0]["outcome"] == "resolved" + assert rows[1]["outcome"] == "failed" + # lsp row untouched + assert rows[2]["outcome"] is None + + +def test_run_harness_raises_when_docker_missing(tmp_path: Path, monkeypatch): + monkeypatch.setattr(sv.shutil, "which", lambda _: None) + with pytest.raises(RuntimeError, match="docker not found"): + sv.run_harness(tmp_path / "preds.jsonl", run_id="x") diff --git a/uv.lock b/uv.lock index 42d272fd..dff8efbb 100644 --- a/uv.lock +++ b/uv.lock @@ -1,14 +1,19 @@ version = 1 revision = 3 requires-python = ">=3.12, <3.14" +resolution-markers = [ + "sys_platform == 'win32'", + "sys_platform == 'emscripten'", + "sys_platform != 'emscripten' and sys_platform != 'win32'", +] [[package]] name = "aiohappyeyeballs" -version = "2.6.1" +version = "2.6.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, + { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, ] [[package]] @@ -115,15 +120,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] -[[package]] -name = "backoff" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, -] - [[package]] name = "beautifulsoup4" version = "4.14.3" @@ -150,6 +146,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096", size = 73054, upload-time = "2026-02-18T22:15:17.958Z" }, ] +[[package]] +name = "cbor2" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/db/810437bcfe13cf5e09b68bad1ce57c8fa04ca9272c68946bbf2f4fa522c8/cbor2-6.1.1.tar.gz", hash = "sha256:6f0644869e0fdcd6f3874330b8f1cebd009f33191de43acf609dc2409cd362c4", size = 86297, upload-time = "2026-05-14T10:57:42.231Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/16/ac4710211e506a522bfe522dc02d676f308cff24c512b375b10e1cff62ed/cbor2-6.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f72e5f7e42a92f5ad2486dd14431bd09f966d167fc9e61cecef6740acf1b451", size = 410055, upload-time = "2026-05-14T10:56:54.133Z" }, + { url = "https://files.pythonhosted.org/packages/13/3a/ae0df2f8e4f8fac9212a3a9684a6213b6ba3190cd7762d78e5bd5043dddb/cbor2-6.1.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:a409b0b6de923f68f5e35287f25ec654fc68135991e41ae9a1c500ddd982c1fb", size = 453919, upload-time = "2026-05-14T10:56:55.468Z" }, + { url = "https://files.pythonhosted.org/packages/87/4c/f5b3feb35e942998f60545199ff9c4c80d552a8b783d07f7ff70e78e8b1f/cbor2-6.1.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:911b34263f39300dd8ec6b78f247b257caba0bbcd278bd2421a54d45595ff602", size = 467302, upload-time = "2026-05-14T10:56:56.76Z" }, + { url = "https://files.pythonhosted.org/packages/17/6d/a0472d99d9a38728498c9bcb4c65687383a948b0152e0bd7a20c1a87c949/cbor2-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:596418d033cff6eb0de9cb4ae63dd91c80e68d4ed01e1d0c61ad51709acc8ed2", size = 521305, upload-time = "2026-05-14T10:56:58.484Z" }, + { url = "https://files.pythonhosted.org/packages/c0/28/1d8cdb754def050e0d0674a556540d4a26bab0d7cfc3e11df14f2e4a2830/cbor2-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ce0e9a33d7ee2c8f47ae216be68a3a0a4d6d9832594a69e34be070cf6d13a9d8", size = 534365, upload-time = "2026-05-14T10:56:59.85Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fe/eaac5df152999aad4f3b4c4a25d0268b422dfebdbec28ebde8d3668604c5/cbor2-6.1.1-cp312-cp312-win32.whl", hash = "sha256:835f789f526ca7e729a8957da5ff6f33dfbda6c0b068695d01872fd6e35bbec2", size = 282520, upload-time = "2026-05-14T10:57:01.177Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e4/f1e480dbe8c11f5edf86c123adc25cfaf2eea1e80740da99e9cef735ae8f/cbor2-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:63ab065ae26e48d39fc6f4d7f44dd0780afdb91a70ffb8f33e281f54ee35ad14", size = 300677, upload-time = "2026-05-14T10:57:03.105Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f8/2534292515d113b1fb319e0bdc2ee508be9d9d2ce2389dbee00a66dfb97e/cbor2-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:199cbe1fa0326ec06f1d986bdfe488b3cafd2b1b5367a81c8f53c8364cab4803", size = 288811, upload-time = "2026-05-14T10:57:04.519Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ec/30a52d7f6844cefd37601311a226d091268564a47b0dac56bc0469573681/cbor2-6.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f027e077345ba7d1a88cbed9168196e77f5ce8e8c816305bb1c7a2e4894bddf", size = 409070, upload-time = "2026-05-14T10:57:05.843Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a5/653193249a64ca46def52798e8f10ddbc918f11818a977b2aa7248062520/cbor2-6.1.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:559025ad8e1f9f5d019a40dc8f14f43c111c11207b4dde852e943a3002b43ec0", size = 453218, upload-time = "2026-05-14T10:57:07.6Z" }, + { url = "https://files.pythonhosted.org/packages/9f/79/bdcb9d43ed537abaa89e662d6340244207ec85b6e66e3bd7f40856c3a5d4/cbor2-6.1.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:a6690f7df210386866e120475183132df98f77bf6df624097f66e3214e775084", size = 466244, upload-time = "2026-05-14T10:57:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/9c/44/fe0543996d53538c074f8ee18f7391b5458c528b1717740d750a9e472e1d/cbor2-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f4898b5463a567775a05310407dbea5b4a8d7ae8e81337ae9084f5fe226938ff", size = 520804, upload-time = "2026-05-14T10:57:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/cd/83/577bbafef3bc887d654a73f3f4ab11e1bd5320abd9108bfc51fbea1498a8/cbor2-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf3ef1fae6f14081a15f178e933ab846d3181f059ee4090975518b71f58bb09f", size = 533598, upload-time = "2026-05-14T10:57:12.098Z" }, + { url = "https://files.pythonhosted.org/packages/57/32/c1c9f435b109ded86ef2e90ff73b95624c84c6edf01489941363a6069725/cbor2-6.1.1-cp313-cp313-win32.whl", hash = "sha256:4642780d27c0b411f4669fcb82e0d7a6b93a0c41c03a0c51296fd6f6858f63fa", size = 281738, upload-time = "2026-05-14T10:57:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/4d/39/9232731f161b2dfe2dc28b06bbacfc2b6a85f1255bf58ebc578ae760ef38/cbor2-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:616bc0538095860fe5607cc06d7b2de3e261a6caccd01ff3f1d4a4a9ad29adbf", size = 300018, upload-time = "2026-05-14T10:57:15.021Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c2/67f2e3a83acfcecad947784bb1590d1978662b5472fcbf7d73e219813456/cbor2-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:7b193d2d024bb5d037e613272f5e436d53f02301101f0ce3916117688643181f", size = 287823, upload-time = "2026-05-14T10:57:16.525Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -194,6 +214,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "chardet" +version = "7.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/b6/9df434a8eeba2e6628c465a1dfa31034228ef79b26f76f46278f4ef7e49d/chardet-7.4.3.tar.gz", hash = "sha256:cc1d4eb92a4ec1c2df3b490836ffa46922e599d34ce0bb75cf41fd2bf6303d56", size = 784800, upload-time = "2026-04-13T21:33:39.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/33/29de185079e6675c3f375546e30a559b7ddc75ce972f18d6e566cd9ea4eb/chardet-7.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:75d3c65cc16bddf40b8da1fd25ba84fca5f8070f2b14e86083653c1c85aee971", size = 874870, upload-time = "2026-04-13T21:33:05.977Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2f/4c5af01fd1a7506a1d5375403d68925eac70289229492db5aa68b58103d8/chardet-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:29af5999f654e8729d251f1724a62b538b1262d9292cccaefddf8a02aae1ef6a", size = 854859, upload-time = "2026-04-13T21:33:07.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/21/edb36ad5dfa48d7f8eed97ab43931ecdaa8c15166c21b1d614967e49d681/chardet-7.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:626f00299ad62dfe937058a09572beed442ccc7b58f87aa667949b20fd3db235", size = 875032, upload-time = "2026-04-13T21:33:08.741Z" }, + { url = "https://files.pythonhosted.org/packages/e5/59/a32a241d861cf180853a11c8e5a67641cb1b2af13c3a5ccce83ec07e2c9f/chardet-7.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a4904dd5f071b7a7d7f50b4a67a86db3c902d243bf31708f1d5cde2f68239cb", size = 888283, upload-time = "2026-04-13T21:33:10.213Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/e1ee6a77abf3782c00e05b89c4d4328c8353bf9500661c4348df1dd68614/chardet-7.4.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d2879598bc220689e8ce509fe9c3f37ad2fca53a36be9c9bd91abdd91dd364f", size = 879974, upload-time = "2026-04-13T21:33:11.448Z" }, + { url = "https://files.pythonhosted.org/packages/32/60/fca69c534602a7ced04280c952a246ad1edde2a6ca3a164f65d32ac41fe7/chardet-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:4b2799bd58e7245cfa8d4ab2e8ad1d76a5c3a5b1f32318eb6acca4c69a3e7101", size = 943973, upload-time = "2026-04-13T21:33:12.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/79ac9b4db5bc87020c9dbc419125371d80882d1d197e9c4765ba8682b605/chardet-7.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e4486df251b8962e86ea9f139ca235aa6e0542a00f7844c9a04160afb99aa9", size = 873769, upload-time = "2026-04-13T21:33:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/55/5f/25bdec773905bff0ff6cf35ca73b17bd05593b4f87bd8c5fa43705f7167d/chardet-7.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fbff1907925b0c5a1064cffb5e040cd5e338585c9c552625f30de6bc2f3107a", size = 853991, upload-time = "2026-04-13T21:33:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/a29380ee0b215d23d77733b5ad60c5c0c7969650e080c667acdf9462040d/chardet-7.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:365135eaf37ba65a828f8e668eb0a8c38c479dcbec724dc25f4dfd781049c357", size = 874024, upload-time = "2026-04-13T21:33:16.915Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b1/3338e121cbd4c8a126b8ccb1061170c2ce51a53f678c502793ea49c6fd6d/chardet-7.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfc134b70c846c21ead8e43ada3ae1a805fff732f6922f8abcf2ff27b8f6493d", size = 887410, upload-time = "2026-04-13T21:33:18.368Z" }, + { url = "https://files.pythonhosted.org/packages/63/1c/44a9a9e0c59c185a5d307ceaeee8768afa1558f0a24f7a4b5fa11b67586b/chardet-7.4.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9acd9988a93e09390f3cd231201ea7166c415eb8da1b735928990ffc05cb9fbb", size = 879269, upload-time = "2026-04-13T21:33:20.377Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b3/5d0e77ea774bd3224321c248880ea0c0379000ac5c2bb6d77609549de247/chardet-7.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:e1b98790c284ff813f18f7cf7de5f05ea2435a080030c7f1a8318f3a4f80b131", size = 944155, upload-time = "2026-04-13T21:33:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6c/0a40afdb50a0fe041ab95553b835a8160b6cf0e81edf2ae2fe9f5224cbf9/chardet-7.4.3-py3-none-any.whl", hash = "sha256:1173b74051570cf08099d7429d92e4882d375ad4217f92a6e5240ccfb26f231e", size = 626562, upload-time = "2026-04-13T21:33:38.559Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.5" @@ -256,6 +306,45 @@ 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 = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +] + [[package]] name = "ct3" version = "3.4.0.post5" @@ -272,6 +361,111 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/a2/67e560a207ef077cff1937c4c983a78935544ea3b02262eed12feba664f7/ct3-3.4.0.post5-cp313-cp313-win_amd64.whl", hash = "sha256:216201ee1978bc653d40ccf7a1cafc5076e81670d64971a5be87e83ade0781fc", size = 187357, upload-time = "2025-11-29T12:42:36.992Z" }, ] +[[package]] +name = "cuda-bindings" +version = "13.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/c8/b2589d68acf7e3d63e2be330b84bc25712e97ed799affbca7edd7eae25d6/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e865447abfb83d6a98ad5130ed3c70b1fc295ae3eeee39fd07b4ddb0671b6788", size = 5722404, upload-time = "2026-03-11T00:12:44.041Z" }, + { url = "https://files.pythonhosted.org/packages/1f/92/f899f7bbb5617bb65ec52a6eac1e9a1447a86b916c4194f8a5001b8cde0c/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46d8776a55d6d5da9dd6e9858fba2efcda2abe6743871dee47dd06eb8cb6d955", size = 6320619, upload-time = "2026-03-11T00:12:45.939Z" }, + { url = "https://files.pythonhosted.org/packages/df/93/eef988860a3ca985f82c4f3174fc0cdd94e07331ba9a92e8e064c260337f/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6629ca2df6f795b784752409bcaedbd22a7a651b74b56a165ebc0c9dcbd504d0", size = 5614610, upload-time = "2026-03-11T00:12:50.337Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/6db3aba46864aee357ab2415135b3fe3da7e9f1fa0221fa2a86a5968099c/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dca0da053d3b4cc4869eff49c61c03f3c5dbaa0bcd712317a358d5b8f3f385d", size = 6149914, upload-time = "2026-03-11T00:12:52.374Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/d0/c177e29701cf1d3008d7d2b16b5fc626592ce13bd535f8795c5f57187e0e/cuda_pathfinder-1.5.4-py3-none-any.whl", hash = "sha256:9563d3175ce1828531acf4b94e1c1c7d67208c347ca002493e2654878b26f4b7", size = 51657, upload-time = "2026-04-27T22:42:07.712Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364, upload-time = "2025-12-19T23:24:07.328Z" }, +] + +[package.optional-dependencies] +cudart = [ + { name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux'" }, +] +cufft = [ + { name = "nvidia-cufft", marker = "sys_platform == 'linux'" }, +] +cufile = [ + { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, +] +cupti = [ + { name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux'" }, +] +curand = [ + { name = "nvidia-curand", marker = "sys_platform == 'linux'" }, +] +cusolver = [ + { name = "nvidia-cusolver", marker = "sys_platform == 'linux'" }, +] +cusparse = [ + { name = "nvidia-cusparse", marker = "sys_platform == 'linux'" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink", marker = "sys_platform == 'linux'" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux'" }, +] +nvtx = [ + { name = "nvidia-nvtx", marker = "sys_platform == 'linux'" }, +] + +[[package]] +name = "datasets" +version = "4.8.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dill" }, + { name = "filelock" }, + { name = "fsspec", extra = ["http"] }, + { name = "httpx" }, + { name = "huggingface-hub" }, + { name = "multiprocess" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pyarrow" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/34/14cd8e76f907f7d4dca2334cfeec9f81d30fd15c25a015f99aaea694eaed/datasets-4.8.5.tar.gz", hash = "sha256:0f0c1c3d56ffff2c93b2f4c63c95bac94f3d7e8621aea2a2a576275233bba772", size = 605649, upload-time = "2026-04-27T15:43:57.384Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/99/00f3196036501b53032c4b1ab8337a0b978dee832ed276dae3815df4e8b5/datasets-4.8.5-py3-none-any.whl", hash = "sha256:5079900781719c0e063a8efdd2cd95a31ad0c63209178669cd23cf1b926149ff", size = 528973, upload-time = "2026-04-27T15:43:53.702Z" }, +] + +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -281,6 +475,20 @@ 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 = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + [[package]] name = "docstring-to-markdown" version = "0.17" @@ -317,6 +525,7 @@ dependencies = [ { name = "fastapi" }, { name = "graphrag-sdk" }, { name = "javatools" }, + { name = "mcp" }, { name = "pygit2" }, { name = "python-dotenv" }, { name = "tree-sitter" }, @@ -332,24 +541,42 @@ dependencies = [ ] [package.optional-dependencies] +bench = [ + { name = "datasets" }, + { name = "mini-swe-agent" }, + { name = "swebench" }, +] test = [ + { name = "anyio" }, { name = "httpx" }, { name = "pytest" }, { name = "ruff" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-anyio" }, + { name = "pyyaml" }, +] + [package.metadata] requires-dist = [ + { name = "anyio", marker = "extra == 'test'", specifier = ">=4.0,<5.0" }, + { name = "datasets", marker = "extra == 'bench'", specifier = ">=4.8.5" }, { name = "falkordb", specifier = ">=1.1.3,<2.0.0" }, { name = "falkordb-multilspy", specifier = ">=0.1.0,<1.0.0" }, { name = "fastapi", specifier = ">=0.115.0,<1.0.0" }, - { name = "graphrag-sdk", specifier = ">=0.8.1,<0.9.0" }, + { name = "graphrag-sdk", specifier = ">=1.1.1,<2.0.0" }, { name = "httpx", marker = "extra == 'test'", specifier = ">=0.28.0,<1.0.0" }, { name = "javatools", specifier = ">=1.6.0,<2.0.0" }, + { name = "mcp", specifier = ">=1.0.0,<2.0.0" }, + { name = "mini-swe-agent", marker = "extra == 'bench'", specifier = ">=1.0.0" }, { name = "pygit2", specifier = ">=1.17.0,<2.0.0" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=9.0.2,<10.0.0" }, { name = "python-dotenv", specifier = ">=1.0.1,<2.0.0" }, { name = "ruff", marker = "extra == 'test'", specifier = ">=0.11.0,<1.0.0" }, + { name = "swebench", marker = "extra == 'bench'", specifier = ">=4.0" }, { name = "tree-sitter", specifier = ">=0.25.2,<0.26.0" }, { name = "tree-sitter-c", specifier = ">=0.24.1,<0.25.0" }, { name = "tree-sitter-c-sharp", specifier = ">=0.23.1,<0.24.0" }, @@ -361,7 +588,14 @@ requires-dist = [ { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0,<1.0.0" }, { name = "validators", specifier = ">=0.35.0,<0.36.0" }, ] -provides-extras = ["test"] +provides-extras = ["test", "bench"] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-anyio", specifier = ">=0.0.0" }, + { name = "pyyaml", specifier = ">=6.0.3" }, +] [[package]] name = "falkordb-multilspy" @@ -394,6 +628,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, ] +[[package]] +name = "fastcore" +version = "1.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/65/99f599a285033febf95f9c608d91d629ac5d9995f57e5b3ac3397097f440/fastcore-1.13.2.tar.gz", hash = "sha256:f660b3448de48ba31973b2866c994ea3cd5e0a654847f57d6911a1a4bffda777", size = 100337, upload-time = "2026-05-17T06:02:24.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/41/2c368f804bb9bd918da3b61324207fc4b410d0f32352c372c0680fc1f670/fastcore-1.13.2-py3-none-any.whl", hash = "sha256:2103c9e9e613311c0b36eab17299a221e778fd214ec526e8df1d32908928277c", size = 105060, upload-time = "2026-05-17T06:02:22.28Z" }, +] + [[package]] name = "fastuuid" version = "0.14.0" @@ -434,12 +677,11 @@ wheels = [ ] [[package]] -name = "fix-busted-json" -version = "0.0.18" +name = "flatbuffers" +version = "25.12.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/20/378d67dd0246f8d4f34902ac9a65dd81c627f77d6cea65cb21a4c34379ec/fix-busted-json-0.0.18.tar.gz", hash = "sha256:93c5dab7cae3b5d0b055f2c7043f9fe727a88a80d0be753c5f2c20bb9b69672f", size = 10491, upload-time = "2024-04-22T08:26:37.341Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/60/dd88b9688821079e92a0ed015779f11a65576218d525948be3148b81b86e/fix_busted_json-0.0.18-py3-none-any.whl", hash = "sha256:fdce0e02c9a810b3aa28e1c3c32c24b21b44e89f6315ec25d2b963bd52a6ef03", size = 7358, upload-time = "2024-04-22T08:26:35.946Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, ] [[package]] @@ -508,27 +750,94 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, ] +[package.optional-dependencies] +http = [ + { name = "aiohttp" }, +] + +[[package]] +name = "ghapi" +version = "1.0.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastcore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/09/1b88f97e8599cda096d42dac830bb2e28ddf202d71843f61bda52bbe99ce/ghapi-1.0.13.tar.gz", hash = "sha256:fb46f5e101efa33bd12a0ae7694de761eec5be1de90f48847699f1e00128f928", size = 72914, upload-time = "2026-02-28T02:21:01.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/ac/e1960ec21cfd5a0fd9b329822c04d0b5f91abb688c3b1acd7e8ff3390432/ghapi-1.0.13-py3-none-any.whl", hash = "sha256:49d7e336e5664e4d4f92b1d442dfe80f31ecccbee4370bd1d271bd63a1ccf18e", size = 71409, upload-time = "2026-02-28T02:21:00.457Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, +] + +[[package]] +name = "gliner" +version = "0.2.26" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "onnxruntime" }, + { name = "sentencepiece" }, + { name = "torch" }, + { name = "tqdm" }, + { name = "transformers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/18/e199cb97147c4a9260c75e4caf51e17be6ff969b0604a029c9c62810cbe0/gliner-0.2.26.tar.gz", hash = "sha256:6783be92b4b81caa878dcc4269ba37800207c37118d8ff9be028b93bddd6813d", size = 181224, upload-time = "2026-03-19T15:07:22.707Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/6e/d54d3d2867e29b68a22b144f570c8204209647fccc7879cec5218d6ed5fb/gliner-0.2.26-py3-none-any.whl", hash = "sha256:b9baa47641efb90b9d069add0528ed2464d137991ff097f42b0cab37a91ba991", size = 170429, upload-time = "2026-03-19T15:07:19.914Z" }, +] + [[package]] name = "graphrag-sdk" -version = "0.8.2" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backoff" }, - { name = "beautifulsoup4" }, { name = "falkordb" }, - { name = "fix-busted-json" }, - { name = "litellm" }, - { name = "pypdf" }, + { name = "gliner" }, + { name = "hnswlib" }, + { name = "numpy" }, + { name = "pydantic" }, { name = "python-dotenv" }, - { name = "ratelimit" }, - { name = "rdflib" }, - { name = "requests" }, - { name = "rich" }, - { name = "typing-extensions" }, + { name = "scipy" }, + { name = "tiktoken" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/73/f63b7e440ca6456a166c23f8603f3e1f7ca0eedce773250722c647886116/graphrag_sdk-1.1.1.tar.gz", hash = "sha256:e8abd863cfb5dccd14a897967553d8085657202ba1a1742f4dfdc4faafd6d78d", size = 218977, upload-time = "2026-05-13T13:17:43.04Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/e9/29b5d7d94c077b0ec2254366b4bdf8a8a032240098c82909f4b1a4f99225/graphrag_sdk-1.1.1-py3-none-any.whl", hash = "sha256:4f18a833f39854883875e11c981bad5649a0f467280a19702e82b1c9938a7abe", size = 159565, upload-time = "2026-05-13T13:17:40.963Z" }, +] + +[[package]] +name = "grpclib" +version = "0.4.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h2" }, + { name = "multidict" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/7d/10caf397a39311b5bb53b715d3fec9745f3e3ff39793914d5cf308d83291/graphrag_sdk-0.8.2.tar.gz", hash = "sha256:5d9d275e1b1bc1128eb56233a07ac4a7324d95430057164629da23948c4b4b69", size = 61476, upload-time = "2026-01-15T13:28:13.801Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/28/5a2c299ec82a876a252c5919aa895a6f1d1d35c96417c5ce4a4660dc3a80/grpclib-0.4.9.tar.gz", hash = "sha256:cc589c330fa81004c6400a52a566407574498cb5b055fa927013361e21466c46", size = 84798, upload-time = "2025-12-14T22:23:14.349Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/10/ea2845cab0288f962ee709ccb066f1476d2251e12feaa5262adb62311ec6/graphrag_sdk-0.8.2-py3-none-any.whl", hash = "sha256:f77a0addd08c13f89bbb87cd3b9a432729e1808bcabf2adeef79d8df3897e0e7", size = 83541, upload-time = "2026-01-15T13:28:12.118Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/b0cbbd9efcc82816c58f31a34963071aa19fb792a212a5d9caf8e0fc3097/grpclib-0.4.9-py3-none-any.whl", hash = "sha256:7762ec1c8ed94dfad597475152dd35cbd11aecaaca2f243e29702435ca24cf0e", size = 77063, upload-time = "2025-12-14T22:23:13.224Z" }, ] [[package]] @@ -540,6 +849,19 @@ 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 = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + [[package]] name = "hf-xet" version = "1.3.2" @@ -564,6 +886,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/02/9a6e4ca1f3f73a164c0cd48e41b3cc56585dcc37e809250de443d673266f/hf_xet-1.3.2-cp37-abi3-win_arm64.whl", hash = "sha256:83d8ec273136171431833a6957e8f3af496bee227a0fe47c7b8b39c106d1749a", size = 3503976, upload-time = "2026-02-27T17:26:12.123Z" }, ] +[[package]] +name = "hnswlib" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/7a/1a9b1405f2eb59515f06c3074750b03e0e96edf7fee0f6dd6df81d9c21d7/hnswlib-0.8.0.tar.gz", hash = "sha256:cb6d037eedebb34a7134e7dc78966441dfd04c9cf5ee93911be911ced951c44c", size = 36206, upload-time = "2023-12-03T04:16:17.55Z" } + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -614,6 +954,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "huggingface-hub" version = "1.6.0" @@ -634,6 +983,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/e3/e3a44f54c8e2f28983fcf07f13d4260b37bd6a0d3a081041bc60b91d230e/huggingface_hub-1.6.0-py3-none-any.whl", hash = "sha256:ef40e2d5cb85e48b2c067020fa5142168342d5108a1b267478ed384ecbf18961", size = 612874, upload-time = "2026-03-06T14:19:16.844Z" }, ] +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -718,45 +1085,47 @@ wheels = [ [[package]] name = "jiter" -version = "0.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, - { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, - { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, - { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, - { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, - { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, - { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, - { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, - { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, - { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, - { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, - { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, - { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" }, - { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" }, - { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" }, - { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" }, - { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" }, - { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" }, - { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" }, - { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" }, - { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" }, - { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" }, - { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" }, - { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" }, - { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" }, - { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" }, - { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, - { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, - { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, - { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, - { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, - { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, - { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, +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/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/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]] @@ -786,9 +1155,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + [[package]] name = "litellm" -version = "1.83.0" +version = "1.86.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -804,9 +1185,9 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/92/6ce9737554994ca8e536e5f4f6a87cc7c4774b656c9eb9add071caf7d54b/litellm-1.83.0.tar.gz", hash = "sha256:860bebc76c4bb27b4cf90b4a77acd66dba25aced37e3db98750de8a1766bfb7a", size = 17333062, upload-time = "2026-03-31T05:08:25.331Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/7c/a94c15e1146f76a6e0dfe0394f824cdfeb9dadb988c6f698a35f6c1b8d4f/litellm-1.86.1.tar.gz", hash = "sha256:616100384073f2ddec1a5391b34c806c7c99e6af4511741434809caa46075e13", size = 15379863, upload-time = "2026-05-26T03:51:58.624Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/2c/a670cc050fcd6f45c6199eb99e259c73aea92edba8d5c2fc1b3686d36217/litellm-1.83.0-py3-none-any.whl", hash = "sha256:88c536d339248f3987571493015784671ba3f193a328e1ea6780dbebaa2094a8", size = 15610306, upload-time = "2026-03-31T05:08:21.987Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/e6e874857f45ff47762c6c29ec3dad9ea4757300e034400642cab90e4982/litellm-1.86.1-py3-none-any.whl", hash = "sha256:54e52372d326c642e9cc76d39afffb9b22387cbff3694c5e2758c9f860bca2e9", size = 17012257, upload-time = "2026-05-26T03:51:49.991Z" }, ] [[package]] @@ -834,6 +1215,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -875,6 +1261,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, ] +[[package]] +name = "mcp" +version = "1.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/83/d1efe7c2980d8a3afa476f4e3d42d53dd54c0ab94c27bee5d755b45c8b73/mcp-1.27.1.tar.gz", hash = "sha256:0f47e1820f8f8f941466b39749eb1d1839a04caddca2bc60e9d46e8a99914924", size = 608458, upload-time = "2026-05-08T16:50:12.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/73/42d9596facebdb533b7f0b86c1b0364ef350d1f8ba78b1052e8a58b48b65/mcp-1.27.1-py3-none-any.whl", hash = "sha256:1af3c4203b329430fde7a87b4fcb6392a041f5cb851fd68fc674016ab4e7c06f", size = 216260, upload-time = "2026-05-08T16:50:10.547Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -884,6 +1307,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mini-swe-agent" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "datasets" }, + { name = "jinja2" }, + { name = "litellm" }, + { name = "openai" }, + { name = "platformdirs" }, + { name = "prompt-toolkit" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, + { name = "tenacity" }, + { name = "textual" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/49/cfc66c0ec44d829e1d01b9e756f54294854a51362f4d54c68b81c7b49928/mini_swe_agent-2.3.0.tar.gz", hash = "sha256:8aae4f2a603a1f971dd183a5ca5157016e130d6f67f0ff35ad148d014673d41f", size = 63780, upload-time = "2026-05-21T14:37:59.703Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/f5/941c20e2152cc3bd0a85773ab62640c1d0a71972bff1964e3d059eceddf9/mini_swe_agent-2.3.0-py3-none-any.whl", hash = "sha256:27f68d784d3a22768fc389f31a451bb55e5d00fa46e1a27032d029da35878988", size = 110649, upload-time = "2026-05-21T14:37:58.548Z" }, +] + +[[package]] +name = "modal" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "cbor2" }, + { name = "certifi" }, + { name = "click" }, + { name = "grpclib" }, + { name = "protobuf" }, + { name = "rich" }, + { name = "synchronicity" }, + { name = "toml" }, + { name = "types-certifi" }, + { name = "types-toml" }, + { name = "typing-extensions" }, + { name = "watchfiles" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/7d/4126d0fe879ef3e86002ca821a34cb68a2588ea2e8ccb2bfe421d0f42ffe/modal-1.4.3.tar.gz", hash = "sha256:35b2fc840f759b512e12527afb538e1ea4cc232b84cfbfcef3f5d96d5a66abaa", size = 720488, upload-time = "2026-05-18T22:34:45.842Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/54/400262056c144ceee5edab40efa2541ae8928ae5f244fd9025f3ad26c909/modal-1.4.3-py3-none-any.whl", hash = "sha256:802917181f576458a0cb833322157dab09c4f367326426c5a732661a0c519577", size = 826232, upload-time = "2026-05-18T22:34:43.335Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -948,22 +1429,273 @@ wheels = [ ] [[package]] -name = "openai" -version = "2.26.0" +name = "multiprocess" +version = "0.70.19" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, + { name = "dill" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/f2/e783ac7f2aeeed14e9e12801f22529cc7e6b7ab80928d6dcce4e9f00922d/multiprocess-0.70.19.tar.gz", hash = "sha256:952021e0e6c55a4a9fe4cd787895b86e239a40e76802a789d6305398d3975897", size = 2079989, upload-time = "2026-01-19T06:47:39.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/45/8004d1e6b9185c1a444d6b55ac5682acf9d98035e54386d967366035a03a/multiprocess-0.70.19-py310-none-any.whl", hash = "sha256:97404393419dcb2a8385910864eedf47a3cadf82c66345b44f036420eb0b5d87", size = 134948, upload-time = "2026-01-19T06:47:32.325Z" }, + { url = "https://files.pythonhosted.org/packages/86/c2/dec9722dc3474c164a0b6bcd9a7ed7da542c98af8cabce05374abab35edd/multiprocess-0.70.19-py311-none-any.whl", hash = "sha256:928851ae7973aea4ce0eaf330bbdafb2e01398a91518d5c8818802845564f45c", size = 144457, upload-time = "2026-01-19T06:47:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/71/70/38998b950a97ea279e6bd657575d22d1a2047256caf707d9a10fbce4f065/multiprocess-0.70.19-py312-none-any.whl", hash = "sha256:3a56c0e85dd5025161bac5ce138dcac1e49174c7d8e74596537e729fd5c53c28", size = 150281, upload-time = "2026-01-19T06:47:35.037Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/d2c27e03cb84251dfe7249b8e82923643c6d48fa4883b9476b025e7dc7eb/multiprocess-0.70.19-py313-none-any.whl", hash = "sha256:8d5eb4ec5017ba2fab4e34a747c6d2c2b6fecfe9e7236e77988db91580ada952", size = 156414, upload-time = "2026-01-19T06:47:35.915Z" }, + { url = "https://files.pythonhosted.org/packages/7e/82/69e539c4c2027f1e1697e09aaa2449243085a0edf81ae2c6341e84d769b6/multiprocess-0.70.19-py39-none-any.whl", hash = "sha256:0d4b4397ed669d371c81dcd1ef33fd384a44d6c3de1bd0ca7ac06d837720d3c5", size = 133477, upload-time = "2026-01-19T06:47:38.619Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, +] + +[[package]] +name = "nvidia-cublas" +version = "13.1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758, upload-time = "2026-04-08T18:46:58.655Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime" +version = "13.0.96" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu13" +version = "9.20.0.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/edb9c0ae051602c3ccaffe424256463636d639e27d7f302dde9975ef9e7a/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304", size = 366173588, upload-time = "2026-03-09T19:29:34.474Z" }, +] + +[[package]] +name = "nvidia-cufft" +version = "12.0.0.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, +] + +[[package]] +name = "nvidia-cufile" +version = "1.15.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, +] + +[[package]] +name = "nvidia-curand" +version = "10.4.0.35" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, +] + +[[package]] +name = "nvidia-cusolver" +version = "12.0.4.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cusparse", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, +] + +[[package]] +name = "nvidia-cusparse" +version = "12.6.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu13" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.29.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/0d/daf50d44177ee0cbc7ff0a0c91eb5ff676c82be42f9a970bc7597f440c3a/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5", size = 206014712, upload-time = "2026-03-03T05:34:20.843Z" }, + { url = "https://files.pythonhosted.org/packages/67/f4/58e4e91b6919367c7aafb8e36fce9aad1a3047e536bf7e2fd560927d3a4c/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d", size = 205976000, upload-time = "2026-03-03T05:36:24.472Z" }, +] + +[[package]] +name = "nvidia-nvjitlink" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b", size = 40713933, upload-time = "2025-09-04T08:35:43.553Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2c/93c5250e64df4f894f1cbb397c6fd71f79813f9fd79d7cd61de3f97b3c2d/nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c", size = 38768748, upload-time = "2025-09-04T08:35:20.008Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu13" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, +] + +[[package]] +name = "nvidia-nvtx" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, + { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/b1/d111b1df656761f980d9e298a60039a9cb66036b1d039e777537743d0ac3/onnxruntime-1.26.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05b028781b322ad74b57ce5b50aa5280bb1fe96ceec334628ade681e0b24c1ac", size = 18016624, upload-time = "2026-05-12T00:41:01.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a0/3f9d896a0385a36bd04345d6d0b802821a5782adde562e7e135f6bb71c73/onnxruntime-1.26.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91f2bb870a4b9224eba0a6728c1fa7a9e552b8e59e1083c51fbbc3d013f2b5c0", size = 16052692, upload-time = "2026-05-08T19:07:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/2a4e04f8dbeffad19bbcced4bcd4289bf478921518437404d6b92bdf213b/onnxruntime-1.26.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b6dd70599005bd1bf29779f04a91978b92b5e719c11a20068a8f8e535f725b6", size = 18185439, upload-time = "2026-05-08T19:07:36.299Z" }, + { url = "https://files.pythonhosted.org/packages/44/fc/026d0a7162b9c2153dac292baea9e027c42304dc1d9dc6f8ff5b4cfbaedd/onnxruntime-1.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:a26374dc7fbcaae593601086b242120e13f2310558df0991da6dd8b8fac00414", size = 13026427, upload-time = "2026-05-08T19:08:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/3e/27/1dcf88e45e4c69db5f7b106f2dacc3801ba98994e082ca03e1dfdf7bfe57/onnxruntime-1.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:54a8053410fd31fd66469bd754fcfe8a4df9f7eb44756b4b5479bf50c842d948", size = 12796647, upload-time = "2026-05-08T19:07:52.108Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a2/c801242685e0ce48a4ca51dfafbb588765e0446397e123be53ba5598f3f5/onnxruntime-1.26.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ccce19c5f771b8268902f77d9fed9e88f9499465d6780808faa6611a789d33f0", size = 18016563, upload-time = "2026-05-08T19:07:28.081Z" }, + { url = "https://files.pythonhosted.org/packages/e2/64/0492c0b1db04e29b2630c87cfa36f9d6872b1ca8614b90c5cad58fac7d76/onnxruntime-1.26.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdbed8cf3b672b66acb032f33a253bc27f42bce6ece48ae3fab4fa483a5e96e0", size = 16052634, upload-time = "2026-05-08T19:07:16.885Z" }, + { url = "https://files.pythonhosted.org/packages/3d/26/4d09ddc755a84fc8d5e192991626b0e0680e8f6c5d58f4f1d05c42bc48cf/onnxruntime-1.26.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c07af6fc6d5557835f2b6ee7a96d8b3235d0c57a8e230efdedaee106a8a3cbc6", size = 18185632, upload-time = "2026-05-08T19:07:38.756Z" }, + { url = "https://files.pythonhosted.org/packages/77/89/3e52249aa08fa301e217ecba07b5246a8338fa2b401e109326e3fc5be0f9/onnxruntime-1.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:61bec80655efa460591c2bc655392d57d2650ce85533a6b9b3b7a790d7ea7916", size = 13026751, upload-time = "2026-05-08T19:08:06.2Z" }, + { url = "https://files.pythonhosted.org/packages/06/b3/c1c8782b14af6797c303de132d6eef26a9fb80dfacd3750ce57911d11c6b/onnxruntime-1.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:a6677545ff451e3539a02746d2f207d8c5baa4a0a818886bb9d6a6eb9511ee89", size = 12796807, upload-time = "2026-05-08T19:07:54.879Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f5/47b0676408abec652c14b84d7173e389837832d850c24f87184277313e8d/onnxruntime-1.26.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e016edc15d3c19f36807e1c6b10be5b27807688c32720f91b5ae480a95215d0", size = 16057265, upload-time = "2026-05-08T19:07:19.603Z" }, + { url = "https://files.pythonhosted.org/packages/3b/45/33ab6deeef010ca844c877dd618cebc079590bbe52d2a3678e7223b1b908/onnxruntime-1.26.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f5fc48a91a046a6a5c9b147f83fb41d65d24d24923373b222cdd248f0f4f4aac", size = 18197590, upload-time = "2026-05-08T19:07:41.422Z" }, +] + +[[package]] +name = "openai" +version = "2.38.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/d7/91/2a06c4e9597c338cac1e5e5a8dd6f29e1836fc229c4c523529dca387fda8/openai-2.26.0.tar.gz", hash = "sha256:b41f37c140ae0034a6e92b0c509376d907f3a66109935fba2c1b471a7c05a8fb", size = 666702, upload-time = "2026-03-05T23:17:35.874Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/12/cfa322c5f5dd8fa21aab9a7a8e979e7a11123800f86ca8d82eb68a83d213/openai-2.38.0.tar.gz", hash = "sha256:798694c6cf74145541fda94325b6f8f72d8e1fd0262cc137c8d728177a6a4ce3", size = 772764, upload-time = "2026-05-21T21:23:42.105Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/2e/3f73e8ca53718952222cacd0cf7eecc9db439d020f0c1fe7ae717e4e199a/openai-2.26.0-py3-none-any.whl", hash = "sha256:6151bf8f83802f036117f06cc8a57b3a4da60da9926826cc96747888b57f394f", size = 1136409, upload-time = "2026-03-05T23:17:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/ccff9be562e24207716d04ef9dc931c76aff0c89a7265da43e2104d7fe06/openai-2.38.0-py3-none-any.whl", hash = "sha256:ec6661c57b2dcc47414a767e6e3335c7ed3d19c9696999283a3c82e95c756a3c", size = 1344910, upload-time = "2026-05-21T21:23:39.636Z" }, ] [[package]] @@ -975,6 +1707,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, +] + [[package]] name = "parso" version = "0.8.6" @@ -984,6 +1752,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, ] +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -994,57 +1771,106 @@ wheels = [ ] [[package]] -name = "propcache" -version = "0.4.1" +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, - { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, - { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, - { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, - { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, - { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, - { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, - { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, - { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, - { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, - { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, - { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, - { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, - { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, - { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, - { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, - { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, - { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, - { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, - { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, - { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, - { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, - { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, - { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, - { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, - { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, - { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, - { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, - { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, - { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, - { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, - { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, - { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] [[package]] @@ -1069,6 +1895,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "pyarrow" +version = "24.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, + { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, + { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, + { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, + { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -1136,6 +1991,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + [[package]] name = "pygit2" version = "1.19.1" @@ -1182,29 +2051,25 @@ wheels = [ [[package]] name = "pygments" -version = "2.20.0" +version = "2.19.2" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] -name = "pyparsing" -version = "3.3.2" +name = "pyjwt" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] -[[package]] -name = "pypdf" -version = "5.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/89/3a/584b97a228950ed85aec97c811c68473d9b8d149e6a8c155668287cf1a28/pypdf-5.9.0.tar.gz", hash = "sha256:30f67a614d558e495e1fbb157ba58c1de91ffc1718f5e0dfeb82a029233890a1", size = 5035118, upload-time = "2025-07-27T14:04:52.364Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/d9/6cff57c80a6963e7dd183bf09e9f21604a77716644b1e580e97b259f7612/pypdf-5.9.0-py3-none-any.whl", hash = "sha256:be10a4c54202f46d9daceaa8788be07aa8cd5ea8c25c529c50dd509206382c35", size = 313193, upload-time = "2025-07-27T14:04:50.53Z" }, +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, ] [[package]] @@ -1223,6 +2088,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-anyio" +version = "0.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/44/a02e5877a671b0940f21a7a0d9704c22097b123ed5cdbcca9cab39f17acc/pytest-anyio-0.0.0.tar.gz", hash = "sha256:b41234e9e9ad7ea1dbfefcc1d6891b23d5ef7c9f07ccf804c13a9cc338571fd3", size = 1560, upload-time = "2021-06-29T22:57:30.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/25/bd6493ae85d0a281b6a0f248d0fdb1d9aa2b31f18bcd4a8800cf397d8209/pytest_anyio-0.0.0-py2.py3-none-any.whl", hash = "sha256:dc8b5c4741cb16ff90be37fddd585ca943ed12bbeb563de7ace6cd94441d8746", size = 1999, upload-time = "2021-06-29T22:57:29.158Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1235,6 +2113,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-discovery" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -1244,6 +2135,28 @@ 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 = "python-multipart" +version = "0.0.29" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1272,24 +2185,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] -[[package]] -name = "ratelimit" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ab/38/ff60c8fc9e002d50d48822cc5095deb8ebbc5f91a6b8fdd9731c87a147c9/ratelimit-2.2.1.tar.gz", hash = "sha256:af8a9b64b821529aca09ebaf6d8d279100d766f19e90b5059ac6a718ca6dee42", size = 5251, upload-time = "2018-12-17T18:55:49.675Z" } - -[[package]] -name = "rdflib" -version = "7.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyparsing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/f5/18bb77b7af9526add0c727a3b2048959847dc5fb030913e2918bf384fec3/rdflib-7.6.0.tar.gz", hash = "sha256:6c831288d5e4a5a7ece85d0ccde9877d512a3d0f02d7c06455d00d6d0ea379df", size = 4943826, upload-time = "2026-02-13T07:15:55.938Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl", hash = "sha256:30c0a3ebf4c0e09215f066be7246794b6492e054e782d7ac2a34c9f70a15e0dd", size = 615416, upload-time = "2026-02-13T07:15:46.487Z" }, -] - [[package]] name = "redis" version = "7.3.0" @@ -1371,7 +2266,7 @@ wheels = [ [[package]] name = "requests" -version = "2.33.0" +version = "2.32.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1379,22 +2274,22 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, ] [[package]] name = "rich" -version = "13.9.4" +version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] [[package]] @@ -1474,6 +2369,110 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, ] +[[package]] +name = "safetensors" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" }, + { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" }, + { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" }, + { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" }, + { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" }, + { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" }, + { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" }, + { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, +] + +[[package]] +name = "sentencepiece" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/15/2e7a025fc62d764b151ae6d0f2a92f8081755ebe8d4a64099accc6f77ba6/sentencepiece-0.2.1.tar.gz", hash = "sha256:8138cec27c2f2282f4a34d9a016e3374cd40e5c6e9cb335063db66a0a3b71fad", size = 3228515, upload-time = "2025-08-12T07:00:51.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/be/32ce495aa1d0e0c323dcb1ba87096037358edee539cac5baf8755a6bd396/sentencepiece-0.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57cae326c8727de58c85977b175af132a7138d84c764635d7e71bbee7e774133", size = 1943152, upload-time = "2025-08-12T06:59:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/88/7e/ff23008899a58678e98c6ff592bf4d368eee5a71af96d0df6b38a039dd4f/sentencepiece-0.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:56dd39a3c4d6493db3cdca7e8cc68c6b633f0d4195495cbadfcf5af8a22d05a6", size = 1325651, upload-time = "2025-08-12T06:59:41.536Z" }, + { url = "https://files.pythonhosted.org/packages/19/84/42eb3ce4796777a1b5d3699dfd4dca85113e68b637f194a6c8d786f16a04/sentencepiece-0.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9381351182ff9888cc80e41c632e7e274b106f450de33d67a9e8f6043da6f76", size = 1253645, upload-time = "2025-08-12T06:59:42.903Z" }, + { url = "https://files.pythonhosted.org/packages/89/fa/d3d5ebcba3cb9e6d3775a096251860c41a6bc53a1b9461151df83fe93255/sentencepiece-0.2.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99f955df238021bf11f0fc37cdb54fd5e5b5f7fd30ecc3d93fb48b6815437167", size = 1316273, upload-time = "2025-08-12T06:59:44.476Z" }, + { url = "https://files.pythonhosted.org/packages/04/88/14f2f4a2b922d8b39be45bf63d79e6cd3a9b2f248b2fcb98a69b12af12f5/sentencepiece-0.2.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cdfecef430d985f1c2bcbfff3defd1d95dae876fbd0173376012d2d7d24044b", size = 1387881, upload-time = "2025-08-12T06:59:46.09Z" }, + { url = "https://files.pythonhosted.org/packages/fd/b8/903e5ccb77b4ef140605d5d71b4f9e0ad95d456d6184688073ed11712809/sentencepiece-0.2.1-cp312-cp312-win32.whl", hash = "sha256:a483fd29a34c3e34c39ac5556b0a90942bec253d260235729e50976f5dba1068", size = 999540, upload-time = "2025-08-12T06:59:48.023Z" }, + { url = "https://files.pythonhosted.org/packages/2d/81/92df5673c067148c2545b1bfe49adfd775bcc3a169a047f5a0e6575ddaca/sentencepiece-0.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:4cdc7c36234fda305e85c32949c5211faaf8dd886096c7cea289ddc12a2d02de", size = 1054671, upload-time = "2025-08-12T06:59:49.895Z" }, + { url = "https://files.pythonhosted.org/packages/fe/02/c5e3bc518655d714622bec87d83db9cdba1cd0619a4a04e2109751c4f47f/sentencepiece-0.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:daeb5e9e9fcad012324807856113708614d534f596d5008638eb9b40112cd9e4", size = 1033923, upload-time = "2025-08-12T06:59:51.952Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4a/85fbe1706d4d04a7e826b53f327c4b80f849cf1c7b7c5e31a20a97d8f28b/sentencepiece-0.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dcd8161eee7b41aae57ded06272905dbd680a0a04b91edd0f64790c796b2f706", size = 1943150, upload-time = "2025-08-12T06:59:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/c2/83/4cfb393e287509fc2155480b9d184706ef8d9fa8cbf5505d02a5792bf220/sentencepiece-0.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c6c8f42949f419ff8c7e9960dbadcfbc982d7b5efc2f6748210d3dd53a7de062", size = 1325651, upload-time = "2025-08-12T06:59:55.073Z" }, + { url = "https://files.pythonhosted.org/packages/8d/de/5a007fb53b1ab0aafc69d11a5a3dd72a289d5a3e78dcf2c3a3d9b14ffe93/sentencepiece-0.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:097f3394e99456e9e4efba1737c3749d7e23563dd1588ce71a3d007f25475fff", size = 1253641, upload-time = "2025-08-12T06:59:56.562Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d2/f552be5928105588f4f4d66ee37dd4c61460d8097e62d0e2e0eec41bc61d/sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7b670879c370d350557edabadbad1f6561a9e6968126e6debca4029e5547820", size = 1316271, upload-time = "2025-08-12T06:59:58.109Z" }, + { url = "https://files.pythonhosted.org/packages/96/df/0cfe748ace5485be740fed9476dee7877f109da32ed0d280312c94ec259f/sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7f0fd2f2693309e6628aeeb2e2faf6edd221134dfccac3308ca0de01f8dab47", size = 1387882, upload-time = "2025-08-12T07:00:00.701Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dd/f7774d42a881ced8e1739f393ab1e82ece39fc9abd4779e28050c2e975b5/sentencepiece-0.2.1-cp313-cp313-win32.whl", hash = "sha256:92b3816aa2339355fda2c8c4e021a5de92180b00aaccaf5e2808972e77a4b22f", size = 999541, upload-time = "2025-08-12T07:00:02.709Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e9/932b9eae6fd7019548321eee1ab8d5e3b3d1294df9d9a0c9ac517c7b636d/sentencepiece-0.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:10ed3dab2044c47f7a2e7b4969b0c430420cdd45735d78c8f853191fa0e3148b", size = 1054669, upload-time = "2025-08-12T07:00:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/c9/3a/76488a00ea7d6931689cda28726a1447d66bf1a4837943489314593d5596/sentencepiece-0.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac650534e2251083c5f75dde4ff28896ce7c8904133dc8fef42780f4d5588fcd", size = 1033922, upload-time = "2025-08-12T07:00:06.496Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b6/08fe2ce819e02ccb0296f4843e3f195764ce9829cbda61b7513f29b95718/sentencepiece-0.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8dd4b477a7b069648d19363aad0cab9bad2f4e83b2d179be668efa672500dc94", size = 1946052, upload-time = "2025-08-12T07:00:08.136Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d9/1ea0e740591ff4c6fc2b6eb1d7510d02f3fb885093f19b2f3abd1363b402/sentencepiece-0.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0c0f672da370cc490e4c59d89e12289778310a0e71d176c541e4834759e1ae07", size = 1327408, upload-time = "2025-08-12T07:00:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/99/7e/1fb26e8a21613f6200e1ab88824d5d203714162cf2883248b517deb500b7/sentencepiece-0.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ad8493bea8432dae8d6830365352350f3b4144415a1d09c4c8cb8d30cf3b6c3c", size = 1254857, upload-time = "2025-08-12T07:00:11.021Z" }, + { url = "https://files.pythonhosted.org/packages/bc/85/c72fd1f3c7a6010544d6ae07f8ddb38b5e2a7e33bd4318f87266c0bbafbf/sentencepiece-0.2.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b81a24733726e3678d2db63619acc5a8dccd074f7aa7a54ecd5ca33ca6d2d596", size = 1315722, upload-time = "2025-08-12T07:00:12.989Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e8/661e5bd82a8aa641fd6c1020bd0e890ef73230a2b7215ddf9c8cd8e941c2/sentencepiece-0.2.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a81799d0a68d618e89063fb423c3001a034c893069135ffe51fee439ae474d6", size = 1387452, upload-time = "2025-08-12T07:00:15.088Z" }, + { url = "https://files.pythonhosted.org/packages/99/5e/ae66c361023a470afcbc1fbb8da722c72ea678a2fcd9a18f1a12598c7501/sentencepiece-0.2.1-cp313-cp313t-win32.whl", hash = "sha256:89a3ea015517c42c0341d0d962f3e6aaf2cf10d71b1932d475c44ba48d00aa2b", size = 1002501, upload-time = "2025-08-12T07:00:16.966Z" }, + { url = "https://files.pythonhosted.org/packages/c1/03/d332828c4ff764e16c1b56c2c8f9a33488bbe796b53fb6b9c4205ddbf167/sentencepiece-0.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:33f068c9382dc2e7c228eedfd8163b52baa86bb92f50d0488bf2b7da7032e484", size = 1057555, upload-time = "2025-08-12T07:00:18.573Z" }, + { url = "https://files.pythonhosted.org/packages/88/14/5aee0bf0864df9bd82bd59e7711362908e4935e3f9cdc1f57246b5d5c9b9/sentencepiece-0.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:b3616ad246f360e52c85781e47682d31abfb6554c779e42b65333d4b5f44ecc0", size = 1036042, upload-time = "2025-08-12T07:00:20.209Z" }, +] + +[[package]] +name = "setuptools" +version = "81.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -1492,6 +2491,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1503,11 +2511,24 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8.3" +version = "2.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, ] [[package]] @@ -1523,6 +2544,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, ] +[[package]] +name = "swebench" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "chardet" }, + { name = "datasets" }, + { name = "docker" }, + { name = "ghapi" }, + { name = "gitpython" }, + { name = "modal" }, + { name = "pre-commit" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "rich" }, + { name = "tenacity" }, + { name = "tqdm" }, + { name = "unidiff" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/e1/c997299ad7bf088876d30398203aa1eed7dec897670dc1aa35b1d748ffcc/swebench-4.1.0.tar.gz", hash = "sha256:5aaa6a92c2db1aa64892d28a47483ca46a45a15cf1d2df673d7744f71811dc9a", size = 134341, upload-time = "2025-09-11T02:58:00.447Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/67/981d8b642ac3eac7c8a7b7832ff8b2fb74f96b28b5fcd9a8979879e5c46d/swebench-4.1.0-py3-none-any.whl", hash = "sha256:1243776f720047cc9e20a427f7a52b75c13a07abda6154fb60fe77f82ec8af57", size = 157231, upload-time = "2025-09-11T02:57:58.953Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "synchronicity" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/5e/50ea27817003665c7cc4f5bdad309f13d6329037f657848ee87fe06c3740/synchronicity-0.12.2.tar.gz", hash = "sha256:6fd605a5035d1ec74ce48fffaca80ea00345c84ca34223914e2436fb4f162ff9", size = 60018, upload-time = "2026-04-06T15:06:15.447Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/44/4f6ba4e2c171847e6f9a460213b196bbf26edea43d0e66889c7ccc55d368/synchronicity-0.12.2-py3-none-any.whl", hash = "sha256:9dbaca81fb7f2b57c6dea326e514e1c80e9ccfd9c9618515e84fa6091026273b", size = 41312, upload-time = "2026-04-06T15:06:14.459Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "textual" +version = "8.2.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/7a/c519db0aba5024f86e71e9631810bfdd6866ed2c8695bd7fa34b90e7ef59/textual-8.2.7.tar.gz", hash = "sha256:658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105", size = 1859249, upload-time = "2026-05-19T10:52:49.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/f5/c1e18bc0707300a0e90204343abbf7d7acd6fb7ebe03a6d4893b99a234b8/textual-8.2.7-py3-none-any.whl", hash = "sha256:4caaa13a90bc4cf9c6c862c067ccd34fe84e9c161710a2a907a8026313b6bd73", size = 731129, upload-time = "2026-05-19T10:52:51.773Z" }, +] + [[package]] name = "tiktoken" version = "0.12.0" @@ -1582,6 +2678,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, ] +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + +[[package]] +name = "torch" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cublas", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/bb/285d643f254731294c9b595a007eac39db4600a98682d7bca688f42ca164/torch-2.12.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b41339df93d491435e790ff8bcbae1c0ce777175889bfd1281d119862793e6a2", size = 88010197, upload-time = "2026-05-13T14:55:35.414Z" }, + { url = "https://files.pythonhosted.org/packages/79/81/76debf1db1343bd929bbb5d74c89fb437c2ed88eb144712557e7bd3eea45/torch-2.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8fbef9f108a863e7722a73740998967e3b074742a834fc5be3a535a2befa7057", size = 426376751, upload-time = "2026-05-13T14:55:03.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/f0/80026028b603c4650ff270fc3785bdef4bd6738765a9cc5a0f5a637d65a2/torch-2.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4b4f64c2c2b11f7510d93dd6412b87025ff6eddd6bb61c3b5a3d892ea20c4756", size = 532261691, upload-time = "2026-05-13T14:52:54.453Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c2/64b06cbb7830fb3cd9be13e1158b31a3f36b68e6a209105ee3c9d9480be0/torch-2.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:8b958caff4a14d3a3b0b2dfc6a378f64dda9728a9dad28c08a0db9ce4dafb549", size = 122988114, upload-time = "2026-05-13T14:54:42.153Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/01896c80ba921676aa45886b2c5b8d774912de2a1f719de48169c6f755cd/torch-2.12.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:90dd587a5f61bfe1307148b581e2084fc5bc4a06e2b90a20e9a36b81087ff16b", size = 88009511, upload-time = "2026-05-13T14:54:47.411Z" }, + { url = "https://files.pythonhosted.org/packages/a5/04/52bdaf4787eab6ac7d7f5851dff934e4def0bc8ead9c8fd2b69b3e529699/torch-2.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:864392c73b7654f4d2b3ae712f607937d0dbb1101c4555fbb41848106b297f39", size = 426383231, upload-time = "2026-05-13T14:53:32.129Z" }, + { url = "https://files.pythonhosted.org/packages/49/8a/94bdecd13f5aaa90d45920b89789d9fe7c6f4af8c3cdd7ce01fcb59908fc/torch-2.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5d6b560dfa7d56291c07d615c3bb73e8d9943d9b6d87f76cd0d9d570c4797fa6", size = 532269288, upload-time = "2026-05-13T14:53:49.423Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2f/bdbaaa267de519ef1b73054bf590d8c93c37a266c9a4e24a01bd38b6918f/torch-2.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:3fee918902090ade827643e758e98363278815de583c75d111fdd665ebffde9f", size = 122987706, upload-time = "2026-05-13T14:54:00.335Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ad/e95e822f3538171e22640a7fbe839a1fdb666600bf6487025de2ff03b11a/torch-2.12.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:10ee1448a9f304d3b987eb4656f664ba6e4d7b410ca7a5a7c642199777a2cf88", size = 88319556, upload-time = "2026-05-13T14:54:05.574Z" }, + { url = "https://files.pythonhosted.org/packages/b7/07/055d06d985b445d67422d25b033c11cf55bbb81785d4c4e68e28bca5820e/torch-2.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:af68dbf403439cae9ceaeaaf92f8352b460787dcd27b92aa05c40dd4a19c0f1e", size = 426397656, upload-time = "2026-05-13T14:52:38.84Z" }, + { url = "https://files.pythonhosted.org/packages/43/94/b0b4fdc3014122e0a7302fb90086d352aa48f2576f0b252561ebb38c01a8/torch-2.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:a6a2eebb237d3b1d9ad3b378e86d9b9e0782afdea8b1e0eba6a13646b9b49c07", size = 532183124, upload-time = "2026-05-13T14:53:16.178Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c8/052405e6ad05d3237bfe5a4df78f917773956f8e17813a2d44c059068b74/torch-2.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2140e373e9a51a3e22ef62e8d14366d0b470d18f0adf19fdc757368077133a34", size = 123232462, upload-time = "2026-05-13T14:52:27.26Z" }, +] + [[package]] name = "tqdm" version = "4.67.3" @@ -1594,6 +2735,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] +[[package]] +name = "transformers" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer-slim" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/1d/a7d91500a6c02ec76058bc9e65fcdec1bdb8882854dec8e4adf12d0aa8b0/transformers-5.1.0.tar.gz", hash = "sha256:c60d6180e5845ea1b4eed38d7d1b06fcc4cc341c6b7fa5c1dc767d7e25fe0139", size = 8531810, upload-time = "2026-02-05T15:41:42.932Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/66/57042d4b0f1ede8046d7ae6409bf3640df996e9cbc3fe20467aa29badc54/transformers-5.1.0-py3-none-any.whl", hash = "sha256:de534b50c9b2ce6217fc56421075a1734241fb40704fdc90f50f6a08fc533d59", size = 10276537, upload-time = "2026-02-05T15:41:40.358Z" }, +] + [[package]] name = "tree-sitter" version = "0.25.2" @@ -1637,6 +2798,14 @@ version = "0.23.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/22/85/a61c782afbb706a47d990eaee6977e7c2bd013771c5bf5c81c617684f286/tree_sitter_c_sharp-0.23.1.tar.gz", hash = "sha256:322e2cfd3a547a840375276b2aea3335fa6458aeac082f6c60fec3f745c967eb", size = 1317728, upload-time = "2024-11-11T05:25:32.535Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/dc/d4a0ad9e466263728f80f9dac399609473af01c1aba2ea3ea8879ce56276/tree_sitter_c_sharp-0.23.1-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:e87be7572991552606a3155d2f6c2045ded8bce94bfd9f74bf521d949c219a1c", size = 333661, upload-time = "2026-04-14T15:11:14.227Z" }, + { url = "https://files.pythonhosted.org/packages/61/7a/5c862770460a2e27079e725585ad2718100373c09448c14e36934ef44414/tree_sitter_c_sharp-0.23.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:86c2fdf178c66474a1be2965602818d30780e4e3ed890e3c206931f65d9a154c", size = 376295, upload-time = "2026-04-14T15:11:15.346Z" }, + { url = "https://files.pythonhosted.org/packages/67/18/0571a3a34c0feda60a9c37cf6dd5edfdbc24f8fcb1e48b6b6eb0f324ad2a/tree_sitter_c_sharp-0.23.1-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:035d259e64c41d02cc45afc3b8b46388b232e7d16d84734d851cca7334761da5", size = 358331, upload-time = "2026-04-14T15:11:16.418Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/0f7e1f50f6365338eb700f01710da0adc49a49fa9a8443e5a90ea4f29491/tree_sitter_c_sharp-0.23.1-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fa472cb9de7e14fee9408e144f29f68384cd8e9c677dff0002da19f361a59bdf", size = 359444, upload-time = "2026-04-14T15:11:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/98/60/129bd56d5ef22b4ae254940a09b6d3ed873093218868a3f9635d571d514e/tree_sitter_c_sharp-0.23.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1a0ea86eccff74e85ab4a2cf77c813fad7c84162962ce242dff0c51601028832", size = 358143, upload-time = "2026-04-14T15:11:18.755Z" }, + { url = "https://files.pythonhosted.org/packages/7c/cd/e12cdca47e0c56151cb4b156d48091b7bc1d968e072c1656cf6b73fe7218/tree_sitter_c_sharp-0.23.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8ab26dc998bbd4b4287b129f67c10ca715deb402ed77d0645674490ea509097e", size = 357524, upload-time = "2026-04-14T15:11:19.717Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2c/f742d60f818cba83760f4975c7158d1c96c36b5807e95a843db7fb8c64b7/tree_sitter_c_sharp-0.23.1-cp310-abi3-win_amd64.whl", hash = "sha256:d4486653feaff3314ef45534dcb6f9ea8ab3aa160896287c6473788f88eb38be", size = 338755, upload-time = "2026-04-14T15:11:20.883Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e4/8a8642b9bba86248ac2facc81ffb187c06c6768efa56c79d61fab70d736b/tree_sitter_c_sharp-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:e7a14b76ec23cc8386cf662d5ea602d81331376c93ca6299a97b174047790345", size = 337261, upload-time = "2026-04-14T15:11:22.111Z" }, { url = "https://files.pythonhosted.org/packages/58/04/f6c2df4c53a588ccd88d50851155945cff8cd887bd70c175e00aaade7edf/tree_sitter_c_sharp-0.23.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2b612a6e5bd17bb7fa2aab4bb6fc1fba45c94f09cb034ab332e45603b86e32fd", size = 372235, upload-time = "2024-11-11T05:25:19.424Z" }, { url = "https://files.pythonhosted.org/packages/99/10/1aa9486f1e28fc22810fa92cbdc54e1051e7f5536a5e5b5e9695f609b31e/tree_sitter_c_sharp-0.23.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a8b98f62bc53efcd4d971151950c9b9cd5cbe3bacdb0cd69fdccac63350d83e", size = 419046, upload-time = "2024-11-11T05:25:20.679Z" }, { url = "https://files.pythonhosted.org/packages/0f/21/13df29f8fcb9ba9f209b7b413a4764b673dfd58989a0dd67e9c7e19e9c2e/tree_sitter_c_sharp-0.23.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:986e93d845a438ec3c4416401aa98e6a6f6631d644bbbc2e43fcb915c51d255d", size = 415999, upload-time = "2024-11-11T05:25:22.359Z" }, @@ -1663,17 +2832,18 @@ wheels = [ [[package]] name = "tree-sitter-javascript" -version = "0.23.1" +version = "0.25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/dc/1c55c33cc6bbe754359b330534cf9f261c1b9b2c26ddf23aef3c5fa67759/tree_sitter_javascript-0.23.1.tar.gz", hash = "sha256:b2059ce8b150162cda05a457ca3920450adbf915119c04b8c67b5241cd7fcfed", size = 110058, upload-time = "2024-11-10T05:40:42.357Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/e0/e63103c72a9d3dfd89a31e02e660263ad84b7438e5f44ee82e443e65bbde/tree_sitter_javascript-0.25.0.tar.gz", hash = "sha256:329b5414874f0588a98f1c291f1b28138286617aa907746ffe55adfdcf963f38", size = 132338, upload-time = "2025-09-01T07:13:44.792Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/d3/c67d7d49967344b51208ad19f105233be1afdf07d3dcb35b471900265227/tree_sitter_javascript-0.23.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6ca583dad4bd79d3053c310b9f7208cd597fd85f9947e4ab2294658bb5c11e35", size = 59333, upload-time = "2024-11-10T05:40:31.988Z" }, - { url = "https://files.pythonhosted.org/packages/a5/db/ea0ee1547679d1750e80a0c4bc60b3520b166eeaf048764cfdd1ba3fd5e5/tree_sitter_javascript-0.23.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:94100e491a6a247aa4d14caf61230c171b6376c863039b6d9cd71255c2d815ec", size = 61071, upload-time = "2024-11-10T05:40:33.458Z" }, - { url = "https://files.pythonhosted.org/packages/67/6e/07c4857e08be37bfb55bfb269863df8ec908b2f6a3f1893cd852b893ecab/tree_sitter_javascript-0.23.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a6bc1055b061c5055ec58f39ee9b2e9efb8e6e0ae970838af74da0afb811f0a", size = 96999, upload-time = "2024-11-10T05:40:34.869Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f5/4de730afe8b9422845bc2064020a8a8f49ebd1695c04261c38d1b3e3edec/tree_sitter_javascript-0.23.1-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:056dc04fb6b24293f8c5fec43c14e7e16ba2075b3009c643abf8c85edc4c7c3c", size = 94020, upload-time = "2024-11-10T05:40:35.735Z" }, - { url = "https://files.pythonhosted.org/packages/77/0a/f980520da86c4eff8392867840a945578ef43372c9d4a37922baa6b121fe/tree_sitter_javascript-0.23.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a11ca1c0f736da42967586b568dff8a465ee148a986c15ebdc9382806e0ce871", size = 92927, upload-time = "2024-11-10T05:40:37.92Z" }, - { url = "https://files.pythonhosted.org/packages/ff/5c/36a98d512aa1d1082409d6b7eda5d26b820bd4477a54100ad9f62212bc55/tree_sitter_javascript-0.23.1-cp39-abi3-win_amd64.whl", hash = "sha256:041fa22b34250ea6eb313d33104d5303f79504cb259d374d691e38bbdc49145b", size = 58824, upload-time = "2024-11-10T05:40:39.903Z" }, - { url = "https://files.pythonhosted.org/packages/dc/79/ceb21988e6de615355a63eebcf806cd2a0fe875bec27b429d58b63e7fb5f/tree_sitter_javascript-0.23.1-cp39-abi3-win_arm64.whl", hash = "sha256:eb28130cd2fb30d702d614cbf61ef44d1c7f6869e7d864a9cc17111e370be8f7", size = 57027, upload-time = "2024-11-10T05:40:40.841Z" }, + { url = "https://files.pythonhosted.org/packages/2c/df/5106ac250cd03661ebc3cc75da6b3d9f6800a3606393a0122eca58038104/tree_sitter_javascript-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b70f887fb269d6e58c349d683f59fa647140c410cfe2bee44a883b20ec92e3dc", size = 64052, upload-time = "2025-09-01T07:13:36.865Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/6b4b2bc90d8ab3955856ce852cc9d1e82c81d7ab9646385f0e75ffd5b5d3/tree_sitter_javascript-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8264a996b8845cfce06965152a013b5d9cbb7d199bc3503e12b5682e62bb1de1", size = 66440, upload-time = "2025-09-01T07:13:37.962Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c4/7da74ecdcd8a398f88bd003a87c65403b5fe0e958cdd43fbd5fd4a398fcf/tree_sitter_javascript-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dc04ba91fc8583344e57c1f1ed5b2c97ecaaf47480011b92fbeab8dda96db75", size = 99728, upload-time = "2025-09-01T07:13:38.755Z" }, + { url = "https://files.pythonhosted.org/packages/96/c8/97da3af4796495e46421e9344738addb3602fa6426ea695be3fcbadbee37/tree_sitter_javascript-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:199d09985190852e0912da2b8d26c932159be314bc04952cf917ed0e4c633e6b", size = 106072, upload-time = "2025-09-01T07:13:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/13/be/c964e8130be08cc9bd6627d845f0e4460945b158429d39510953bbcb8fcc/tree_sitter_javascript-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dfcf789064c58dc13c0a4edb550acacfc6f0f280577f1e7a00de3e89fc7f8ddc", size = 104388, upload-time = "2025-09-01T07:13:40.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/89/9b773dee0f8961d1bb8d7baf0a204ab587618df19897c1ef260916f318ec/tree_sitter_javascript-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b852d3aee8a36186dbcc32c798b11b4869f9b5041743b63b65c2ef793db7a54", size = 98377, upload-time = "2025-09-01T07:13:41.838Z" }, + { url = "https://files.pythonhosted.org/packages/3b/dc/d90cb1790f8cec9b4878d278ad9faf7c8f893189ce0f855304fd704fc274/tree_sitter_javascript-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:e5ed840f5bd4a3f0272e441d19429b26eedc257abe5574c8546da6b556865e3c", size = 62975, upload-time = "2025-09-01T07:13:42.828Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1f/f9eba1038b7d4394410f3c0a6ec2122b590cd7acb03f196e52fa57ebbe72/tree_sitter_javascript-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:622a69d677aa7f6ee2931d8c77c981a33f0ebb6d275aa9d43d3397c879a9bb0b", size = 61668, upload-time = "2025-09-01T07:13:43.803Z" }, ] [[package]] @@ -1707,6 +2877,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/19/4b5569d9b1ebebb5907d11554a96ef3fa09364a30fcfabeff587495b512f/tree_sitter_python-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:0fbf6a3774ad7e89ee891851204c2e2c47e12b63a5edbe2e9156997731c128bb", size = 74169, upload-time = "2025-09-11T06:47:56.747Z" }, ] +[[package]] +name = "triton" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/13/ec05adfcd87311d532ba61e3af143e8be59fcd26675884c4682841406a20/triton-3.7.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4bf49b00a7a377a68a6da603a876e797614e6455a80e9021669c476a953ad9a", size = 188505104, upload-time = "2026-05-07T19:05:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/62/7b/468a576e35beef1426e0828e28e9ba9e65f5474d496f16ee126c15646324/triton-3.7.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f111161d49bf903c0eaedde3962353a3d841c08a836839b7cc1025b8426efcf", size = 201457567, upload-time = "2026-05-07T18:46:13.505Z" }, + { url = "https://files.pythonhosted.org/packages/01/e1/a59a583de59b8f62c495d67c80ee3ea97d09e91ac80c4c6e76456ed8d8ac/triton-3.7.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abdf6beaa89b1bcfb9a43cd990536ce66091a997841a4814b260b7bee4c88c3c", size = 188503209, upload-time = "2026-05-07T19:05:17.935Z" }, + { url = "https://files.pythonhosted.org/packages/30/b1/b7507bb9815d403927c8dd51d4158ed2e11751a92dbc118a044f247b6848/triton-3.7.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a35d7afe3f3f058e7ec49fcce09794049e0ffc5c59019ac25ec3413741b8c4e7", size = 201453566, upload-time = "2026-05-07T18:46:20.427Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8f/0bea7a6a0c989315c9135a1d7fb37e41905cfb3a17cbc1f10044ebd4cc3a/triton-3.7.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc1d61c172d257db80ddf42595131fb196ad2e9bdd751e90fe2ef13531734e8b", size = 188612899, upload-time = "2026-05-07T19:05:24.955Z" }, + { url = "https://files.pythonhosted.org/packages/e1/02/d96f57828d0912aec733b9bc7e0e7dbfd2c6f079a8fa433ac25cb93d1a30/triton-3.7.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70fb9bbdc9f400afc54bbf6eb2670af28829a6ae3996863317964783141daf56", size = 201553816, upload-time = "2026-05-07T18:46:27.49Z" }, +] + [[package]] name = "typer" version = "0.24.1" @@ -1722,6 +2905,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, ] +[[package]] +name = "typer-slim" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/a7/e6aecc4b4eb59598829a3b5076a93aff291b4fdaa2ded25efc4e1f4d219c/typer_slim-0.24.0.tar.gz", hash = "sha256:f0ed36127183f52ae6ced2ecb2521789995992c521a46083bfcdbb652d22ad34", size = 4776, upload-time = "2026-02-16T22:08:51.2Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/24/5480c20380dfd18cf33d14784096dca45a24eae6102e91d49a718d3b6855/typer_slim-0.24.0-py3-none-any.whl", hash = "sha256:d5d7ee1ee2834d5020c7c616ed5e0d0f29b9a4b1dd283bdebae198ec09778d0e", size = 3394, upload-time = "2026-02-16T22:08:49.92Z" }, +] + +[[package]] +name = "types-certifi" +version = "2021.10.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/68/943c3aeaf14624712a0357c4a67814dba5cea36d194f5c764dad7959a00c/types-certifi-2021.10.8.3.tar.gz", hash = "sha256:72cf7798d165bc0b76e1c10dd1ea3097c7063c42c21d664523b928e88b554a4f", size = 2095, upload-time = "2022-06-09T15:19:05.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/63/2463d89481e811f007b0e1cd0a91e52e141b47f9de724d20db7b861dcfec/types_certifi-2021.10.8.3-py3-none-any.whl", hash = "sha256:b2d1e325e69f71f7c78e5943d410e650b4707bb0ef32e4ddf3da37f54176e88a", size = 2136, upload-time = "2022-06-09T15:19:03.127Z" }, +] + +[[package]] +name = "types-toml" +version = "0.10.8.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/11/6ece999e91f2ccb848ab4420f3f4816e78ac0541f739e6864affdaaa5737/types_toml-0.10.8.20260518.tar.gz", hash = "sha256:80e10facd24fdeda9d5c672187d72be3ac284843788d67f5aae59e3e016db6fe", size = 9419, upload-time = "2026-05-18T06:02:16.719Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/25/489751806bf5c95e4007f8e17409199c54d31e49ffbea07c5729b1286c8e/types_toml-0.10.8.20260518-py3-none-any.whl", hash = "sha256:0e564ab05f6fde62a315b3b5a9b6624fda569399795d30a37e64705a70459303", size = 9669, upload-time = "2026-05-18T06:02:15.86Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1743,6 +2956,33 @@ 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 = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + +[[package]] +name = "unidiff" +version = "0.7.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/48/81be0ac96e423a877754153699731ef439fd7b80b4c8b5425c94ed079ebd/unidiff-0.7.5.tar.gz", hash = "sha256:2e5f0162052248946b9f0970a40e9e124236bf86c82b70821143a6fc1dea2574", size = 20931, upload-time = "2023-03-10T01:05:39.185Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/54/57c411a6e8f7bd7848c8b66e4dcaffa586bf4c02e63f2280db0327a4e6eb/unidiff-0.7.5-py2.py3-none-any.whl", hash = "sha256:c93bf2265cc1ba2a520e415ab05da587370bc2a3ae9e0414329f54f0c2fc09e8", size = 14386, upload-time = "2023-03-10T01:05:36.594Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -1805,6 +3045,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/6e/3e955517e22cbdd565f2f8b2e73d52528b14b8bcfdb04f62466b071de847/validators-0.35.0-py3-none-any.whl", hash = "sha256:e8c947097eae7892cb3d26868d637f79f47b4a0554bc6b80065dfe5aac3705dd", size = 44712, upload-time = "2025-05-01T05:42:04.203Z" }, ] +[[package]] +name = "virtualenv" +version = "21.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, +] + [[package]] name = "watchfiles" version = "1.1.1" @@ -1852,6 +3107,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, ] +[[package]] +name = "wcwidth" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, +] + [[package]] name = "websockets" version = "16.0" @@ -1879,72 +3143,122 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] +[[package]] +name = "xxhash" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/2f/e183a1b407002f5af81822bee18b61cdb94b8670208ef34734d8d2b8ebe9/xxhash-3.7.0.tar.gz", hash = "sha256:6cc4eefbb542a5d6ffd6d70ea9c502957c925e800f998c5630ecc809d6702bae", size = 82022, upload-time = "2026-04-25T11:10:32.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/8a/51a14cdef4728c6c2337db8a7d8704422cc65676d9199d77215464c880af/xxhash-3.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:082c87bfdd2b9f457606c7a4a53457f4c4b48b0cdc48de0277f4349d79bb3d7a", size = 33357, upload-time = "2026-04-25T11:06:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1b/0c2c933809421ffd9bf42b59315552c143c755db5d9a816b2f1ae273e884/xxhash-3.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5e7ce913b61f35b0c1c839a49ac9c8e75dd8d860150688aed353b0ce1bf409d8", size = 30869, upload-time = "2026-04-25T11:06:21.989Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/89d5fdd6ee12d70ba99451de46dd0e8010167468dcd913ec855653f4dd50/xxhash-3.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3beb1de3b1e9694fcdd853e570ee64c631c7062435d2f8c69c1adf809bc086f0", size = 194100, upload-time = "2026-04-25T11:06:23.586Z" }, + { url = "https://files.pythonhosted.org/packages/87/ee/2f9f2ed993e77206d1e66991290a1ebe22e843351ca3ebec8e49e01ba186/xxhash-3.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3e7b689c3bce16699efcf736066f5c6cc4472c3840fe4b22bd8279daf4abdac", size = 212977, upload-time = "2026-04-25T11:06:25.019Z" }, + { url = "https://files.pythonhosted.org/packages/de/60/5a91644615a9e9d4e42c2e9925f1908e3a24e4e691d9de7340d565bea024/xxhash-3.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a6545e6b409e3d5cbafc850fb84c55a1ca26ed15a6b11e3bf07a0e0cd84517c8", size = 236373, upload-time = "2026-04-25T11:06:26.482Z" }, + { url = "https://files.pythonhosted.org/packages/22/c0/f3a9384eaaed9d14d4d062a5d953aa0da489bfe9747877aa994caa87cd0b/xxhash-3.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:31ab1461c77a11461d703c88eb949e132a1c6515933cf675d97ec680f4bd18de", size = 212229, upload-time = "2026-04-25T11:06:28.065Z" }, + { url = "https://files.pythonhosted.org/packages/2e/67/02f07a9fd79726804190f2172c4894c3ed9a4ebccaca05653c84beb58025/xxhash-3.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c4d596b7676f811172687ec567cbafb9e4dea2f9be1bbb4f622410cb7f40f40", size = 445462, upload-time = "2026-04-25T11:06:30.048Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/558f5a90c0672fc9b4402dc25d87ac5b7406616e8969430c9ca4e52ee74d/xxhash-3.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13805f0461cba0a857924e70ff91ae6d52d2598f79a884e788db80532614a4a1", size = 193932, upload-time = "2026-04-25T11:06:31.857Z" }, + { url = "https://files.pythonhosted.org/packages/d5/90/aaa09cd58661d32044dbbad7df55bbe22a623032b810e7ed3b8c569a2a6f/xxhash-3.7.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d398f372496152f1c6933a33566373f8d1b37b98b8c9d608fa6edc0976f23b2", size = 284807, upload-time = "2026-04-25T11:06:33.697Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f3/53df3719ab127a02c174f0c1c74924fcd110866e89c966bc7909cfa8fa84/xxhash-3.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d610aa62cdb7d4d497740741772a24a794903bf3e79eaa51d2e800082abe11e5", size = 210445, upload-time = "2026-04-25T11:06:35.488Z" }, + { url = "https://files.pythonhosted.org/packages/72/33/d219975c0e8b6fa2eb9ccd486fe47e21bf1847985b878dd2fbc3126e0d5c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:073c23900a9fbf3d26616c17c830db28af9803677cd5b33aea3224d824111514", size = 241273, upload-time = "2026-04-25T11:06:37.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/50/49b1afe610eb3964cedcb90a4d4c3d46a261ee8669cbd4f060652619ae3c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:418a463c3e6a590c0cdc890f8be19adb44a8c8acd175ca5b2a6de77e61d0b386", size = 197950, upload-time = "2026-04-25T11:06:39.148Z" }, + { url = "https://files.pythonhosted.org/packages/c6/75/5f42a1a4c78717d906a4b6a140c6dbf837ab1f547a54d23c4e2903310936/xxhash-3.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:03f8ff4474ee61c845758ce00711d7087a770d77efb36f7e74a6e867301000b8", size = 210709, upload-time = "2026-04-25T11:06:40.958Z" }, + { url = "https://files.pythonhosted.org/packages/8a/85/237e446c25abced71e9c53d269f2cef5bab8a82b3f88a12e00c5368e7368/xxhash-3.7.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:44fba4a5f1d179b7ddc7b3dc40f56f9209046421679b57025d4d8821b376fd8d", size = 275345, upload-time = "2026-04-25T11:06:42.525Z" }, + { url = "https://files.pythonhosted.org/packages/62/34/c2c26c0a6a9cc739bc2a5f0ae03ba8b87deb12b8bce35f7ac495e790dc6d/xxhash-3.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31e3516a0f829d06ded4a2c0f3c7c5561993256bfa1c493975fb9dc7bfa828a1", size = 414056, upload-time = "2026-04-25T11:06:44.343Z" }, + { url = "https://files.pythonhosted.org/packages/a0/aa/5c58e9bc8071b8afd8dcf297ff362f723c4892168faba149f19904132bf4/xxhash-3.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b59ee2ac81de57771a09ecad09191e840a1d2fae1ef684208320591055768f83", size = 191485, upload-time = "2026-04-25T11:06:46.262Z" }, + { url = "https://files.pythonhosted.org/packages/d4/69/a929cf9d1e2e65a48b818cdce72cb6b69eab2e6877f21436d0a1942aff43/xxhash-3.7.0-cp312-cp312-win32.whl", hash = "sha256:74bbd92f8c7fcc397ba0a11bfdc106bc72ad7f11e3a60277753f87e7532b4d81", size = 30671, upload-time = "2026-04-25T11:06:48.039Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1b/104b41a8947f4e1d4a66ce1e628eea752f37d1890bfd7453559ca7a3d950/xxhash-3.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:7bd7bc82dd4f185f28f35193c2e968ef46131628e3cac62f639dadf321cba4d1", size = 31514, upload-time = "2026-04-25T11:06:49.279Z" }, + { url = "https://files.pythonhosted.org/packages/98/a0/1fd0ea1f1b886d9e7c73f0397571e22333a7d79e31da6d7127c2a4a71d75/xxhash-3.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:7d7148180ec99ba36585b42c8c5de25e9b40191613bc4be68909b4d25a77a852", size = 27761, upload-time = "2026-04-25T11:06:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ca/d5174b4c36d10f64d4ca7050563138c5a599efb01a765858ddefc9c1202a/xxhash-3.7.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:4b6d6b33f141158692bd4eafbb96edbc5aa0dabdb593a962db01a91983d4f8fa", size = 36813, upload-time = "2026-04-25T11:06:51.73Z" }, + { url = "https://files.pythonhosted.org/packages/41/d0/abc6c9d347ba1f1e1e1d98125d0881a0452c7f9a76a9dd03a7b5d2197f23/xxhash-3.7.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:845d347df254d6c619f616afa921331bada8614b8d373d58725c663ba97c3605", size = 35121, upload-time = "2026-04-25T11:06:53.048Z" }, + { url = "https://files.pythonhosted.org/packages/bf/11/4cc834eb3d79f2f2b3a6ef7324195208bcdfbdcf7534d2b17267aa5f3a8f/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:fddbbb69a6fff4f421e7a0d1fa28f894b20112e9e3fab306af451e2dfd0e459b", size = 29624, upload-time = "2026-04-25T11:06:54.311Z" }, + { url = "https://files.pythonhosted.org/packages/23/83/e97d3e7b635fe73a1dfb1e91f805324dd6d930bb42041cbf18f183bc0b6d/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:54876a4e45101cec2bf8f31a973cda073a23e2e108538dad224ba07f85f22487", size = 30638, upload-time = "2026-04-25T11:06:55.864Z" }, + { url = "https://files.pythonhosted.org/packages/f4/40/d84951d80c35db1f4c40a29a64a8520eea5d56e764c603906b4fe763580f/xxhash-3.7.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:0c72fe9c7e3d6dfd7f1e21e224a877917fa09c465694ba4e06464b9511b65544", size = 33323, upload-time = "2026-04-25T11:06:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/c7dc6558d97e9ab023f663d69ab28b340ed9bf4d2d94f2c259cf896bb354/xxhash-3.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a6d73a830b17ef49bc04e00182bd839164c1b3c59c127cd7c54fcb10c7ed8ee8", size = 33362, upload-time = "2026-04-25T11:06:58.656Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6e/46b84017b1301d54091430353d4ad5901654a3e0871649877a416f7f1644/xxhash-3.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91c3b07cf3362086d8f126c6aecd8e5e9396ad8b2f2219ea7e49a8250c318acd", size = 30874, upload-time = "2026-04-25T11:06:59.834Z" }, + { url = "https://files.pythonhosted.org/packages/df/5e/8f9158e3ab906ad3fec51e09b5ea0093e769f12207bfa42a368ca204e7ab/xxhash-3.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50e879ebbac351c81565ca108db766d7832f5b8b6a5b14b8c0151f7190028e3d", size = 194185, upload-time = "2026-04-25T11:07:01.658Z" }, + { url = "https://files.pythonhosted.org/packages/f3/29/a804ded9f5d3d3758292678d23e7528b08fda7b7e750688d08b052322475/xxhash-3.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:921c14e93817842dd0dd9f372890a0f0c72e534650b6ab13c5be5cd0db11d47e", size = 213033, upload-time = "2026-04-25T11:07:03.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/91/1ce5a7d2fdc975267320e2c78fc1cecfe7ab735ccbcf6993ec5dd541cb2c/xxhash-3.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e64a7c9d7dfca3e0fafcbc5e455519090706a3e36e95d655cec3e04e79f95aaa", size = 236140, upload-time = "2026-04-25T11:07:05.396Z" }, + { url = "https://files.pythonhosted.org/packages/34/04/fd595a4fd8617b05fa27bd9b684ecb4985bfed27917848eea85d54036d06/xxhash-3.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2220af08163baf5fa36c2b8af079dc2cbe6e66ae061385267f9472362dfd53c6", size = 212291, upload-time = "2026-04-25T11:07:06.966Z" }, + { url = "https://files.pythonhosted.org/packages/03/fb/f1a379cbc372ae5b9f4ab36154c48a849ca6ebe3ac477067a57865bf3bc6/xxhash-3.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f14bb8b22a4a91325813e3d553b8963c10cf8c756cff65ee50c194431296c655", size = 445532, upload-time = "2026-04-25T11:07:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/65/59/172424b79f8cfd4b6d8a122b2193e6b8ad4b11f7159bb3b6f9b3191329bb/xxhash-3.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:496736f86a9bedaf64b0dc70e3539d0766df01c71ea22032698e88f3f04a1ce9", size = 193990, upload-time = "2026-04-25T11:07:10.315Z" }, + { url = "https://files.pythonhosted.org/packages/b9/19/aeac22161d953f139f07ba5586cb4a17c5b7b6dff985122803bb12933500/xxhash-3.7.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0ff71596bd79816975b3de7130ab1ff4541410285a3c084584eeb1c8239996fd", size = 284876, upload-time = "2026-04-25T11:07:12.15Z" }, + { url = "https://files.pythonhosted.org/packages/77/d5/4fd0b59e7a02242953da05ff679fbb961b0a4368eac97a217e11dae110c1/xxhash-3.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1ad86695c19b1d46fe106925db3c7a37f16be37669dcf58dcc70a9dd6e324676", size = 210495, upload-time = "2026-04-25T11:07:13.952Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fb/976a3165c728c7faf74aa1b5ab3cf6a85e6d731612894741840524c7d28c/xxhash-3.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:970f9f8c50961d639cbd0d988c96f80ddf66006de93641719282c4fe7a87c5e6", size = 241331, upload-time = "2026-04-25T11:07:15.557Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2c/6763d5901d53ac9e6ba296e5717ae599025c9d268396e8faa8b4b0a8e0ac/xxhash-3.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5886ad85e9e347911783760a1d16cb6b393e8f9e3b52c982568226cb56927bdc", size = 198037, upload-time = "2026-04-25T11:07:17.563Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/876e722d533833f5f9a83473e6ba993e48745701096944e77bbecf29b2c3/xxhash-3.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6e934bbae1e0ec74e27d5f0d7f37ef547ce5ff9f0a7e63fb39e559fc99526734", size = 210744, upload-time = "2026-04-25T11:07:19.055Z" }, + { url = "https://files.pythonhosted.org/packages/21/e6/d7e7baef7ce24166b4668d3c48557bb35a23b92ecadcac7e7718d099ab69/xxhash-3.7.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:3b6b3d28228af044ebcded71c4a3dd86e1dbd7e2f4645bf40f7b5da65bb5fb5a", size = 275406, upload-time = "2026-04-25T11:07:20.908Z" }, + { url = "https://files.pythonhosted.org/packages/92/fe/198b3763b2e01ca908f2154969a2352ec99bda892b574a11a9a151c5ede4/xxhash-3.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:6be4d70d9ab76c9f324ead9c01af6ff52c324745ea0c3731682a0cf99720f1fe", size = 414125, upload-time = "2026-04-25T11:07:23.037Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6d/019a11affd5a5499137cacca53808659964785439855b5aa40dfd3412916/xxhash-3.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:151d7520838d4465461a0b7f4ae488b3b00de16183dd3214c1a6b14bf89d7fb6", size = 191555, upload-time = "2026-04-25T11:07:24.991Z" }, + { url = "https://files.pythonhosted.org/packages/76/21/b96d58568df2d01533244c3e0e5cbdd0c8b2b25c4bec4d72f19259a292d7/xxhash-3.7.0-cp313-cp313-win32.whl", hash = "sha256:d798c1e291bffb8e37b5bbe0dda77fc767cd19e89cadaf66e6ed5d0ff88c9fe6", size = 30668, upload-time = "2026-04-25T11:07:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/99/57/d849a8d3afa1f8f4bc6a831cd89f49f9706fbbad94d2975d6140a171988c/xxhash-3.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:875811ba23c543b1a1c3143c926e43996eb27ebb8f52d3500744aa608c275aed", size = 31524, upload-time = "2026-04-25T11:07:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/81/52/bacc753e92dee78b058af8dcef0a50815f5f860986c664a92d75f965b6a5/xxhash-3.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:54a675cb300dda83d71daae2a599389d22db8021a0f8db0dd659e14626eb3ecc", size = 27768, upload-time = "2026-04-25T11:07:29.113Z" }, + { url = "https://files.pythonhosted.org/packages/1c/47/ddbd683b7fc7e592c1a8d9d65f73ce9ab513f082b3967eee2baf549b8fc6/xxhash-3.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a3b19a42111c4057c1547a4a1396a53961dca576a0f6b82bfa88a2d1561764b2", size = 33576, upload-time = "2026-04-25T11:07:30.469Z" }, + { url = "https://files.pythonhosted.org/packages/07/f2/36d3310161db7f72efb4562aadde0ed429f1d0531782dd6345b12d2da527/xxhash-3.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8f4608a06e4d61b7a3425665a46d00e0579122e1a2fae97a0c52953a3aad9aa3", size = 31123, upload-time = "2026-04-25T11:07:31.989Z" }, + { url = "https://files.pythonhosted.org/packages/0d/3f/75937a5c69556ed213021e43cbedd84c8e0279d0d74e7d41a255d84ba4b1/xxhash-3.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ad37c7792479e49cf96c1ab25517d7003fe0d93687a772ba19a097d235bbe41e", size = 196491, upload-time = "2026-04-25T11:07:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/f10d7ff8c7a733d4403a43b9de18c8fabc005f98cec054644f04418659ee/xxhash-3.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc026e3b89d98e30a8288c95cb696e77d150b3f0fb7a51f73dcd49ee6b5577fa", size = 215793, upload-time = "2026-04-25T11:07:34.919Z" }, + { url = "https://files.pythonhosted.org/packages/8b/fd/778f60aa295f58907938f030a8b514611f391405614a525cccd2ffc00eb5/xxhash-3.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9b31ab1f28b078a6a1ac1a54eb35e7d5390deddd56870d0be3a0a733d1c321c", size = 237993, upload-time = "2026-04-25T11:07:36.638Z" }, + { url = "https://files.pythonhosted.org/packages/70/f5/736db5de387b4a540e37a05b84b40dc58a1ce974bfd2b4e5754ce29b68c3/xxhash-3.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3bb5fd680c038fd5229e44e9c493782f90df9bef632fd0499d442374688ff70b", size = 214887, upload-time = "2026-04-25T11:07:38.564Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/09a095f22fdb9a27fbb716841fbff52119721f9ca4261952d07a912f7839/xxhash-3.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:030c0fd688fce3569fbb49a2feefd4110cbb0b650186fb4610759ecfac677548", size = 448407, upload-time = "2026-04-25T11:07:40.552Z" }, + { url = "https://files.pythonhosted.org/packages/74/8a/b745efeeca9e34a91c26fdc97ad8514c43d5a81ac78565cba80a1353870a/xxhash-3.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b1bde10324f4c31812ae0d0502e92d916ae8917cad7209353f122b8b8f610c3", size = 196119, upload-time = "2026-04-25T11:07:42.101Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5c/0cfceb024af90c191f665c7933b1f318ee234f4797858383bebd1881d52f/xxhash-3.7.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:503722d52a615f2604f5e7611de7d43878df010dc0053094ef91cb9a9ac3d987", size = 286751, upload-time = "2026-04-25T11:07:43.568Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0a/0793e405dc3cf8f4ebe2c1acec1e4e4608cd9e7e50ea691dabbc2a95ccbb/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c72500a3b6d6c30ebfc135035bcace9eb5884f2dc220804efcaaba43e9f611dd", size = 212961, upload-time = "2026-04-25T11:07:45.388Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7e/721118ffc63bfff94aa565bcf2555a820f9f4bdb0f001e0d609bdfad70de/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:43475925a766d01ca8cd9a857fd87f3d50406983c8506a4c07c4df12adcc867f", size = 243703, upload-time = "2026-04-25T11:07:47.053Z" }, + { url = "https://files.pythonhosted.org/packages/6e/18/16f6267160488b8276fd3d449d425712512add292ba545c1b6946bfdb7dd/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8d09dfd2ab135b985daf868b594315ebe11ad86cd9fea46e6c69f19b28f7d25a", size = 200894, upload-time = "2026-04-25T11:07:48.657Z" }, + { url = "https://files.pythonhosted.org/packages/2d/94/80ba841287fd97e3e9cac1d228788c8ef623746f570404961eec748ecb5c/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c50269d0055ac1faecfd559886d2cbe4b730de236585aba0e873f9d9dadbe585", size = 213357, upload-time = "2026-04-25T11:07:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7e/106d4067130c59f1e18a55ffadcd876d8c68534883a1e02685b29d3d8153/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1910df4756a5ab58cfad8744fc2d0f23926e3efcc346ee76e87b974abab922f4", size = 277600, upload-time = "2026-04-25T11:07:51.745Z" }, + { url = "https://files.pythonhosted.org/packages/c5/86/a081dd30da71d720b2612a792bfd55e45fa9a07ac76a0507f60487473c25/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d006faf3b491957efcb433489be3c149efe4787b7063d5cddb8ddaefdc60e0c1", size = 416980, upload-time = "2026-04-25T11:07:53.504Z" }, + { url = "https://files.pythonhosted.org/packages/35/29/1a95221a029a3c1293773869e1ab47b07cbbdd82444a42809e8c60156626/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:abb65b4e947e958f7b3b0d71db3ce447d1bc5f37f5eab871ce7223bda8768a04", size = 193840, upload-time = "2026-04-25T11:07:55.103Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/db909dd0823285de2286f67e10ee4d81e96ad35d7d8e964ecb07fccd8af9/xxhash-3.7.0-cp313-cp313t-win32.whl", hash = "sha256:178959906cb1716a1ce08e0d69c82886c70a15a6f2790fc084fdd146ca30cd49", size = 30966, upload-time = "2026-04-25T11:07:56.524Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ff/d705b15b22f21ee106adce239cb65d35067a158c630b240270f09b17c2e6/xxhash-3.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2524a1e20d4c231d13b50f7cf39e44265b055669a64a7a4b9a2a44faa03f19b6", size = 31784, upload-time = "2026-04-25T11:07:57.758Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1f/b2cf83c3638fd0588e0b17f22e5a9400bdfb1a3e3755324ac0aee2250b88/xxhash-3.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:37d994d0ffe81ef087bb330d392caa809bb5853c77e22ea3f71db024a0543dba", size = 27932, upload-time = "2026-04-25T11:07:59.109Z" }, +] + [[package]] name = "yarl" -version = "1.23.0" +version = "1.24.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, - { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, - { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, - { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, - { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, - { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, - { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, - { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, - { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, - { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, - { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, - { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, - { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, - { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, - { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, - { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, - { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, - { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, - { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, - { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, - { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, - { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, - { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, - { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, - { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, - { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, - { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, - { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, - { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, - { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, - { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, - { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, - { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, - { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, - { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, - { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, - { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, - { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, - { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, - { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, - { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, - { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, - { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, - { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, - { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, + { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, + { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, ] [[package]]