Merge origin/v8 into pr-1136 — resolve codex .codex path + version bump

This commit is contained in:
Safi
2026-06-07 14:32:01 +01:00
94 changed files with 3558 additions and 293 deletions
+25
View File
@@ -0,0 +1,25 @@
.git
.github
.venv
venv
__pycache__
*.pyc
.pytest_cache
.ruff_cache
.mypy_cache
dist
build
*.egg-info
graphify-out
graphify-benchmark
graphify_eval
graphify_test
worked
llm-stack-corpus
llm-stack-demo
product-site
ebook
tests
docs
*.md
!README.md
+25
View File
@@ -2,6 +2,31 @@
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
## 0.8.34 (2026-06-07)
- Feat: Streamable HTTP transport for the MCP server. `python -m graphify.serve graph.json --transport http --port 8080 --api-key $SECRET` serves the graph over the MCP Streamable HTTP transport (spec 2025-03-26) so a single shared process can serve the whole team. Flags: `--host`, `--port`, `--api-key` (env `GRAPHIFY_API_KEY`), `--path`, `--json-response`, `--stateless`, `--session-timeout`. Docker image included. stdio remains the default (#1143).
- Feat: Salesforce Apex extractor. `.cls` and `.trigger` files are now AST-extracted via regex (no tree-sitter grammar exists for Apex). Extracts classes, interfaces, enums, methods, triggers, and SOQL/DML edges (#1159).
- Feat: Azure OpenAI Service backend. `--backend azure` reads `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_ENDPOINT` and auto-detects both. Uses the existing `openai` package — no new dependency (#1107).
- Feat: live PostgreSQL introspection. `graphify extract --postgres "postgresql://..."` connects directly to a running database and maps tables, views, routines, and FK relations via `information_schema` in a `SERIALIZABLE READ ONLY` transaction. New `graphify[postgres]` extra (psycopg3). Credentials are sanitized from error messages (#1103).
- Feat: vision and PDF support in headless extract. Images now route through per-backend vision payloads (base64/data-URI for claude/openai, file path for claude-cli, bytes for bedrock) instead of producing garbage binary data. Non-vision backends get a text reference via `_strip_pixels`. PDFs reuse pypdf. 5MB cap, 20-image chunk limit (#1110).
- Fix: `graphify update` now prunes symbols removed from files that still exist on disk. Previously, deleting a function left a ghost node in the graph until the source file itself was deleted. Every AST node is now stamped with `_origin="ast"`; on a full rebuild any stamped node absent from the fresh output is dropped (#1118).
- Fix: `graphify path` and `shortest_path` now fire the exact-match bonus for multi-word queries. The per-token comparison never equalled a full multi-word label, so the exact bonus was silently skipped for queries like `"AuthService"` when the label contained punctuation or spaces. The full normalized query is now compared alongside each token (#1165).
- Fix: `_is_sensitive` no longer flags topic-mentioning filenames as secrets. `token-economics-of-recall.md` and `password-policy-discussion.md` were silently dropped. Generic keywords (token/secret/password) now only fire when the keyword ends the filename stem or the stem is ≤2 words; specific patterns (`.env`, `.pem`, `id_rsa`, etc.) remain unconditional (#1169).
- Fix: git hooks no longer use `nohup` to background the rebuild. Git for Windows' MSYS shell has no `nohup`, causing the post-commit/post-checkout hook to fail silently and the graph to go stale. Replaced with a cross-platform Python launcher using `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` on Windows and `start_new_session=True` on POSIX (#1161 / #1170).
- Fix: post-commit and post-checkout hooks now respect an existing `.graphify_root`. A scoped build (`graphify src/`) was silently expanded to the full repo on the next commit because the hook hardcoded `Path('.')`. The hook body now reads `graphify-out/.graphify_root` first (#1173).
- Fix: `graphify affected` now forces a directed graph on load, matching the identical fix already applied in `serve.py` and `__main__.py`. On undirected graphs (`"directed": false` in graph.json) the traversal was direction-blind — missing true callers and reporting callees as affected (#1174).
- Fix: Step 9 skill cleanup no longer aborts under fish/zsh on pure-code corpora. The `rm -f ... .graphify_chunk_*.json` glob errored with "no matches found" when no chunk files existed, leaving other temp files on disk. Split into `rm -f` for fixed filenames and `find -maxdepth 1 -delete` for the chunk glob (#1172).
- Fix: `detect_incremental` no longer crashes on schema-drifted manifest files. A dict-valued `mtime` entry (from an older richer schema) is now coerced to `None` and the file is treated as new rather than raising a comparison error (#1163).
- Fix: numpy pinned to `>=2.0` only on Python 3.13+ in the `svg` and `all` extras. numpy 1.26.4 ships no `cp313` wheel so `uv sync` fell back to a source build requiring a C compiler (#1153 / #1154).
- Fix: Codex platform skill now installs to `.codex/skills/graphify/` (was `.agents/skills/graphify/`), aligning with where the hook already lives (#1160).
## 0.8.33 (2026-06-06)
- Feat: install banner — `graphify install` now prints an amber knowledge-graph brain in the terminal (TTY-only, silent in CI/pipes, never raises).
- Fix: Python `from pkg import submod` package-form imports now resolve to a file-level `imports_from` edge to the submodule file when it exists on disk. Previously these imports produced zero edges, leaving test files as disconnected islands in the graph (up to 66% of test nodes in some corpora). The fix lives in the symbol-resolution post-pass which has filesystem access (#1146).
- Fix: builtin type-annotation nodes (`str`, `int`, `bool`, `float`, `bytes`, `MagicMock`, `Mock`, `AsyncMock`, etc.) no longer appear as graph nodes or accumulate edges. They were being created via the annotation walker whenever used as parameter or return types, inflating degree counts ~25% and displacing real abstractions from god-node rankings. A new `_PYTHON_ANNOTATION_NOISE` filter suppresses them at extraction time; `god_nodes` also filters them as a defense for pre-existing graphs (#1147).
- Fix: AST/semantic ghost-duplicate nodes are now auto-merged at build time. When AST and semantic extraction produce different IDs for the same symbol (one with `source_location=L<n>`, one without), `build_from_json` detects the pair by `(source_file basename, label)` and collapses the semantic ghost into the AST node, re-pointing all edges. Graphs built before this release can be cleaned up with `graphify extract . --force` (#1145).
## 0.8.32 (2026-06-05)
- Feat: Terraform/HCL support. `.tf`, `.tfvars`, and `.hcl` files are now AST-extracted via `tree-sitter-hcl` into a structured infrastructure dependency graph. Nodes: resources, data sources, modules, variables, outputs, providers, and locals. Edges: `contains`, `references` (interpolation), and `depends_on`. Node IDs are directory-scoped for cross-file resolution. Requires `uv tool install "graphifyy[terraform]"` (#1129).
+25
View File
@@ -0,0 +1,25 @@
# graphify MCP server as a shared HTTP service (issue #1143).
#
# Build: docker build -t graphify .
# Run: docker run -p 8080:8080 -v "$(pwd)/graphify-out:/data" graphify \
# /data/graph.json --transport http --host 0.0.0.0 --api-key "$SECRET"
#
# Builds from source so the image includes the Streamable HTTP transport even
# before it lands on PyPI. The graph.json is mounted at runtime (-v), never
# baked into the image.
FROM python:3.12-slim
WORKDIR /app
COPY . /app
# The [mcp] extra pulls mcp + starlette + uvicorn, which the HTTP transport needs.
RUN pip install --no-cache-dir ".[mcp]"
# Run as a non-root user — the server is network-exposed.
RUN useradd --create-home --uid 10001 graphify
USER graphify
EXPOSE 8080
ENTRYPOINT ["python", "-m", "graphify.serve"]
CMD ["/data/graph.json", "--transport", "http", "--host", "0.0.0.0", "--port", "8080"]
+38 -2
View File
@@ -171,7 +171,9 @@ Install only what you need:
| `gemini` | Google Gemini API | `uv tool install "graphifyy[gemini]"` |
| `anthropic` | Anthropic Claude API (`--backend claude`, uses `ANTHROPIC_API_KEY`) | `uv tool install "graphifyy[anthropic]"` |
| `bedrock` | AWS Bedrock (uses IAM, no API key) | `uv tool install "graphifyy[bedrock]"` |
| `azure` | Azure OpenAI Service (`--backend azure`, uses `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_ENDPOINT`) | `uv tool install "graphifyy[openai]"` |
| `sql` | SQL schema extraction | `uv tool install "graphifyy[sql]"` |
| `postgres` | Live PostgreSQL introspection (`--postgres DSN`) | `uv tool install "graphifyy[postgres]"` |
| `dm` | BYOND DreamMaker `.dm`/`.dme` AST extraction (may need a C compiler + `python3-dev` if no wheel matches your platform) | `uv tool install "graphifyy[dm]"` |
| `terraform` | Terraform / HCL `.tf`/`.tfvars`/`.hcl` AST extraction | `uv tool install "graphifyy[terraform]"` |
| `chinese` | Chinese query segmentation (jieba) | `uv tool install "graphifyy[chinese]"` |
@@ -236,6 +238,7 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg
| Type | Extensions |
|------|-----------|
| Code (28 tree-sitter grammars) | `.py .ts .js .jsx .tsx .mjs .go .rs .java .c .cpp .h .hpp .rb .cs .kt .scala .php .swift .lua .luau .zig .ps1 .ex .exs .m .mm .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .csproj .fsproj .vbproj .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`) |
| Salesforce Apex | `.cls .trigger` (regex-based; classes, interfaces, enums, methods, triggers, SOQL/DML edges) |
| Terraform / HCL | `.tf .tfvars .hcl` (requires `uv tool install graphifyy[terraform]`) |
| MCP configs | `.mcp.json` `mcp.json` `mcp_servers.json` `claude_desktop_config.json` — extracts server nodes, package refs, env var requirements |
| Docs | `.md .mdx .qmd .html .txt .rst .yaml .yml` |
@@ -348,10 +351,37 @@ python -m graphify.serve graphify-out/graph.json
# register with Kimi Code:
kimi mcp add --transport stdio graphify -- python -m graphify.serve graphify-out/graph.json
# or serve over HTTP so a whole team points at one URL (no local graphify needed):
python -m graphify.serve graphify-out/graph.json --transport http --port 8080
python -m graphify.serve graphify-out/graph.json --transport http --host 0.0.0.0 --api-key "$SECRET"
```
The MCP server gives your assistant structured access: `query_graph`, `get_node`, `get_neighbors`, `shortest_path`, `list_prs`, `get_pr_impact`, `triage_prs`.
### Shared HTTP server
`--transport stdio` (the default) spawns one local server per developer. `--transport http` serves the same tools over the MCP Streamable HTTP transport, so a single shared process can serve the graph for the whole team — clients point their IDE MCP config at `http://<host>:8080/mcp` instead of running graphify locally.
| Flag | Default | Purpose |
|---|---|---|
| `--transport {stdio,http}` | `stdio` | Transport to serve on |
| `--host` | `127.0.0.1` | HTTP bind host (use `0.0.0.0` to expose beyond localhost) |
| `--port` | `8080` | HTTP bind port |
| `--api-key` | env `GRAPHIFY_API_KEY` | Require `Authorization: Bearer <key>` (or `X-API-Key`) |
| `--path` | `/mcp` | HTTP mount path |
| `--json-response` | off | Return plain JSON instead of SSE streams |
| `--stateless` | off | No per-session state (for load-balanced / CI deployments) |
| `--session-timeout` | `3600` | Reap idle stateful sessions after N seconds (`0` disables) |
The default `127.0.0.1` bind is loopback-only. Set `--host 0.0.0.0` **and** `--api-key` together when exposing on a shared host. Run it in a container:
```bash
docker build -t graphify .
docker run -p 8080:8080 -v "$(pwd)/graphify-out:/data" graphify \
/data/graph.json --transport http --host 0.0.0.0 --api-key "$SECRET"
```
> **WSL / Linux note:** Ubuntu ships `python3`, not `python`. Use a venv to avoid conflicts:
> ```bash
> python3 -m venv .venv && .venv/bin/pip install "graphifyy[mcp]"
@@ -374,6 +404,10 @@ These are only needed for **headless / CI extraction** (`graphify extract`). Whe
| `OLLAMA_MODEL` | Ollama model name | `--backend ollama` (default: auto-detect) |
| `GRAPHIFY_OLLAMA_NUM_CTX` | Override Ollama KV-cache window size | optional — auto-sized by default |
| `GRAPHIFY_OLLAMA_KEEP_ALIVE` | Minutes to keep Ollama model loaded | optional — set `0` to unload after each chunk |
| `AZURE_OPENAI_API_KEY` | Azure OpenAI Service backend | `--backend azure` |
| `AZURE_OPENAI_ENDPOINT` | Azure resource endpoint URL | `--backend azure` (required alongside API key) |
| `AZURE_OPENAI_API_VERSION` | Azure API version override | optional — default `2024-12-01-preview` |
| `AZURE_OPENAI_DEPLOYMENT` or `GRAPHIFY_AZURE_MODEL` | Azure deployment name | optional — default `gpt-4o` |
| `AWS_*` / `~/.aws/credentials` | AWS Bedrock — standard credential chain | `--backend bedrock` (no API key, uses IAM) |
| `GRAPHIFY_MAX_WORKERS` | AST parallelism thread count | optional — also `--max-workers` flag |
| `GRAPHIFY_MAX_OUTPUT_TOKENS` | Raise output cap for dense corpora | optional — e.g. `32768` for large files |
@@ -393,7 +427,7 @@ These are only needed for **headless / CI extraction** (`graphify extract`). Whe
- **Code files** — processed locally via tree-sitter. Nothing leaves your machine. A code-only corpus requires no API key — `graphify extract` runs fully offline.
- **Video / audio** — transcribed locally with faster-whisper. Nothing leaves your machine.
- **Docs, PDFs, images** — sent to your AI assistant for semantic extraction (via the `/graphify` skill, using whatever model your IDE session runs). Headless `graphify extract` requires `GEMINI_API_KEY` / `GOOGLE_API_KEY` (Gemini), `MOONSHOT_API_KEY` (Kimi), `ANTHROPIC_API_KEY` (Claude), `OPENAI_API_KEY` (OpenAI), `DEEPSEEK_API_KEY` (DeepSeek), a running Ollama instance (`OLLAMA_BASE_URL`), AWS credentials via the standard provider chain (Bedrock - no API key needed, uses IAM), or the `claude` CLI binary (Claude Code - no API key needed, uses your Claude subscription). The `--dedup-llm` flag uses the same key.
- **Data residency**`graphify extract` auto-detects which provider to use based on which API key is set (priority: Gemini → Kimi → Claude → OpenAI → DeepSeek → Bedrock → Ollama). For code with data-residency requirements, use `--backend ollama` (fully local) or pass an explicit `--backend` flag. Kimi (`MOONSHOT_API_KEY`) routes to Moonshot AI servers in China.
- **Data residency**`graphify extract` auto-detects which provider to use based on which API key is set (priority: Gemini → Kimi → Claude → OpenAI → DeepSeek → Azure → Bedrock → Ollama). For code with data-residency requirements, use `--backend ollama` (fully local) or pass an explicit `--backend` flag. Kimi (`MOONSHOT_API_KEY`) routes to Moonshot AI servers in China.
- No telemetry, no usage tracking, no analytics.
- **Query logging** — every `graphify query`, `graphify path`, `graphify explain`, and MCP `query_graph` call is logged to `~/.cache/graphify-queries.log` in JSON Lines format (timestamp, question, corpus, nodes returned, duration). Full subgraph responses are **not** stored by default. Set `GRAPHIFY_QUERY_LOG_DISABLE=1` to opt out, or `GRAPHIFY_QUERY_LOG=/dev/null` to silence without disabling the code path.
@@ -417,7 +451,7 @@ PowerShell treats a leading `/` as a path separator. Use `graphify .` (no slash)
If a refactor deleted files, the old nodes linger. Pass `--force` (or set `GRAPHIFY_FORCE=1`) to overwrite even when the rebuild has fewer nodes.
**Graph has duplicate nodes for the same entity (ghost duplicates)**
This happens when semantic and AST extraction disagreed on the node ID format. Run a full re-extract to clean up:
Ghost duplicates (same symbol appearing twice — once from AST extraction with a source location, once from semantic extraction without) are now automatically merged at build time. If you see this in a graph built before v0.8.33, run a full re-extract to clean up:
```bash
graphify extract . --force
```
@@ -535,7 +569,9 @@ GRAPHIFY_OLLAMA_NUM_CTX=32768 graphify extract ./docs --backend ollama # overr
GRAPHIFY_OLLAMA_KEEP_ALIVE=0 graphify extract ./docs --backend ollama # unload model after each chunk (saves VRAM on small GPUs)
graphify extract ./docs --backend bedrock # AWS Bedrock via IAM - no API key, uses AWS credential chain
graphify extract ./docs --backend claude-cli # route through Claude Code CLI - no API key, uses your Claude subscription
graphify extract ./docs --backend azure # Azure OpenAI (set AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT)
graphify extract ./docs --max-workers 16 # AST parallelism (also GRAPHIFY_MAX_WORKERS)
graphify extract --postgres "postgresql://user:pass@host/db" # introspect live PostgreSQL schema directly
graphify extract ./docs --token-budget 30000 # smaller semantic chunks for local/small models
graphify extract ./docs --max-concurrency 2 # fewer parallel LLM calls (useful for local inference)
graphify extract ./docs --api-timeout 900 # longer HTTP timeout for slow local models (default 600s)
+53 -18
View File
@@ -415,7 +415,7 @@ _PLATFORM_CONFIG: dict[str, dict] = {
},
"codex": {
"skill_file": "skill-codex.md",
"skill_dst": Path(".agents") / "skills" / "graphify" / "SKILL.md",
"skill_dst": Path(".codex") / "skills" / "graphify" / "SKILL.md",
"claude_md": False,
"skill_refs": "codex",
},
@@ -2168,6 +2168,9 @@ def main() -> None:
print(" --out DIR output dir (default: <path>); writes <DIR>/graphify-out/")
print(" --google-workspace export .gdoc/.gsheet/.gslides shortcuts via gws before extraction")
print(" --no-cluster skip clustering, write raw extraction only")
print(" --postgres DSN extract schema from a live PostgreSQL database")
print(" maps tables, views, functions + FK relationships;")
print(" column-level detail is not represented in the graph")
print(" --global also merge the resulting graph into the global graph")
print(" --as <tag> repo tag for --global (default: target directory name)")
print(" global add <graph.json> add/update a project graph in the global graph (~/.graphify/global-graph.json)")
@@ -3841,20 +3844,26 @@ def main() -> None:
"Usage: graphify extract <path> [--backend gemini|kimi|claude|openai|deepseek|ollama] "
"[--model M] [--mode deep] [--out DIR] [--google-workspace] [--no-cluster] "
"[--max-workers N] [--token-budget N] [--max-concurrency N] "
"[--api-timeout S]",
"[--api-timeout S] [--postgres DSN]",
file=sys.stderr,
)
sys.exit(1)
target = Path(sys.argv[2]).resolve()
if not target.exists():
print(f"error: path not found: {target}", file=sys.stderr)
sys.exit(1)
has_path = True
if sys.argv[2].startswith("-"):
has_path = False
target = Path(".").resolve()
else:
target = Path(sys.argv[2]).resolve()
if not target.exists():
print(f"error: path not found: {target}", file=sys.stderr)
sys.exit(1)
backend: str | None = None
model: str | None = None
extract_mode: str | None = None
out_dir: Path | None = None
cli_postgres_dsn: str | None = None
no_cluster = False
dedup_llm = False
google_workspace = False
@@ -3892,7 +3901,7 @@ def main() -> None:
sys.exit(2)
return v
args = sys.argv[3:]
args = sys.argv[3:] if has_path else sys.argv[2:]
i = 0
while i < len(args):
a = args[i]
@@ -3950,9 +3959,17 @@ def main() -> None:
cli_excludes.append(args[i + 1]); i += 2
elif a.startswith("--exclude="):
cli_excludes.append(a.split("=", 1)[1]); i += 1
elif a == "--postgres" and i + 1 < len(args):
cli_postgres_dsn = args[i + 1]; i += 2
elif a.startswith("--postgres="):
cli_postgres_dsn = a.split("=", 1)[1]; i += 1
else:
i += 1
if not has_path and cli_postgres_dsn is None:
print("error: must specify a path to scan or a --postgres DSN", file=sys.stderr)
sys.exit(1)
_VALID_MODES = {"deep"}
if extract_mode is not None and extract_mode not in _VALID_MODES:
print(
@@ -3986,9 +4003,17 @@ def main() -> None:
)
manifest_path = graphify_out / "manifest.json"
existing_graph_path = graphify_out / "graph.json"
incremental_mode = manifest_path.exists() and existing_graph_path.exists()
incremental_mode = manifest_path.exists() and existing_graph_path.exists() if has_path else False
if incremental_mode:
if not has_path:
code_files = []
doc_files = []
paper_files = []
image_files = []
deleted_files = []
unchanged_total = 0
files_by_type = {}
elif incremental_mode:
print(f"[graphify extract] incremental scan of {target}")
detection = _detect_incremental(
target,
@@ -3996,12 +4021,7 @@ def main() -> None:
google_workspace=google_workspace or None,
extra_excludes=cli_excludes or None,
)
else:
print(f"[graphify extract] scanning {target}")
detection = _detect(target, google_workspace=google_workspace or None, extra_excludes=cli_excludes or None)
files_by_type = detection.get("files", {})
if incremental_mode:
files_by_type = detection.get("files", {})
new_by_type = detection.get("new_files", {})
code_files = [Path(p) for p in new_by_type.get("code", [])]
doc_files = [Path(p) for p in new_by_type.get("document", [])]
@@ -4010,6 +4030,9 @@ def main() -> None:
deleted_files = list(detection.get("deleted_files", []))
unchanged_total = sum(len(v) for v in detection.get("unchanged_files", {}).values())
else:
print(f"[graphify extract] scanning {target}")
detection = _detect(target, google_workspace=google_workspace or None, extra_excludes=cli_excludes or None)
files_by_type = detection.get("files", {})
code_files = [Path(p) for p in files_by_type.get("code", [])]
doc_files = [Path(p) for p in files_by_type.get("document", [])]
paper_files = [Path(p) for p in files_by_type.get("paper", [])]
@@ -4227,13 +4250,25 @@ def main() -> None:
sem_result["input_tokens"] += fresh.get("input_tokens", 0)
sem_result["output_tokens"] += fresh.get("output_tokens", 0)
# Merge AST + semantic. Order matters for deduplication: passing AST
pg_result: dict = {"nodes": [], "edges": []}
if cli_postgres_dsn is not None:
from graphify.pg_introspect import introspect_postgres
print(f"[graphify extract] introspecting PostgreSQL schema...")
try:
pg_result = introspect_postgres(cli_postgres_dsn)
except (ConnectionError, ImportError) as exc:
print(f"error: {exc}", file=sys.stderr)
sys.exit(1)
print(f"[graphify extract] PostgreSQL: {len(pg_result['nodes'])} nodes, "
f"{len(pg_result['edges'])} edges")
# Merge AST + semantic + pg_result. Order matters for deduplication: passing AST
# first means semantic node attributes win on collision (richer labels
# for symbols also referenced in docs). Hyperedges only come from the
# semantic side.
merged: dict = {
"nodes": list(ast_result.get("nodes", [])) + list(sem_result.get("nodes", [])),
"edges": list(ast_result.get("edges", [])) + list(sem_result.get("edges", [])),
"nodes": list(ast_result.get("nodes", [])) + list(sem_result.get("nodes", [])) + list(pg_result.get("nodes", [])),
"edges": list(ast_result.get("edges", [])) + list(sem_result.get("edges", [])) + list(pg_result.get("edges", [])),
"hyperedges": list(sem_result.get("hyperedges", [])),
"input_tokens": ast_result.get("input_tokens", 0) + sem_result.get("input_tokens", 0),
"output_tokens": ast_result.get("output_tokens", 0) + sem_result.get("output_tokens", 0),
+3
View File
@@ -145,6 +145,9 @@ def load_graph(path: Path) -> nx.Graph:
from networkx.readwrite import json_graph
raw = json.loads(path.read_text(encoding="utf-8"))
# Force directed so stored caller→callee direction survives the round-trip;
# mirrors serve.py and __main__.py (#1174).
raw = {**raw, "directed": True}
try:
return json_graph.node_link_graph(raw, edges="links")
except TypeError:
+12
View File
@@ -5,6 +5,16 @@ import networkx as nx
from graphify.build import edge_data
# Builtin/mock names that can appear as annotation-derived nodes in pre-existing
# graphs. Excluded from god-node ranking so they don't displace real abstractions
# even if they weren't filtered at extraction time (#1147).
_BUILTIN_NOISE_LABELS = frozenset({
"str", "int", "float", "bool", "bytes", "bytearray", "complex", "object",
"True", "False",
"MagicMock", "Mock", "AsyncMock", "NonCallableMock",
"NonCallableMagicMock", "PropertyMock", "patch", "sentinel",
})
# Language families — extensions sharing a runtime can legitimately call each other
_LANG_FAMILY: dict[str, str] = {
**{e: "python" for e in (".py", ".pyw")},
@@ -94,6 +104,8 @@ def god_nodes(G: nx.Graph, top_n: int = 10) -> list[dict]:
for node_id, deg in sorted_nodes:
if _is_file_node(G, node_id) or _is_concept_node(G, node_id) or _is_json_key_node(G, node_id):
continue
if G.nodes[node_id].get("label", "") in _BUILTIN_NOISE_LABELS:
continue
result.append({
"id": node_id,
"label": G.nodes[node_id].get("label", node_id),
+43
View File
@@ -156,10 +156,53 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
node["source_file"] = _norm_source_file(node["source_file"], _root)
G.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"})
node_set = set(G.nodes())
# #1145: merge semantic ghost-duplicate nodes into AST nodes.
# When AST and semantic extractors emit different IDs for the same symbol
# (one has source_location=L<n>, the other has source_location=None), find
# pairs that share (source_file basename, label) and collapse the semantic
# copy into the AST copy so edges re-point to a single node.
# Two passes: first collect all AST (located) nodes, then find ghosts.
_loc_nodes: dict[tuple[str, str], str] = {} # (basename, label) -> AST node id
_noloc_nodes: dict[tuple[str, str], str] = {} # (basename, label) -> semantic node id
for nid in node_set:
attrs = G.nodes[nid]
label = str(attrs.get("label", "")).strip()
sf = str(attrs.get("source_file", ""))
basename = Path(sf).name if sf else ""
if not label or not basename:
continue
if attrs.get("source_location"):
_loc_nodes[(basename, label)] = nid
for nid in node_set:
attrs = G.nodes[nid]
label = str(attrs.get("label", "")).strip()
sf = str(attrs.get("source_file", ""))
basename = Path(sf).name if sf else ""
if not label or not basename or attrs.get("source_location"):
continue
key = (basename, label)
if key in _loc_nodes and _loc_nodes[key] != nid:
_noloc_nodes[key] = nid
# For every ghost that has an AST counterpart, record a remap.
_ghost_remap: dict[str, str] = {} # ghost_id -> canonical_id
for key, sem_id in _noloc_nodes.items():
ast_id = _loc_nodes.get(key)
if ast_id is not None:
_ghost_remap[sem_id] = ast_id
# Remove ghost nodes from the graph; edges will be re-pointed via norm_to_id.
for ghost_id in _ghost_remap:
G.remove_node(ghost_id)
node_set.discard(ghost_id)
# Normalized ID map: lets edges survive when the LLM generates IDs with
# slightly different casing or punctuation than the AST extractor.
# e.g. "Session_ValidateToken" maps to "session_validatetoken".
norm_to_id: dict[str, str] = {_normalize_id(nid): nid for nid in node_set}
# Also map ghost IDs to their canonical AST replacements.
for ghost_id, canonical_id in _ghost_remap.items():
norm_to_id[_normalize_id(ghost_id)] = canonical_id
norm_to_id[ghost_id] = canonical_id
# Iterate edges in a deterministic order. The graph is undirected and stores
# direction in _src/_tgt; when two edges collapse onto the same node pair the
# last write wins, so an unstable iteration order flips _src/_tgt run-to-run
+5 -1
View File
@@ -186,7 +186,11 @@ def deduplicate_entities(
for node in group:
sf = node.get("source_file") or ""
by_file[sf].append(node)
for file_group in by_file.values():
for sf, file_group in by_file.items():
if not sf:
# No source_file — cannot prove same symbol; skip to avoid
# collapsing distinct nodes that happen to share a label (#1178).
continue
if len(file_group) > 1:
winner = _pick_winner(file_group)
for node in file_group:
+55 -9
View File
@@ -25,7 +25,7 @@ class FileType(str, Enum):
_MANIFEST_PATH = "graphify-out/manifest.json"
CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.csproj', '.fsproj', '.vbproj', '.razor', '.cshtml'}
CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.csproj', '.fsproj', '.vbproj', '.razor', '.cshtml', '.cls', '.trigger'}
DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.txt', '.rst', '.html', '.yaml', '.yml'}
PAPER_EXTENSIONS = {'.pdf'}
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
@@ -98,23 +98,60 @@ _SENSITIVE_DIRS = frozenset({
".ssh", ".gnupg", ".aws", ".gcloud", "secrets", ".secrets", "credentials",
})
# Files that may contain secrets - skip silently.
# Files that may contain secrets - skip silently. These patterns are specific
# (extensions, exact credential-store names) and always apply.
_SENSITIVE_PATTERNS = [
re.compile(r'(^|[\\/])\.(env|envrc)(\.|$)', re.IGNORECASE),
re.compile(r'\.(pem|key|p12|pfx|cert|crt|der|p8)$', re.IGNORECASE),
re.compile(r'(id_rsa|id_dsa|id_ecdsa|id_ed25519)(\.pub)?$'),
re.compile(r'(\.netrc|\.pgpass|\.htpasswd)$', re.IGNORECASE),
re.compile(r'(aws_credentials|gcloud_credentials|service.account)', re.IGNORECASE),
]
# Generic keyword patterns - these only count when the keyword is LOAD-BEARING
# in the filename (see _generic_keyword_hit), because a keyword buried mid-phrase
# in a long descriptive slug names a topic, not a credential store:
# "token-economics-of-recall.md" is a note ABOUT tokens; "api_token.txt" IS one.
# Uses lookarounds instead of \b so underscore-prefixed names like api_token.txt
# match. Both patterns use (?![a-zA-Z]) so that the trailing-underscore behavior
# is consistent: "secret_store.txt" IS flagged, "tokenizer.py" is NOT (because
# "i" after "token" is alpha and blocks the match).
# `token` is kept separate because its longer suffix "izer"/"ize" is the only
# common false-positive; other keywords have no such well-known derivatives.
_SENSITIVE_PATTERNS = [
re.compile(r'(^|[\\/])\.(env|envrc)(\.|$)', re.IGNORECASE),
re.compile(r'\.(pem|key|p12|pfx|cert|crt|der|p8)$', re.IGNORECASE),
_GENERIC_KEYWORD_PATTERNS = [
re.compile(r'(?<![a-zA-Z0-9])(credential|secret|passwd|password|private_key)s?(?![a-zA-Z])', re.IGNORECASE),
re.compile(r'(?<![a-zA-Z0-9])tokens?(?![a-zA-Z])', re.IGNORECASE),
re.compile(r'(id_rsa|id_dsa|id_ecdsa|id_ed25519)(\.pub)?$'),
re.compile(r'(\.netrc|\.pgpass|\.htpasswd)$', re.IGNORECASE),
re.compile(r'(aws_credentials|gcloud_credentials|service.account)', re.IGNORECASE),
]
# Word separators for the load-bearing check (underscore intentionally included;
# multi-word keywords like private_key are handled by the end-of-stem check,
# which runs before word counting).
_WORD_SPLIT = re.compile(r'[-_\s]+')
def _generic_keyword_hit(name: str) -> bool:
"""True if a generic secret keyword appears load-bearing in the filename.
Secret-store files name their contents, and in English compounds the
content noun is the head, which comes last: "github-personal-access-token",
"api_token", "oauth_token". A keyword that is neither at the end of the
stem nor in a short (<=2 word) name is a topic word in a descriptive slug
("token-economics-of-recall.md", "password-policy-discussion.md") and must
not cause the file to be silently dropped from the graph (#436, #718).
"""
# Stem = name up to the first dot, ignoring leading dots so dotfiles like
# ".token" keep their keyword ("" stems would never match).
stem = name.lstrip('.').split('.')[0]
for pat in _GENERIC_KEYWORD_PATTERNS:
hit = False
for m in pat.finditer(stem):
hit = True
if m.end() == len(stem): # keyword ends the stem -> names the contents
return True
if hit and len([w for w in _WORD_SPLIT.split(stem) if w]) <= 2:
return True # short name like token_config.yaml / secret_handler.txt
return False
# Signals that a .md/.txt file is actually a converted academic paper
_PAPER_SIGNALS = [
re.compile(r'\barxiv\b', re.IGNORECASE),
@@ -143,7 +180,10 @@ def _is_sensitive(path: Path) -> bool:
return True
# Stage 2: filename pattern match
name = path.name
return any(p.search(name) for p in _SENSITIVE_PATTERNS)
if any(p.search(name) for p in _SENSITIVE_PATTERNS):
return True
# Stage 3: generic keywords, only when load-bearing in the name
return _generic_keyword_hit(name)
def _looks_like_paper(path: Path) -> bool:
@@ -1307,6 +1347,12 @@ def detect_incremental(
changed = True
else:
stored_mtime = stored.get("mtime")
# Schema-drift guard (#1163): tolerate a nested {mtime: ...}
# dict or any non-numeric value without crashing.
if isinstance(stored_mtime, dict):
stored_mtime = stored_mtime.get("mtime")
if not isinstance(stored_mtime, (int, float)):
stored_mtime = None
if stored_mtime is None or current_mtime != stored_mtime:
# mtime bumped — verify with content hash before re-extracting
changed = _md5_file(Path(f)) != stored_hash
+23 -5
View File
@@ -493,9 +493,12 @@ def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *,
import sys as _sys
print(
f"[graphify] WARNING: new graph has {new_n} nodes but existing "
f"graph.json has {existing_n}. Refusing to overwrite — you may be "
f"missing chunk files from a previous session. "
f"Pass force=True to override.",
f"graph.json has {existing_n} (net -{existing_n - new_n}). "
f"Refusing to overwrite. Possible causes: missing chunk files from "
f"a previous session, or fuzzy dedup collapsed same-named symbols "
f"across files during an --update on an already-current graph. "
f"Run a full rebuild (/graphify .) to be safe, or pass force=True "
f"only if you have verified the reduction is legitimate.",
file=_sys.stderr,
)
return False
@@ -1274,7 +1277,10 @@ def push_to_neo4j(
with driver.session() as session:
for node_id, data in G.nodes(data=True):
props = {k: v for k, v in data.items() if isinstance(v, (str, int, float, bool))}
props = {
k: v for k, v in data.items()
if isinstance(v, (str, int, float, bool)) and not k.startswith("_")
}
props["id"] = node_id
cid = node_community.get(node_id)
if cid is not None:
@@ -1289,7 +1295,10 @@ def push_to_neo4j(
for u, v, data in G.edges(data=True):
rel = _safe_rel(data.get("relation", "RELATED_TO"))
props = {k: v for k, v in data.items() if isinstance(v, (str, int, float, bool))}
props = {
k: v for k, v in data.items()
if isinstance(v, (str, int, float, bool)) and not k.startswith("_")
}
session.run(
f"MATCH (a {{id: $src}}), (b {{id: $tgt}}) "
f"MERGE (a)-[r:{rel}]->(b) SET r += $props",
@@ -1317,6 +1326,15 @@ def to_graphml(
node_community = _node_community_map(communities)
for node_id in H.nodes():
H.nodes[node_id]["community"] = node_community.get(node_id, -1)
# Drop internal markers (e.g. the AST-provenance "_origin" tag, #1116, and
# the "_src"/"_tgt" direction markers) — they are persistence/runtime details,
# not graph data, and should not leak into the exported file.
for _, attrs in H.nodes(data=True):
for k in [k for k in attrs if k.startswith("_")]:
del attrs[k]
for _, _, attrs in H.edges(data=True):
for k in [k for k in attrs if k.startswith("_")]:
del attrs[k]
nx.write_graphml(H, output_path)
+257 -5
View File
@@ -473,6 +473,18 @@ _PYTHON_TYPE_CONTAINERS = frozenset({
"None", "Ellipsis",
})
# Scalar builtins and test-mock names that appear as type annotations but carry
# no useful semantic meaning as graph nodes (#1147). Suppressed at the annotation
# walker level so they are never created as nodes or emitted as edges.
_PYTHON_ANNOTATION_NOISE = frozenset({
# scalar builtins
"str", "int", "float", "bool", "bytes", "bytearray", "complex", "object",
"True", "False",
# unittest.mock
"MagicMock", "Mock", "AsyncMock", "NonCallableMock",
"NonCallableMagicMock", "PropertyMock", "patch", "sentinel",
})
def _python_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None:
"""Walk a Python type annotation; append (name, role) where role is 'type' or 'generic_arg'.
@@ -490,19 +502,19 @@ def _python_collect_type_refs(node, source: bytes, generic: bool, out: list[tupl
return
if t == "identifier":
name = _read_text(node, source)
if name and name not in _PYTHON_TYPE_CONTAINERS:
if name and name not in _PYTHON_TYPE_CONTAINERS and name not in _PYTHON_ANNOTATION_NOISE:
out.append((name, "generic_arg" if generic else "type"))
return
if t == "attribute":
tail = _read_text(node, source).rsplit(".", 1)[-1]
if tail and tail not in _PYTHON_TYPE_CONTAINERS:
if tail and tail not in _PYTHON_TYPE_CONTAINERS and tail not in _PYTHON_ANNOTATION_NOISE:
out.append((tail, "generic_arg" if generic else "type"))
return
if t == "generic_type":
for c in node.children:
if c.type == "identifier":
container = _read_text(c, source)
if container and container not in _PYTHON_TYPE_CONTAINERS:
if container and container not in _PYTHON_TYPE_CONTAINERS and container not in _PYTHON_ANNOTATION_NOISE:
out.append((container, "generic_arg" if generic else "type"))
elif c.type == "type_parameter":
for sub in c.children:
@@ -4029,6 +4041,205 @@ def extract_csharp(path: Path) -> dict:
return _extract_generic(path, _CSHARP_CONFIG)
def extract_apex(path: Path) -> dict:
"""Extract classes, interfaces, enums, methods, and Salesforce constructs from
Apex .cls and .trigger files using regex (no tree-sitter grammar on PyPI)."""
import re as _re
try:
source = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return {"nodes": [], "edges": []}
str_path = str(path)
stem = _file_stem(path)
file_nid = _make_id(str_path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
def add_node(nid: str, label: str, line: int) -> None:
if nid not in seen_ids:
seen_ids.add(nid)
nodes.append({
"id": nid,
"label": label,
"file_type": "code",
"source_file": str_path,
"source_location": f"L{line}",
})
def add_edge(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED") -> None:
edges.append({
"source": src,
"target": tgt,
"relation": relation,
"confidence": confidence,
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
})
add_node(file_nid, path.name, 1)
lines = source.splitlines()
_ACCESS = r"(?:public|private|protected|global|webService)?"
_SHARING = r"(?:\s+(?:with|without|inherited)\s+sharing)?"
_MOD = r"(?:\s+(?:abstract|virtual|override|static|final|transient|testMethod))?"
_ANNOTATION = r"(?:\s*@\w+(?:\s*\([^)]*\))?\s*)*"
cls_re = _re.compile(
rf"^{_ANNOTATION}\s*{_ACCESS}{_SHARING}{_MOD}\s*class\s+(\w+)"
rf"(?:\s+extends\s+(\w+))?(?:\s+implements\s+([\w,\s]+))?\s*\{{?",
_re.IGNORECASE,
)
iface_re = _re.compile(
rf"^{_ANNOTATION}\s*{_ACCESS}{_SHARING}{_MOD}\s*interface\s+(\w+)"
rf"(?:\s+extends\s+([\w,\s]+))?\s*\{{?",
_re.IGNORECASE,
)
enum_re = _re.compile(
rf"^{_ANNOTATION}\s*{_ACCESS}{_SHARING}{_MOD}\s*enum\s+(\w+)\s*\{{?",
_re.IGNORECASE,
)
trigger_re = _re.compile(
r"^\s*trigger\s+(\w+)\s+on\s+(\w+)\s*\(",
_re.IGNORECASE,
)
method_re = _re.compile(
rf"^{_ANNOTATION}\s*{_ACCESS}{_MOD}\s*(?:static\s+)?[\w<>\[\]]+\s+(\w+)\s*\([^)]*\)\s*(?:throws\s+\w+\s*)?\{{?",
_re.IGNORECASE,
)
annotation_re = _re.compile(r"@(\w+)", _re.IGNORECASE)
soql_re = _re.compile(r"\[\s*SELECT\b[^\]]+FROM\s+(\w+)", _re.IGNORECASE)
dml_re = _re.compile(r"\b(insert|update|delete|upsert|merge|undelete)\s+\w", _re.IGNORECASE)
_CONTROL_FLOW = frozenset({
"if", "else", "for", "while", "do", "switch", "try", "catch",
"finally", "return", "throw", "new", "void", "null",
"true", "false", "this", "super", "class", "interface", "enum",
"trigger", "on",
})
current_class_nid: str | None = None
pending_annotations: list[str] = []
for lineno, line_text in enumerate(lines, start=1):
stripped = line_text.strip()
if stripped.startswith("@"):
for m in annotation_re.finditer(stripped):
pending_annotations.append(m.group(1).lower())
continue
tm = trigger_re.match(stripped)
if tm:
trig_name, sobject = tm.group(1), tm.group(2)
trig_nid = _make_id(stem, trig_name)
add_node(trig_nid, trig_name, lineno)
add_edge(file_nid, trig_nid, "contains", lineno)
sob_nid = _make_id(sobject)
if sob_nid not in seen_ids:
add_node(sob_nid, sobject, lineno)
add_edge(trig_nid, sob_nid, "uses", lineno, confidence="INFERRED")
current_class_nid = trig_nid
pending_annotations = []
continue
cm = cls_re.match(stripped)
if cm:
class_name = cm.group(1)
if class_name.lower() in _CONTROL_FLOW:
pending_annotations = []
continue
class_nid = _make_id(stem, class_name)
add_node(class_nid, class_name, lineno)
add_edge(file_nid, class_nid, "contains", lineno)
if cm.group(2):
base = cm.group(2).strip()
base_nid = _make_id(stem, base)
if base_nid not in seen_ids:
base_nid = _make_id(base)
if base_nid not in seen_ids:
add_node(base_nid, base, lineno)
add_edge(class_nid, base_nid, "extends", lineno, confidence="INFERRED")
if cm.group(3):
for iface in cm.group(3).split(","):
iface = iface.strip()
if iface:
iface_nid = _make_id(stem, iface)
if iface_nid not in seen_ids:
iface_nid = _make_id(iface)
if iface_nid not in seen_ids:
add_node(iface_nid, iface, lineno)
add_edge(class_nid, iface_nid, "implements", lineno, confidence="INFERRED")
current_class_nid = class_nid
pending_annotations = []
continue
im = iface_re.match(stripped)
if im:
iface_name = im.group(1)
if iface_name.lower() in _CONTROL_FLOW:
pending_annotations = []
continue
iface_nid = _make_id(stem, iface_name)
add_node(iface_nid, iface_name, lineno)
add_edge(file_nid if current_class_nid is None else current_class_nid,
iface_nid, "contains", lineno)
pending_annotations = []
continue
em = enum_re.match(stripped)
if em:
enum_name = em.group(1)
if enum_name.lower() in _CONTROL_FLOW:
pending_annotations = []
continue
enum_nid = _make_id(stem, enum_name)
add_node(enum_nid, enum_name, lineno)
add_edge(file_nid if current_class_nid is None else current_class_nid,
enum_nid, "contains", lineno)
pending_annotations = []
continue
if current_class_nid is not None:
mm = method_re.match(stripped)
if mm:
method_name = mm.group(1)
if method_name.lower() not in _CONTROL_FLOW:
method_nid = _make_id(current_class_nid, method_name)
method_label = f".{method_name}()"
add_node(method_nid, method_label, lineno)
add_edge(current_class_nid, method_nid, "method", lineno)
if "auraenabled" in pending_annotations or "invocablemethod" in pending_annotations:
add_edge(file_nid, method_nid, "contains", lineno, confidence="INFERRED")
pending_annotations = []
continue
pending_annotations = []
for sm in soql_re.finditer(line_text):
sobject = sm.group(1)
sob_nid = _make_id(sobject)
if sob_nid not in seen_ids:
add_node(sob_nid, sobject, lineno)
src = current_class_nid or file_nid
add_edge(src, sob_nid, "uses", lineno, confidence="INFERRED")
for dm in dml_re.finditer(line_text):
dml_op = dm.group(1).lower()
dml_nid = _make_id(f"dml_{dml_op}")
if dml_nid not in seen_ids:
add_node(dml_nid, dml_op, lineno)
src = current_class_nid or file_nid
add_edge(src, dml_nid, "uses", lineno, confidence="INFERRED")
return {"nodes": nodes, "edges": edges}
def extract_kotlin(path: Path) -> dict:
"""Extract classes, objects, functions, and imports from a .kt/.kts file."""
return _extract_generic(path, _KOTLIN_CONFIG)
@@ -4716,7 +4927,7 @@ def extract_verilog(path: Path) -> dict:
return {"nodes": nodes, "edges": edges}
def extract_sql(path: Path) -> dict:
def extract_sql(path: Path, content: str | bytes | None = None) -> dict:
"""Extract tables, views, functions, and relationships from .sql files via tree-sitter."""
try:
import tree_sitter_sql as tssql
@@ -4727,12 +4938,17 @@ def extract_sql(path: Path) -> dict:
try:
language = Language(tssql.language())
parser = Parser(language)
source = path.read_bytes()
source = (
content.encode("utf-8") if isinstance(content, str)
else content if content is not None
else path.read_bytes()
)
tree = parser.parse(source)
root = tree.root_node
except Exception as e:
return {"nodes": [], "edges": [], "error": str(e)}
stem = _file_stem(path)
str_path = str(path)
file_nid = _make_id(str_path)
@@ -6731,6 +6947,9 @@ class _SymbolResolutionFacts:
exports: list[_SymbolExportFact] = field(default_factory=list)
star_exports: list[_StarExportFact] = field(default_factory=list)
uses: list[_SymbolUseFact] = field(default_factory=list)
# File-to-file submodule imports from `from pkg import submod` (#1146).
# Each entry is (importing_file, submodule_file, line).
module_imports: list[tuple[Path, Path, int]] = field(default_factory=list)
def _apply_symbol_resolution_facts(
@@ -6748,6 +6967,7 @@ def _apply_symbol_resolution_facts(
or facts.exports
or facts.star_exports
or facts.uses
or facts.module_imports
):
return
@@ -6914,6 +7134,17 @@ def _apply_symbol_resolution_facts(
import_fact.file_path,
)
# #1146: emit file-to-file imports_from edges for package-form submodule imports.
for from_path, to_path, line in facts.module_imports:
try:
from_rel = from_path.relative_to(root)
to_rel = to_path.relative_to(root)
except ValueError:
continue
source_id = _make_id(_file_stem(from_rel))
target_id = _make_id(_file_stem(to_rel))
add_edge(source_id, target_id, "imports_from", "submodule_import", line, from_path)
for use_fact in facts.uses:
file_path = use_fact.file_path.resolve()
target_id = None
@@ -7540,8 +7771,20 @@ def _collect_python_symbol_resolution_facts(
target_path = _resolve_python_module_path(module_name, path, root, level)
if target_path is None:
continue
# #1146: `from pkg import submod` — if the target is a package
# (__init__.py) and an imported name matches a submodule file on
# disk, emit a file-level import edge to that submodule rather
# than only to the package.
pkg_dir = target_path.parent if target_path.name == "__init__.py" else None
for imported_name, local_name in _python_imported_names(node, source):
line = node.start_point[0] + 1
if pkg_dir is not None:
sub_py = pkg_dir / f"{imported_name}.py"
sub_pkg = pkg_dir / imported_name / "__init__.py"
submodule = sub_py if sub_py.is_file() else (sub_pkg if sub_pkg.is_file() else None)
if submodule is not None:
facts.module_imports.append((path, submodule, line))
continue
facts.imports.append(
_SymbolImportFact(path, local_name, target_path, imported_name, line)
)
@@ -10786,6 +11029,8 @@ _DISPATCH: dict[str, Any] = {
".vbproj": extract_csproj,
".razor": extract_razor,
".cshtml": extract_razor,
".cls": extract_apex,
".trigger": extract_apex,
}
@@ -11259,6 +11504,13 @@ def extract(
except ValueError:
pass
# Tag AST provenance so the incremental watch rebuild can distinguish
# AST-extracted nodes from semantic/LLM nodes. On a full re-extraction
# the watcher drops any AST-marked node missing from the fresh output
# even when its source file still exists (#1116).
for n in all_nodes:
n["_origin"] = "ast"
return {
"nodes": all_nodes,
"edges": all_edges,
+124 -56
View File
@@ -74,6 +74,122 @@ if [ -z "$GRAPHIFY_PYTHON" ]; then
fi
"""
# The Python that the rebuild runs, shared by both hooks. Embedded verbatim into
# the launcher below and re-executed in the detached child. Must not contain the
# double-quote, $, backtick or backslash characters: it is carried inside a
# shell double-quoted `-c "..."` argument (see _detached_launch).
_REBUILD_BODY_COMMIT = """\
import os, signal, sys
from pathlib import Path
changed_raw = os.environ.get('GRAPHIFY_CHANGED', '')
changed = [Path(f.strip()) for f in changed_raw.strip().splitlines() if f.strip()]
if not changed:
sys.exit(0)
print(f'[graphify hook] {len(changed)} file(s) changed - rebuilding graph...')
try:
from graphify.watch import _rebuild_code, _apply_resource_limits
_apply_resource_limits()
_timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600'))
if _timeout > 0 and hasattr(signal, 'SIGALRM'):
signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError(f'graphify rebuild exceeded {_timeout}s')))
signal.alarm(_timeout)
_force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes')
_root = Path('.')
_saved = Path('graphify-out/.graphify_root')
if _saved.exists():
_txt = _saved.read_text(encoding='utf-8').strip()
if _txt:
_root = Path(_txt)
_rebuild_code(_root, changed_paths=changed, force=_force)
except TimeoutError as exc:
print(f'[graphify hook] {exc}')
sys.exit(1)
except Exception as exc:
print(f'[graphify hook] Rebuild failed: {exc}')
sys.exit(1)
"""
_REBUILD_BODY_CHECKOUT = """\
from graphify.watch import _rebuild_code, _apply_resource_limits
from pathlib import Path
import os, signal, sys
try:
_apply_resource_limits()
_timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600'))
if _timeout > 0 and hasattr(signal, 'SIGALRM'):
signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError(f'graphify rebuild exceeded {_timeout}s')))
signal.alarm(_timeout)
_force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes')
# post-checkout: branch switch can touch arbitrary files; full rebuild path
# (no changed_paths) is correct here. The flock inside _rebuild_code still
# prevents pile-ups when commit + checkout fire back-to-back.
_root = Path('.')
_saved = Path('graphify-out/.graphify_root')
if _saved.exists():
_txt = _saved.read_text(encoding='utf-8').strip()
if _txt:
_root = Path(_txt)
_rebuild_code(_root, force=_force)
except TimeoutError as exc:
print(f'[graphify] {exc}')
sys.exit(1)
except Exception as exc:
print(f'[graphify] Rebuild failed: {exc}')
sys.exit(1)
"""
# Cross-platform detached-launch shim (#1161). The hooks used to background the
# rebuild with `nohup "$GRAPHIFY_PYTHON" -c "..." &`, but Git for Windows' bundled
# MSYS shell ships no nohup (nor setsid), so that line died with
# 'nohup: command not found' and the rebuild silently never ran — git commit/pull
# still returned 0, so the graph just went stale with no signal. graphify already
# requires Python, so we let Python do the detaching: a tiny outer process spawns
# the real rebuild fully detached and returns immediately, so the hook never
# blocks. POSIX uses start_new_session (the setsid equivalent); Windows uses
# DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, breaking away from any job object
# when allowed. This payload is carried inside a shell double-quoted -c argument,
# so it deliberately uses only single-quoted Python strings (no ", $, ` or \\).
_LAUNCHER_TEMPLATE = """\
import os, subprocess, sys
_src = '''
__REBUILD_BODY__
'''
_log = os.environ.get('GRAPHIFY_REBUILD_LOG') or os.path.join(os.path.expanduser('~'), '.cache', 'graphify-rebuild.log')
try:
os.makedirs(os.path.dirname(_log), exist_ok=True)
_out = open(_log, 'a', buffering=1, encoding='utf-8', errors='replace')
except OSError:
_out = subprocess.DEVNULL
_kw = dict(stdout=_out, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, cwd=os.getcwd(), close_fds=True)
_cmd = [sys.executable, '-c', _src]
if os.name == 'nt':
_flags = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
try:
subprocess.Popen(_cmd, creationflags=_flags | 0x01000000, **_kw) # + CREATE_BREAKAWAY_FROM_JOB
except OSError:
subprocess.Popen(_cmd, creationflags=_flags, **_kw)
else:
subprocess.Popen(_cmd, start_new_session=True, **_kw)
"""
def _detached_launch(rebuild_body: str) -> str:
"""Return a POSIX-sh line that runs ``rebuild_body`` as a detached background
Python process via ``$GRAPHIFY_PYTHON``.
Replaces the old ``nohup ... &`` form, which failed on Git for Windows'
shell (no nohup/setsid) and let the rebuild silently never run (#1161).
The launcher writes the child's output to ``$GRAPHIFY_REBUILD_LOG`` and
returns the instant the child is spawned, so the git hook never blocks.
"""
launcher = _LAUNCHER_TEMPLATE.replace("__REBUILD_BODY__", rebuild_body)
return '"$GRAPHIFY_PYTHON" -c "' + launcher + '"\n'
_HOOK_SCRIPT = """\
# graphify-hook-start
# Auto-rebuilds the knowledge graph after each commit (code files only, no LLM needed).
@@ -107,41 +223,15 @@ fi
""" + _PYTHON_DETECT + """
export GRAPHIFY_CHANGED="$CHANGED"
# Run rebuild detached so git commit returns immediately.
# Full repo rebuilds can take hours; blocking the post-commit hook stalls the shell.
# Run the rebuild detached so git commit returns immediately. Full-repo rebuilds
# can take hours; blocking the post-commit hook stalls the shell. The Python
# launcher below detaches the child cross-platform, so it works on Git for
# Windows' shell too (which lacks the coreutils backgrounding tools) (#1161).
_GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log"
mkdir -p "$(dirname "$_GRAPHIFY_LOG")"
export GRAPHIFY_REBUILD_LOG="$_GRAPHIFY_LOG"
echo "[graphify hook] launching background rebuild (log: $_GRAPHIFY_LOG)"
nohup "$GRAPHIFY_PYTHON" -c "
import os, signal, sys
from pathlib import Path
changed_raw = os.environ.get('GRAPHIFY_CHANGED', '')
changed = [Path(f.strip()) for f in changed_raw.strip().splitlines() if f.strip()]
if not changed:
sys.exit(0)
print(f'[graphify hook] {len(changed)} file(s) changed - rebuilding graph...')
try:
from graphify.watch import _rebuild_code, _apply_resource_limits
_apply_resource_limits()
_timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600'))
if _timeout > 0 and hasattr(signal, 'SIGALRM'):
signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError(f'graphify rebuild exceeded {_timeout}s')))
signal.alarm(_timeout)
_force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes')
_rebuild_code(Path('.'), changed_paths=changed, force=_force)
except TimeoutError as exc:
print(f'[graphify hook] {exc}')
sys.exit(1)
except Exception as exc:
print(f'[graphify hook] Rebuild failed: {exc}')
sys.exit(1)
" >> "$_GRAPHIFY_LOG" 2>&1 < /dev/null &
disown 2>/dev/null || true
# graphify-hook-end
""" + _detached_launch(_REBUILD_BODY_COMMIT) + """# graphify-hook-end
"""
@@ -179,31 +269,9 @@ GIT_DIR=$(git rev-parse --git-dir 2>/dev/null)
""" + _PYTHON_DETECT + """
_GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log"
mkdir -p "$(dirname "$_GRAPHIFY_LOG")"
export GRAPHIFY_REBUILD_LOG="$_GRAPHIFY_LOG"
echo "[graphify] Branch switched - launching background rebuild (log: $_GRAPHIFY_LOG)"
nohup "$GRAPHIFY_PYTHON" -c "
from graphify.watch import _rebuild_code, _apply_resource_limits
from pathlib import Path
import os, signal, sys
try:
_apply_resource_limits()
_timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600'))
if _timeout > 0 and hasattr(signal, 'SIGALRM'):
signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError(f'graphify rebuild exceeded {_timeout}s')))
signal.alarm(_timeout)
_force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes')
# post-checkout: branch switch can touch arbitrary files; full rebuild path
# (no changed_paths) is correct here. The flock inside _rebuild_code still
# prevents pile-ups when commit + checkout fire back-to-back.
_rebuild_code(Path('.'), force=_force)
except TimeoutError as exc:
print(f'[graphify] {exc}')
sys.exit(1)
except Exception as exc:
print(f'[graphify] Rebuild failed: {exc}')
sys.exit(1)
" >> "$_GRAPHIFY_LOG" 2>&1 < /dev/null &
disown 2>/dev/null || true
# graphify-checkout-hook-end
""" + _detached_launch(_REBUILD_BODY_CHECKOUT) + """# graphify-checkout-hook-end
"""
+408 -15
View File
@@ -5,6 +5,7 @@
# this module provides a direct API path for non-Claude-Code environments.
from __future__ import annotations
import base64
import json
import os
import re
@@ -12,6 +13,7 @@ import sys
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, replace
from pathlib import Path
# `_read_files` truncates each file at this many characters before joining into
@@ -53,11 +55,15 @@ BACKENDS: dict[str, dict] = {
"pricing": {"input": 3.0, "output": 15.0}, # USD per 1M tokens
"temperature": 0,
"max_tokens": 16384,
"vision": True,
},
"kimi": {
"base_url": "https://api.moonshot.ai/v1",
"default_model": "kimi-k2.6",
"env_key": "MOONSHOT_API_KEY",
# kimi-k2.6 is natively multimodal (MoonViT) and accepts the same
# OpenAI image_url data-URI block via Moonshot's compat endpoint.
"vision": True,
"pricing": {"input": 0.74, "output": 4.66}, # USD per 1M tokens
"temperature": None, # kimi-k2.6 enforces its own fixed temperature; sending any value raises 400
"max_tokens": 16384,
@@ -79,6 +85,7 @@ BACKENDS: dict[str, dict] = {
"temperature": 0,
"reasoning_effort": "low",
"max_completion_tokens": 16384,
"vision": True,
},
"openai": {
"base_url": "https://api.openai.com/v1",
@@ -87,6 +94,7 @@ BACKENDS: dict[str, dict] = {
"model_env_key": "GRAPHIFY_OPENAI_MODEL",
"pricing": {"input": 0.40, "output": 1.60}, # USD per 1M tokens
"temperature": 0,
"vision": True,
},
"deepseek": {
"base_url": "https://api.deepseek.com",
@@ -99,12 +107,28 @@ BACKENDS: dict[str, dict] = {
"temperature": 0,
"max_tokens": 16384,
},
"azure": {
# Azure OpenAI Service — uses AzureOpenAI SDK client, not the standard
# OpenAI client, so it has its own call path (_call_azure).
# Required env vars: AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT.
# Optional: AZURE_OPENAI_API_VERSION (defaults to 2024-12-01-preview),
# AZURE_OPENAI_DEPLOYMENT or GRAPHIFY_AZURE_MODEL (deployment name).
# base_url is intentionally absent — prevents accidental routing through
# _call_openai_compat, which requires it and uses the wrong SDK client class.
"default_model": os.environ.get("AZURE_OPENAI_DEPLOYMENT", os.environ.get("GRAPHIFY_AZURE_MODEL", "gpt-4o")),
"env_key": "AZURE_OPENAI_API_KEY",
"model_env_key": "GRAPHIFY_AZURE_MODEL",
"pricing": {"input": 2.50, "output": 10.00}, # USD per 1M tokens (gpt-4o; may mis-estimate other deployments)
"temperature": 0,
"max_tokens": 16384,
},
"bedrock": {
"default_model": "anthropic.claude-3-5-sonnet-20241022-v2:0",
"model_env_key": "GRAPHIFY_BEDROCK_MODEL",
"pricing": {"input": 3.0, "output": 15.0}, # USD per 1M tokens
"temperature": 0,
"max_tokens": 16384,
"vision": True,
},
"claude-cli": {
# Routes through the locally-installed `claude` CLI (Claude Code) using
@@ -115,6 +139,9 @@ BACKENDS: dict[str, dict] = {
"pricing": {"input": 0.0, "output": 0.0},
"temperature": 0,
"max_tokens": 16384,
# Claude Code is multimodal; images are passed by path and read with the
# CLI's Read tool rather than as inline base64 (see `_call_claude_cli`).
"vision": True,
},
}
@@ -259,6 +286,20 @@ def _extraction_system(*, deep: bool = False) -> str:
return _EXTRACTION_SYSTEM + _DEEP_EXTRACTION_SUFFIX
def _file_to_text(path: Path) -> str:
"""Return a text-like file's content for the extraction prompt.
Most files are read directly. PDFs are binary, so reading them with
`read_text` yields garbage (the same failure images had); route them through
pypdf instead. A scanned PDF with no text layer extracts to an empty string,
which still produces a reference node rather than noise.
"""
if path.suffix.lower() == ".pdf":
from graphify.detect import extract_pdf_text
return extract_pdf_text(path)
return path.read_text(encoding="utf-8", errors="replace")
def _read_files(paths: list[Path], root: Path) -> str:
"""Return file contents formatted for the extraction prompt."""
parts: list[str] = []
@@ -268,13 +309,226 @@ def _read_files(paths: list[Path], root: Path) -> str:
except ValueError:
rel = p
try:
content = p.read_text(encoding="utf-8", errors="replace")
content = _file_to_text(p)
except OSError:
continue
parts.append(f"=== {rel} ===\n{content[:20000]}")
return "\n\n".join(parts)
# ── Image (vision) handling ───────────────────────────────────────────────────
# Raster image types a vision model can actually look at. `.svg` is intentionally
# excluded: it is XML markup, so `_read_files` reads it as text (the model parses
# the source directly), which is more useful than rasterising it. Before this,
# every image was fed through `path.read_text(errors="replace")`, turning binary
# pixels into garbage text — noise for API backends and an outright `exit 1` for
# the claude-cli backend.
_VISION_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
_IMAGE_MEDIA_TYPES = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
}
# Per-image byte ceiling. Anthropic caps a request at 32 MB and Bedrock images
# at ~5 MB; 5 MB per image keeps every backend within limits. Oversized images
# fall back to a text reference (the node is still created, just unseen).
_MAX_IMAGE_BYTES = 5 * 1024 * 1024
# Flat token estimate per image for chunk packing. Vision models bill an image
# at a roughly fixed cost regardless of file size, so estimating by byte size
# (as the generic path does) would force every large PNG into its own chunk.
_IMAGE_TOKEN_ESTIMATE = 1_600
# Hard cap on images per chunk, independent of the token budget. A large
# token budget would otherwise pack hundreds of images into one request —
# past provider per-request image limits (Anthropic allows 100), and far too
# many for the claude-cli Read-tool loop to work through. Keeps memory and
# request size bounded on image-dense corpora.
_MAX_IMAGES_PER_CHUNK = 20
# Backends that read an image by file path (claude-cli's Read tool)
# instead of inlining base64. They open the file themselves and downsample as
# needed, so `_MAX_IMAGE_BYTES` does not apply and the bytes never need loading.
_PATH_IMAGE_BACKENDS = {"claude-cli"}
@dataclass
class _ImageRef:
"""A single image destined for a vision request.
`raw` is None when the image is unreadable or exceeds `_MAX_IMAGE_BYTES`, or
when the target backend has no vision support in every such case the
renderers emit a text reference instead of pixels, so the image still
becomes a graph node.
"""
path: Path # absolute path (claude-cli reads it via the Read tool)
rel: str # path relative to the corpus root (the node's source_file)
media_type: str # e.g. "image/png"
raw: bytes | None
@property
def b64(self) -> str:
return base64.standard_b64encode(self.raw).decode("ascii") if self.raw else ""
@property
def bedrock_format(self) -> str:
# Converse wants a bare format token, not a media type.
return self.media_type.split("/", 1)[-1]
def _is_vision_image(path: Path) -> bool:
return path.suffix.lower() in _VISION_IMAGE_EXTENSIONS
def _partition_semantic_files(files: list[Path]) -> tuple[list[Path], list[Path]]:
"""Split a chunk into (text-like files, raster-image files)."""
text_files = [f for f in files if not _is_vision_image(f)]
image_files = [f for f in files if _is_vision_image(f)]
return text_files, image_files
def _build_image_refs(image_files: list[Path], root: Path, *, read_bytes: bool = True) -> list[_ImageRef]:
"""Build `_ImageRef`s for raster images.
`read_bytes=True` (base64 backends) loads the pixels and drops any image over
`_MAX_IMAGE_BYTES` to a reference, because a base64 request body has a hard
size ceiling. `read_bytes=False` (path-based backends claude-cli)
skips the read entirely: those backends open the file themselves and
downsample as needed, so there is no per-image size limit and no reason to
load (potentially tens of MB of) bytes that would never be used.
"""
refs: list[_ImageRef] = []
for p in image_files:
try:
rel = str(p.relative_to(root))
except ValueError:
rel = str(p)
media = _IMAGE_MEDIA_TYPES.get(p.suffix.lower(), "image/png")
raw: bytes | None = None
if read_bytes:
try:
raw = p.read_bytes()
except OSError as exc:
print(f"[graphify] could not read image {rel}: {exc}", file=sys.stderr)
raw = None
if raw is not None and len(raw) > _MAX_IMAGE_BYTES:
print(
f"[graphify] image {rel} is {len(raw) // 1024} KB, over the "
f"{_MAX_IMAGE_BYTES // (1024 * 1024)} MB inline-image limit for this "
"backend; sending it as a reference node without inline pixels.",
file=sys.stderr,
)
raw = None
try:
abs_path = p.resolve()
except OSError:
abs_path = p
refs.append(_ImageRef(abs_path, rel, media, raw))
return refs
def _strip_pixels(refs: list[_ImageRef]) -> list[_ImageRef]:
"""Return refs with pixel data dropped (for non-vision backends)."""
return [replace(r, raw=None) for r in refs]
def _backend_supports_vision(backend: str) -> bool:
"""Whether `backend`'s configured model can see images.
Ollama is special-cased: its default model is text-only, so vision is
opt-in via GRAPHIFY_OLLAMA_VISION=1 once the user selects a vision model
(e.g. --model llama3.2-vision).
"""
if backend == "ollama":
return os.environ.get("GRAPHIFY_OLLAMA_VISION", "").strip() == "1"
return bool(BACKENDS.get(backend, {}).get("vision", False))
def _image_notes(refs: list[_ImageRef], *, with_paths: bool = False) -> str:
"""Text block listing the images so the model emits one node per image.
Always included alongside the visual payload (and used on its own when the
backend can't see pixels), so an image becomes a graph node either way.
`with_paths=True` also lists the absolute path and asks the model to open it
with the Read tool used by the claude-cli backend.
"""
if not refs:
return ""
if with_paths:
header = (
"Use the Read tool to open and view each image file at the path below, "
"then emit one node per image"
)
else:
header = (
"The following image file(s) are attached as visual input. Emit one "
"node per image"
)
lines = [
"=== IMAGES ===",
f"{header} with \"file_type\":\"image\" and the listed source_file, a label "
"describing what it depicts (diagram, screenshot, chart, photo, UI, logo), "
"and edges to any code/doc nodes the image clearly references.",
]
for i, r in enumerate(refs, 1):
note = f"[image {i}] source_file: {r.rel}"
if with_paths:
note += f" path: {r.path}"
if r.raw is None and not with_paths:
note += " (not shown: unreadable or exceeds size limit)"
lines.append(note)
return "\n".join(lines)
def _with_image_notes(user_message: str, refs: list[_ImageRef], *, with_paths: bool = False) -> str:
notes = _image_notes(refs, with_paths=with_paths)
if not notes:
return user_message
if not user_message.strip():
return notes
return f"{user_message}\n\n{notes}"
def _anthropic_content(user_message: str, refs: list[_ImageRef]):
"""Build the Anthropic `messages[].content` value (str, or block list with images)."""
blocks = [
{"type": "image", "source": {"type": "base64", "media_type": r.media_type, "data": r.b64}}
for r in refs
if r.raw
]
text = _with_image_notes(user_message, refs)
if not blocks:
return text
return [*blocks, {"type": "text", "text": text}]
def _openai_content(user_message: str, refs: list[_ImageRef]):
"""Build the OpenAI-compatible user `content` value (str, or part list with images)."""
parts: list[dict] = [
{
"type": "image_url",
"image_url": {"url": f"data:{r.media_type};base64,{r.b64}", "detail": "auto"},
}
for r in refs
if r.raw
]
text = _with_image_notes(user_message, refs)
if not parts:
return text
return [{"type": "text", "text": text}, *parts]
def _bedrock_content(user_message: str, refs: list[_ImageRef]) -> list[dict]:
"""Build the Bedrock Converse user content list (raw bytes, not base64)."""
content: list[dict] = [
{"image": {"format": r.bedrock_format, "source": {"bytes": r.raw}}}
for r in refs
if r.raw
]
content.append({"text": _with_image_notes(user_message, refs)})
return content
_LLM_JSON_MAX_BYTES = 10 * 1024 * 1024 # 10 MB hard cap before json.loads (F-016)
@@ -434,6 +688,7 @@ def _call_openai_compat(
*,
backend: str = "",
deep_mode: bool = False,
images: list[_ImageRef] | None = None,
) -> dict:
"""Call any OpenAI-compatible API (Kimi, OpenAI, etc.) and return parsed JSON."""
try:
@@ -452,7 +707,7 @@ def _call_openai_compat(
"model": model,
"messages": [
{"role": "system", "content": _extraction_system(deep=deep_mode)},
{"role": "user", "content": user_message},
{"role": "user", "content": _openai_content(user_message, images or [])},
],
"max_completion_tokens": max_completion_tokens,
}
@@ -547,7 +802,7 @@ def _call_openai_compat(
return result
def _call_claude(api_key: str, model: str, user_message: str, max_tokens: int = 8192, *, deep_mode: bool = False) -> dict:
def _call_claude(api_key: str, model: str, user_message: str, max_tokens: int = 8192, *, deep_mode: bool = False, images: list[_ImageRef] | None = None) -> dict:
"""Call Anthropic Claude directly (not via OpenAI compat layer)."""
try:
import anthropic
@@ -559,7 +814,7 @@ def _call_claude(api_key: str, model: str, user_message: str, max_tokens: int =
model=model,
max_tokens=max_tokens,
system=_extraction_system(deep=deep_mode),
messages=[{"role": "user", "content": user_message}],
messages=[{"role": "user", "content": _anthropic_content(user_message, images or [])}],
)
raw_content = resp.content[0].text if resp.content else None
result = _parse_llm_json(raw_content or "{}")
@@ -580,12 +835,16 @@ def _call_claude(api_key: str, model: str, user_message: str, max_tokens: int =
return result
def _call_claude_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bool = False) -> dict:
def _call_claude_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bool = False, images: list[_ImageRef] | None = None) -> dict:
"""Call Claude via the locally-installed Claude Code CLI (`claude -p`).
Routes through the user's Claude Code subscription auth instead of a separate
ANTHROPIC_API_KEY. Useful for Pro/Max subscribers who don't want to provision
a pay-as-you-go API key just to run graphify's semantic pass.
Images are passed by absolute path rather than inline base64: the prompt asks
the model to open each one with its Read tool, and each containing directory
is allowlisted with `--add-dir` so the read is permitted.
"""
import platform
import shutil
@@ -621,10 +880,23 @@ def _call_claude_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bo
# preamble — both of which fail the strict json.loads in _parse_llm_json.
# Replacing the default prompt eliminates the conflict at the source.
# Side benefit: cache-creation tokens per call drop ~19% in practice.
# When images are present, append the Read-the-paths instruction and
# allowlist each containing directory so the CLI's Read tool can open them.
add_dir_args: list[str] = []
if images:
user_message = _with_image_notes(user_message, images, with_paths=True)
seen_dirs: set[str] = set()
for r in images:
d = str(r.path.parent)
if d not in seen_dirs:
seen_dirs.add(d)
add_dir_args.extend(["--add-dir", d])
cli_args = [
claude_cmd, "-p",
"--output-format", "json",
"--no-session-persistence",
*add_dir_args,
"--system-prompt", _extraction_system(deep=deep_mode),
]
# claude-cli defaults to Opus, which is overkill for the structured-JSON
@@ -680,7 +952,69 @@ def _call_claude_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bo
return result
def _call_bedrock(model: str, user_message: str, max_tokens: int = 8192, *, deep_mode: bool = False) -> dict:
def _azure_client(api_key: str, endpoint: str):
"""Construct an AzureOpenAI client with env-driven api_version and timeout."""
try:
from openai import AzureOpenAI
except ImportError as exc:
raise ImportError(
"Azure OpenAI requires the openai package. Run: pip install openai"
) from exc
api_version = os.environ.get("AZURE_OPENAI_API_VERSION", "2024-12-01-preview").strip()
timeout_raw = os.environ.get("GRAPHIFY_API_TIMEOUT", "").strip()
timeout_s: float = 600.0
if timeout_raw:
try:
v = float(timeout_raw)
if v > 0:
timeout_s = v
except ValueError:
pass
return AzureOpenAI(api_key=api_key, azure_endpoint=endpoint, api_version=api_version, timeout=timeout_s)
def _call_azure(
api_key: str,
endpoint: str,
model: str,
user_message: str,
temperature: float | None = 0,
max_tokens: int = 8192,
*,
deep_mode: bool = False,
) -> dict:
"""Call Azure OpenAI Service via the AzureOpenAI SDK client."""
client = _azure_client(api_key, endpoint)
kwargs: dict = {
"model": model,
"messages": [
{"role": "system", "content": _extraction_system(deep=deep_mode)},
{"role": "user", "content": user_message},
],
"max_completion_tokens": max_tokens,
}
if temperature is not None:
kwargs["temperature"] = temperature
resp = client.chat.completions.create(**kwargs)
if not resp.choices or resp.choices[0].message is None:
raise ValueError("Azure OpenAI returned empty or filtered response")
raw_content = resp.choices[0].message.content
result = _parse_llm_json(raw_content or "{}")
result["input_tokens"] = resp.usage.prompt_tokens if resp.usage else 0
result["output_tokens"] = resp.usage.completion_tokens if resp.usage else 0
result["model"] = model
result["finish_reason"] = resp.choices[0].finish_reason
if _response_is_hollow(raw_content, result) and result["finish_reason"] != "length":
print(
"[graphify] azure returned a hollow response; treating as "
"truncation so adaptive retry can bisect the chunk.",
file=sys.stderr,
)
result["finish_reason"] = "length"
return result
def _call_bedrock(model: str, user_message: str, max_tokens: int = 8192, *, deep_mode: bool = False, images: list[_ImageRef] | None = None) -> dict:
"""Call AWS Bedrock via boto3 Converse API using the standard AWS credential chain."""
try:
import boto3
@@ -699,7 +1033,7 @@ def _call_bedrock(model: str, user_message: str, max_tokens: int = 8192, *, deep
resp = client.converse(
modelId=model,
system=[{"text": _extraction_system(deep=deep_mode)}],
messages=[{"role": "user", "content": [{"text": user_message}]}],
messages=[{"role": "user", "content": _bedrock_content(user_message, images or [])}],
inferenceConfig={"maxTokens": max_tokens, "temperature": 0},
)
except botocore.exceptions.ClientError as exc:
@@ -744,7 +1078,8 @@ def extract_files_direct(
if backend is None:
raise ValueError(
"No LLM backend configured. Set one of: GEMINI_API_KEY, ANTHROPIC_API_KEY, "
"OPENAI_API_KEY, DEEPSEEK_API_KEY, MOONSHOT_API_KEY, OLLAMA_BASE_URL, "
"OPENAI_API_KEY, DEEPSEEK_API_KEY, MOONSHOT_API_KEY, "
"AZURE_OPENAI_API_KEY+AZURE_OPENAI_ENDPOINT, OLLAMA_BASE_URL, "
"or AWS credentials. Pass backend= explicitly to select a provider."
)
if backend not in BACKENDS:
@@ -771,15 +1106,42 @@ def extract_files_direct(
f"Set {_format_backend_env_keys(backend)} or pass api_key=."
)
mdl = model or _default_model_for_backend(backend)
user_msg = _read_files(files, root)
# Separate raster images from text-like files. Text goes through _read_files
# as before; images become structured refs the backend renders as pixels
# (vision backends) or as a text reference node (everything else).
text_files, image_files = _partition_semantic_files(files)
user_msg = _read_files(text_files, root)
vision = _backend_supports_vision(backend)
# Only base64 (inline) vision backends need the bytes loaded + size-capped;
# path-based backends (claude-cli) and non-vision backends do not.
read_bytes = vision and backend not in _PATH_IMAGE_BACKENDS
image_refs = _build_image_refs(image_files, root, read_bytes=read_bytes) if image_files else []
if image_refs and not vision:
image_refs = _strip_pixels(image_refs)
max_out = _resolve_max_tokens(cfg.get("max_tokens", 8192))
if backend == "claude":
return _call_claude(key, mdl, user_msg, max_tokens=max_out, deep_mode=deep_mode)
return _call_claude(key, mdl, user_msg, max_tokens=max_out, deep_mode=deep_mode, images=image_refs)
if backend == "claude-cli":
return _call_claude_cli(user_msg, max_tokens=max_out, deep_mode=deep_mode)
return _call_claude_cli(user_msg, max_tokens=max_out, deep_mode=deep_mode, images=image_refs)
if backend == "bedrock":
return _call_bedrock(mdl, user_msg, max_tokens=max_out, deep_mode=deep_mode)
return _call_bedrock(mdl, user_msg, max_tokens=max_out, deep_mode=deep_mode, images=image_refs)
if backend == "azure":
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT", "").strip()
if not endpoint:
raise ValueError(
"Azure OpenAI backend requires AZURE_OPENAI_ENDPOINT to be set "
"(e.g. https://my-resource.openai.azure.com/)."
)
return _call_azure(
key,
endpoint,
mdl,
user_msg,
temperature=cfg.get("temperature", 0),
max_tokens=max_out,
deep_mode=deep_mode,
)
return _call_openai_compat(
cfg["base_url"],
key,
@@ -790,6 +1152,7 @@ def extract_files_direct(
max_completion_tokens=_resolve_max_tokens(cfg.get("max_completion_tokens", 8192)),
backend=backend,
deep_mode=deep_mode,
images=image_refs,
)
@@ -802,6 +1165,10 @@ def _estimate_file_tokens(path: Path) -> int:
the `=== rel ===` separator. Returns 0 for unreadable paths so they don't
blow up packing.
"""
# Raster images are not read as text; a vision model bills them at a roughly
# fixed token cost, so estimate by image count rather than (binary) byte size.
if _is_vision_image(path):
return _IMAGE_TOKEN_ESTIMATE
if _TOKENIZER is None:
try:
size = path.stat().st_size
@@ -841,16 +1208,22 @@ def _pack_chunks_by_tokens(
chunks: list[list[Path]] = []
current: list[Path] = []
current_tokens = 0
current_images = 0
for directory in sorted(by_dir):
for path in by_dir[directory]:
cost = _estimate_file_tokens(path)
if current and current_tokens + cost > token_budget:
is_image = _is_vision_image(path)
over_budget = current_tokens + cost > token_budget
over_images = is_image and current_images >= _MAX_IMAGES_PER_CHUNK
if current and (over_budget or over_images):
chunks.append(current)
current = []
current_tokens = 0
current_images = 0
current.append(path)
current_tokens += cost
current_images += is_image
if current:
chunks.append(current)
@@ -1215,6 +1588,7 @@ def _call_llm(prompt: str, *, backend: str, max_tokens: int = 200) -> str:
raise RuntimeError(f"claude -p produced unparseable JSON envelope: {exc}") from exc
return envelope.get("result", "")
if backend == "bedrock":
try:
import boto3
@@ -1231,6 +1605,23 @@ def _call_llm(prompt: str, *, backend: str, max_tokens: int = 200) -> str:
)
return resp.get("output", {}).get("message", {}).get("content", [{}])[0].get("text", "")
if backend == "azure":
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT", "").strip()
if not endpoint:
raise ValueError(
"Azure OpenAI backend requires AZURE_OPENAI_ENDPOINT to be set."
)
azure_client = _azure_client(key, endpoint)
resp = azure_client.chat.completions.create(
model=mdl,
messages=[{"role": "user", "content": prompt}],
max_completion_tokens=max_tokens,
temperature=cfg.get("temperature", 0),
)
if not resp.choices or resp.choices[0].message is None:
raise ValueError("Azure OpenAI returned empty or filtered response")
return resp.choices[0].message.content or ""
# OpenAI-compatible (kimi, openai, gemini, ollama)
try:
from openai import OpenAI
@@ -1340,7 +1731,7 @@ def _validate_ollama_base_url(url: str, *, warn: bool = True) -> None:
def detect_backend() -> str | None:
"""Return the name of whichever backend has an API key set, or None.
Priority: gemini kimi claude openai bedrock ollama (last, opt-in).
Priority: gemini kimi claude openai deepseek azure bedrock ollama (last, opt-in).
Ollama is intentionally checked LAST so a paid API key (Anthropic/OpenAI/etc.)
is never silently shadowed by an incidental OLLAMA_BASE_URL in the environment
@@ -1351,6 +1742,8 @@ def detect_backend() -> str | None:
for backend in ("gemini", "kimi", "claude", "openai", "deepseek"):
if _get_backend_api_key(backend):
return backend
if _get_backend_api_key("azure") and os.environ.get("AZURE_OPENAI_ENDPOINT"):
return "azure"
if os.environ.get("AWS_PROFILE") or os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION"):
return "bedrock"
ollama_url = os.environ.get("OLLAMA_BASE_URL")
@@ -1358,7 +1751,7 @@ def detect_backend() -> str | None:
_validate_ollama_base_url(ollama_url)
return "ollama"
for name in BACKENDS:
if name not in ("gemini", "kimi", "claude", "openai", "deepseek", "bedrock", "ollama", "claude-cli"):
if name not in ("gemini", "kimi", "claude", "openai", "deepseek", "azure", "bedrock", "ollama", "claude-cli"):
if _get_backend_api_key(name):
return name
return None
+142
View File
@@ -0,0 +1,142 @@
from __future__ import annotations
from pathlib import Path
from graphify.extract import extract_sql
def _quote_ident(name: str) -> str:
"""Double-quote a PostgreSQL identifier, escaping embedded double-quotes."""
return '"' + name.replace('"', '""') + '"'
def introspect_postgres(dsn: str | None = None) -> dict:
"""Connect to PostgreSQL, reconstruct DDL, and extract via extract_sql()."""
try:
import psycopg
except ModuleNotFoundError:
raise ImportError(
"psycopg is required for --postgres. "
"Install with: pip install 'graphify[postgres]'"
)
try:
conn = psycopg.connect(dsn or "") # empty string = PG* env vars
except psycopg.OperationalError as exc:
# Sanitize: strip the DSN/credentials that psycopg may embed in the
# OperationalError message (e.g. "connection to server … failed: …\nDETAIL: …")
msg = str(exc).split("\n")[0]
raise ConnectionError(f"could not connect to PostgreSQL: {msg}") from None
try:
conn.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE")
# 1. Query tables
with conn.cursor() as cur:
cur.execute("""
SELECT table_schema, table_name, table_type
FROM information_schema.tables
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY table_schema, table_name;
""")
tables = cur.fetchall()
# 2. Query views
cur.execute("""
SELECT table_schema, table_name, view_definition
FROM information_schema.views
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY table_schema, table_name;
""")
views = cur.fetchall()
# 3. Query routines (functions/procedures), including language
cur.execute("""
SELECT routine_schema, routine_name, routine_type,
routine_definition, external_language
FROM information_schema.routines
WHERE routine_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY routine_schema, routine_name;
""")
routines = cur.fetchall()
# 4. Query foreign keys — grouped by constraint to handle composites
cur.execute("""
SELECT
tc.constraint_name,
kcu1.table_schema,
kcu1.table_name,
ARRAY_AGG(kcu1.column_name ORDER BY kcu1.ordinal_position) AS columns,
kcu2.table_schema AS foreign_table_schema,
kcu2.table_name AS foreign_table_name,
ARRAY_AGG(kcu2.column_name ORDER BY kcu2.ordinal_position) AS foreign_columns
FROM
information_schema.table_constraints AS tc
JOIN information_schema.referential_constraints AS rc
ON tc.constraint_name = rc.constraint_name
AND tc.table_schema = rc.constraint_schema
JOIN information_schema.key_column_usage AS kcu1
ON tc.constraint_name = kcu1.constraint_name
AND tc.table_schema = kcu1.table_schema
JOIN information_schema.key_column_usage AS kcu2
ON rc.unique_constraint_name = kcu2.constraint_name
AND rc.unique_constraint_schema = kcu2.table_schema
AND kcu1.position_in_unique_constraint = kcu2.ordinal_position
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema NOT IN ('pg_catalog', 'information_schema')
GROUP BY tc.constraint_name, kcu1.table_schema, kcu1.table_name,
kcu2.table_schema, kcu2.table_name
ORDER BY kcu1.table_schema, kcu1.table_name;
""")
fks = cur.fetchall()
finally:
conn.close()
ddl = []
# Tables — quote identifiers to handle reserved words, hyphens, mixed-case
for schema, name, ttype in tables:
if ttype == "BASE TABLE":
ddl.append(f"CREATE TABLE {_quote_ident(schema)}.{_quote_ident(name)} (id INT);")
# Views — real body if available, stub if NULL (permission denied)
for schema, name, body in views:
if body:
ddl.append(f"CREATE VIEW {_quote_ident(schema)}.{_quote_ident(name)} AS {body};")
else:
ddl.append(f"CREATE VIEW {_quote_ident(schema)}.{_quote_ident(name)} AS SELECT 1;")
# Functions & Procedures — real body if available, stub if NULL
# Use $gfx$ as the dollar-quote tag to avoid collision with $$ inside bodies.
# Use external_language from the catalog; fall back to plpgsql if NULL/blank.
for schema, name, rtype, body, ext_lang in routines:
lang = (ext_lang or "plpgsql").lower()
fn_sig = f"{_quote_ident(schema)}.{_quote_ident(name)}()"
stub_body = "BEGIN SELECT 1; END;"
if rtype in ("FUNCTION", "PROCEDURE"):
actual_body = body if body else stub_body
# Represent PROCEDUREs as FUNCTION so tree-sitter-sql can parse them
ddl.append(
f"CREATE FUNCTION {fn_sig} RETURNS void"
f" AS $gfx$ {actual_body} $gfx$ LANGUAGE {lang};"
)
# FK edges — one ALTER TABLE per constraint (handles composite FKs correctly)
for constraint_name, t_schema, t_name, cols, r_schema, r_name, r_cols in fks:
col_list = ", ".join(_quote_ident(c) for c in cols)
ref_col_list = ", ".join(_quote_ident(c) for c in r_cols)
ddl.append(
f"ALTER TABLE {_quote_ident(t_schema)}.{_quote_ident(t_name)} "
f"ADD CONSTRAINT {_quote_ident(constraint_name)} "
f"FOREIGN KEY ({col_list}) REFERENCES {_quote_ident(r_schema)}.{_quote_ident(r_name)}({ref_col_list});"
)
ddl_string = "\n".join(ddl)
# Determine host/dbname for virtual path DSN sanitization
info = psycopg.conninfo.conninfo_to_dict(dsn or "")
host = info.get("host", "localhost")
dbname = info.get("dbname", "db")
virtual_path = Path(f"postgresql://{host}/{dbname}")
# Pass virtual path and in-memory DDL content to extract_sql
result = extract_sql(virtual_path, content=ddl_string)
return result
+308 -6
View File
@@ -136,11 +136,38 @@ def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]:
scored = []
norm_terms = [tok for t in terms for tok in _search_tokens(t)]
idf = _compute_idf(G, norm_terms)
# Whole-query string for full-label matching (mirrors _find_node's `term`).
joined = " ".join(norm_terms)
# Weight the full-query bonus by the rarest constituent term so a specific
# multi-word label still outweighs common-token noise; floor at 1.0.
joined_w = max((idf.get(t, 1.0) for t in norm_terms), default=1.0)
for nid, data in G.nodes(data=True):
norm_label = data.get("norm_label") or _strip_diacritics(data.get("label") or "").lower()
bare_label = norm_label.rstrip("()")
# Tokenized form of the label (punctuation stripped, same transform as the
# query). norm_label may still carry punctuation like ':' or '-', which a
# tokenized query can never equal; comparing token-joined forms on both
# sides makes "uoce: dehumidifier driver" match query "uoce dehumidifier
# driver".
label_tokens = " ".join(_search_tokens(data.get("label") or ""))
source = (data.get("source_file") or "").lower()
score = 0.0
# Full-query tier: a multi-word query that equals (or prefixes) the whole
# label must dominate the per-token bag-of-words sums below, so `path`/
# `query` resolve the same node `explain` does (via _find_node). Without
# this, no single token equals a multi-word label, the per-token exact
# tier never fires, and every node sharing the token set ties -> arbitrary
# node-id sort -> wrong/disconnected endpoint -> false "No path found".
if joined:
nid_lower = nid.lower()
if joined in (norm_label, bare_label, label_tokens, nid_lower):
score += _EXACT_MATCH_BONUS * 10 * joined_w
elif (
norm_label.startswith(joined)
or bare_label.startswith(joined)
or label_tokens.startswith(joined)
):
score += _PREFIX_MATCH_BONUS * 10 * joined_w
for t in norm_terms:
w = idf.get(t, 1.0)
# Three-tier precedence: exact > prefix > substring (take the
@@ -155,7 +182,10 @@ def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]:
score += _SOURCE_MATCH_BONUS * w
if score > 0:
scored.append((score, nid))
return sorted(scored, reverse=True)
# Sort by score desc; break ties toward the shorter label so a concise exact
# match beats a longer superset that happens to share the same score.
scored.sort(key=lambda s: (-s[0], len(G.nodes[s[1]].get("label") or s[1]), s[1]))
return scored
def _pick_seeds(scored: list[tuple[float, str]], max_k: int = 3, gap_ratio: float = 0.2) -> list[str]:
@@ -472,13 +502,18 @@ def _filter_blank_stdin() -> None:
sys.stdin = open(0, "r", closefd=False)
def serve(graph_path: str = "graphify-out/graph.json") -> None:
"""Start the MCP server. Requires pip install mcp."""
def _build_server(graph_path: str):
"""Build the configured low-level MCP Server (shared by every transport).
All graph query tools and resources are registered here over a single
``mcp.server.Server`` instance; the caller picks the transport (stdio or
Streamable HTTP) and runs it. Hot-reload of graph.json works the same way
regardless of transport, since reloads happen inside the tool handlers.
"""
import threading
try:
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp import types
from mcp.types import AnyUrl
except ImportError as e:
@@ -992,8 +1027,19 @@ def serve(graph_path: str = "graphify-out/graph.json") -> None:
except Exception as exc:
return [types.TextContent(type="text", text=f"Error executing {name}: {exc}")]
return server
def serve(graph_path: str = "graphify-out/graph.json") -> None:
"""Start the MCP server over stdio (the default, per-developer transport)."""
try:
from mcp.server.stdio import stdio_server
except ImportError as e:
raise ImportError('mcp not installed. Run: pip install "graphifyy[mcp]"') from e
import asyncio
server = _build_server(graph_path)
async def main() -> None:
async with stdio_server() as streams:
await server.run(streams[0], streams[1], server.create_initialization_options())
@@ -1002,6 +1048,262 @@ def serve(graph_path: str = "graphify-out/graph.json") -> None:
asyncio.run(main())
class _MCPASGIApp:
"""Raw-ASGI wrapper around the Streamable HTTP session manager.
Passed to a Starlette ``Route`` as a class instance (not a function) so
Starlette treats it as an ASGI app: it serves the exact mount path for all
methods (GET/POST/DELETE) with no request/response wrapping and no
trailing-slash redirect mirroring how FastMCP mounts the same manager.
"""
def __init__(self, manager) -> None:
self._manager = manager
async def __call__(self, scope, receive, send) -> None:
await self._manager.handle_request(scope, receive, send)
class _ApiKeyMiddleware:
"""Pure-ASGI API-key gate for the HTTP transport.
Implemented as raw ASGI (not Starlette's BaseHTTPMiddleware) on purpose:
BaseHTTPMiddleware buffers responses and breaks the Streamable HTTP SSE
stream. This short-circuits with 401 before the request ever reaches the
session manager, leaving the streaming path untouched for authorized calls.
"""
def __init__(self, app, api_key: str) -> None:
self.app = app
self._expected = api_key.encode("utf-8")
async def __call__(self, scope, receive, send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
import hmac
headers = dict(scope.get("headers") or [])
provided = headers.get(b"x-api-key")
if provided is None:
# RFC 6750: the auth scheme token is case-insensitive.
scheme, _, token = headers.get(b"authorization", b"").partition(b" ")
if scheme.lower() == b"bearer" and token:
provided = token.strip()
# Constant-time compare; reject when no key was supplied at all.
if provided is None or not hmac.compare_digest(provided, self._expected):
body = b'{"error": "unauthorized"}'
await send({
"type": "http.response.start",
"status": 401,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode("ascii")),
],
})
await send({"type": "http.response.body", "body": body})
return
await self.app(scope, receive, send)
def _build_http_app(
graph_path: str,
*,
host: str = "127.0.0.1",
port: int = 8080,
api_key: str | None = None,
path: str = "/mcp",
json_response: bool = False,
stateless: bool = False,
session_timeout: float | None = 3600.0,
):
"""Build the Starlette ASGI app for the Streamable HTTP transport.
Split out from :func:`serve_http` (which blocks on uvicorn) so the wiring
can be exercised with an in-process ASGI test client.
``session_timeout`` reaps stateful sessions idle for that many seconds so a
long-running shared server does not leak memory when IDE clients disconnect
without sending a DELETE. ``None`` (or <= 0) disables reaping; it is forced
to ``None`` in stateless mode, which has no sessions to reap.
"""
try:
import contextlib
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.routing import Route
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.server.transport_security import TransportSecuritySettings
except ImportError as e:
raise ImportError(
'HTTP transport needs the mcp extra (mcp + starlette + uvicorn). '
'Run: pip install "graphifyy[mcp]"'
) from e
# A blank key (e.g. --api-key "" or an empty GRAPHIFY_API_KEY) must not be
# mistaken for "auth on" — normalize it to None so the gate is unambiguous.
api_key = (api_key or "").strip() or None
server = _build_server(graph_path)
# DNS-rebinding protection. When the operator binds a wildcard address they
# are intentionally exposing the server, so accept any Host header; for a
# loopback/specific bind, restrict Host to that address (with and without
# the port) plus the localhost aliases.
if host in ("0.0.0.0", "::", ""):
security = TransportSecuritySettings(enable_dns_rebinding_protection=False)
else:
allowed = {host, "localhost", "127.0.0.1"}
allowed |= {f"{h}:{port}" for h in list(allowed)}
security = TransportSecuritySettings(allowed_hosts=sorted(allowed))
# The SDK rejects a non-positive timeout and forbids one in stateless mode.
idle_timeout = None if (stateless or not session_timeout or session_timeout <= 0) else session_timeout
manager = StreamableHTTPSessionManager(
app=server,
json_response=json_response,
stateless=stateless,
security_settings=security,
session_idle_timeout=idle_timeout,
)
@contextlib.asynccontextmanager
async def lifespan(_app):
# The session manager owns an anyio task group that must wrap the whole
# server lifetime, so enter it here rather than per-request.
async with manager.run():
yield
middleware = []
if api_key:
middleware.append(Middleware(_ApiKeyMiddleware, api_key=api_key))
return Starlette(
routes=[Route(path, endpoint=_MCPASGIApp(manager))],
middleware=middleware,
lifespan=lifespan,
)
def serve_http(
graph_path: str = "graphify-out/graph.json",
*,
host: str = "127.0.0.1",
port: int = 8080,
api_key: str | None = None,
path: str = "/mcp",
json_response: bool = False,
stateless: bool = False,
session_timeout: float | None = 3600.0,
) -> None:
"""Start the MCP server over Streamable HTTP (MCP spec 2025-03-26).
Serves the same tools/resources as the stdio transport, so a single shared
process can host the graph for a whole team. Clients point their IDE MCP
config at ``http://<host>:<port><path>`` (default ``/mcp``).
``api_key`` (or the ``GRAPHIFY_API_KEY`` env var) enables a simple header
check (``Authorization: Bearer <key>`` or ``X-API-Key: <key>``). OAuth is a
deliberate follow-up. Binding ``0.0.0.0`` exposes the server beyond
localhost set an api_key when you do.
"""
try:
import uvicorn
except ImportError as e:
raise ImportError(
'HTTP transport needs the mcp extra (mcp + starlette + uvicorn). '
'Run: pip install "graphifyy[mcp]"'
) from e
api_key = (api_key or "").strip() or None
app = _build_http_app(
graph_path,
host=host,
port=port,
api_key=api_key,
path=path,
json_response=json_response,
stateless=stateless,
session_timeout=session_timeout,
)
auth_note = "api-key required" if api_key else "no auth (set --api-key to require one)"
print(
f"graphify MCP server (streamable-http) on http://{host}:{port}{path} - {auth_note}",
file=sys.stderr,
)
if host in ("0.0.0.0", "::", "") and not api_key:
print(
f"WARNING: binding {host or '0.0.0.0'} with no api-key exposes the graph "
"unauthenticated on the network. Set --api-key (or GRAPHIFY_API_KEY).",
file=sys.stderr,
)
uvicorn.run(app, host=host, port=port)
def _main(argv: list[str] | None = None) -> None:
import argparse
import os
parser = argparse.ArgumentParser(
prog="python -m graphify.serve",
description="Serve a graphify knowledge graph over MCP (stdio or Streamable HTTP).",
)
parser.add_argument(
"graph_path",
nargs="?",
default="graphify-out/graph.json",
help="Path to graph.json (default: graphify-out/graph.json)",
)
parser.add_argument(
"--transport",
choices=["stdio", "http"],
default="stdio",
help="Transport to serve on (default: stdio)",
)
parser.add_argument("--host", default="127.0.0.1", help="HTTP bind host (default: 127.0.0.1)")
parser.add_argument("--port", type=int, default=8080, help="HTTP bind port (default: 8080)")
parser.add_argument(
"--api-key",
default=os.environ.get("GRAPHIFY_API_KEY"),
help="Require this key on the HTTP transport (env: GRAPHIFY_API_KEY)",
)
parser.add_argument("--path", default="/mcp", help="HTTP mount path (default: /mcp)")
parser.add_argument(
"--json-response",
action="store_true",
help="Return plain JSON responses instead of SSE streams",
)
parser.add_argument(
"--stateless",
action="store_true",
help="Run without per-session state (for load-balanced / CI deployments)",
)
parser.add_argument(
"--session-timeout",
type=float,
default=3600.0,
help="Reap stateful sessions idle this many seconds (default: 3600; 0 disables)",
)
args = parser.parse_args(argv)
if args.transport == "http":
serve_http(
args.graph_path,
host=args.host,
port=args.port,
api_key=args.api_key,
path=args.path,
json_response=args.json_response,
stateless=args.stateless,
session_timeout=args.session_timeout,
)
else:
serve(args.graph_path)
if __name__ == "__main__":
graph_path = sys.argv[1] if len(sys.argv) > 1 else "graphify-out/graph.json"
serve(graph_path)
_main()
+1 -1
View File
@@ -684,7 +684,7 @@ cost_path.write_text(json.dumps(cost, indent=2))
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f .graphify_detect.json .graphify_extract.json .graphify_ast.json .graphify_semantic.json .graphify_analysis.json .graphify_labels.json .graphify_chunk_*.json
rm -f .graphify_detect.json .graphify_extract.json .graphify_ast.json .graphify_semantic.json .graphify_analysis.json .graphify_labels.json; find . -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
+2 -1
View File
@@ -520,7 +520,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
+2 -1
View File
@@ -523,7 +523,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
+2 -1
View File
@@ -520,7 +520,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
+2 -1
View File
@@ -523,7 +523,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
+1 -1
View File
@@ -801,7 +801,7 @@ cost_path.write_text(json.dumps(cost, indent=2))
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_labels.json graphify-out/.graphify_chunk_*.json graphify-out/.graphify_incremental.json graphify-out/.graphify_transcripts.json graphify-out/.graphify_old.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_labels.json graphify-out/.graphify_incremental.json graphify-out/.graphify_transcripts.json graphify-out/.graphify_old.json; find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
+2 -1
View File
@@ -520,7 +520,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
+2 -1
View File
@@ -523,7 +523,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
+2 -1
View File
@@ -522,7 +522,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
+2 -1
View File
@@ -515,7 +515,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
+2 -1
View File
@@ -522,7 +522,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
+2 -1
View File
@@ -521,7 +521,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
+2 -1
View File
@@ -519,7 +519,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
+2 -1
View File
@@ -545,7 +545,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
+2 -1
View File
@@ -523,7 +523,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
+6 -1
View File
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
+6 -1
View File
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
+6 -1
View File
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
+6 -1
View File
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
+6 -1
View File
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
+6 -1
View File
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
+6 -1
View File
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
+6 -1
View File
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
+6 -1
View File
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
+6 -1
View File
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
+6 -1
View File
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
+6 -1
View File
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
+10
View File
@@ -543,9 +543,19 @@ def _rebuild_code(
evict_sources.add(sf)
evict_sources.add(norm)
deleted_paths.add(norm)
# On a full re-extraction every code file is re-extracted, so
# new_ast_ids is the complete current AST set. Any AST-marked node
# missing from it is stale and must be dropped even if its source
# file still exists (a symbol removed from a surviving file, #1116).
# Gate on full_rebuild: in incremental mode an AST node from an
# unchanged file is legitimately absent from new_ast_ids. Semantic
# nodes lack the "_origin" marker, so they are never dropped here —
# only by the deleted-file eviction in evict_sources above.
full_rebuild = changed_paths is None
preserved_nodes = [
n for n in existing.get("nodes", [])
if n["id"] not in new_ast_ids
and not (full_rebuild and n.get("_origin") == "ast")
and (not evict_sources or n.get("source_file") not in evict_sources)
]
all_ids = new_ast_ids | {n["id"] for n in preserved_nodes}
+4 -3
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "graphifyy"
version = "0.8.32"
version = "0.8.34"
description = "AI coding assistant skill (Claude Code, CodeBuddy, Codex, OpenCode, Kilo Code, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Devin CLI, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph"
readme = "README.md"
license = { file = "LICENSE" }
@@ -52,10 +52,11 @@ mcp = ["mcp"]
neo4j = ["neo4j"]
pdf = ["pypdf", "markdownify"]
watch = ["watchdog"]
svg = ["matplotlib"]
svg = ["matplotlib", "numpy>=2.0; python_version >= '3.13'"]
leiden = ["graspologic; python_version < '3.13'"]
office = ["python-docx", "openpyxl"]
google = ["openpyxl"]
postgres = ["psycopg[binary]"]
video = ["faster-whisper; python_version >= '3.11'", "yt-dlp"]
kimi = ["openai", "tiktoken"]
ollama = ["openai"]
@@ -70,7 +71,7 @@ sql = ["tree-sitter-sql"]
# avoids breaking the default `uv tool install graphifyy` for everyone (#1104).
dm = ["tree-sitter-dm"]
terraform = ["tree-sitter-hcl"]
all = ["mcp", "neo4j", "pypdf", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp", "matplotlib", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl"]
all = ["mcp", "neo4j", "pypdf", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl"]
[project.scripts]
graphify = "graphify.__main__:main"
+55
View File
@@ -0,0 +1,55 @@
public with sharing class AccountService {
private static final String DEFAULT_TYPE = 'Customer';
public interface Notifiable {
void notify(String message);
}
public enum AccountStatus { ACTIVE, INACTIVE, PENDING }
@AuraEnabled
public static List<Account> getAccounts(String accountType) {
return [SELECT Id, Name, Type FROM Account WHERE Type = :accountType];
}
@future
public static void updateAccountsAsync(List<Id> accountIds) {
List<Account> accounts = [SELECT Id FROM Account WHERE Id IN :accountIds];
for (Account acc : accounts) {
acc.Type = DEFAULT_TYPE;
}
update accounts;
}
@InvocableMethod(label='Create Account' description='Creates a new Account')
public static List<Id> createAccounts(List<String> names) {
List<Account> toInsert = new List<Account>();
for (String n : names) {
toInsert.add(new Account(Name = n));
}
insert toInsert;
List<Id> ids = new List<Id>();
for (Account a : toInsert) {
ids.add(a.Id);
}
return ids;
}
public static void deleteOldAccounts(Date cutoff) {
List<Account> old = [SELECT Id FROM Account WHERE CreatedDate < :cutoff];
delete old;
}
@isTest
static void testGetAccounts() {
List<Account> result = getAccounts('Customer');
System.assertNotEquals(null, result);
}
@isTest
static void testCreateAccounts() {
List<Id> ids = createAccounts(new List<String>{'Test'});
System.assertEquals(1, ids.size());
}
}
+8
View File
@@ -0,0 +1,8 @@
trigger AccountTrigger on Account (before insert, before update, after insert, after update) {
if (Trigger.isBefore) {
AccountService.validateAccounts(Trigger.new);
}
if (Trigger.isAfter && Trigger.isInsert) {
AccountService.sendWelcomeNotifications(Trigger.new);
}
}
+34
View File
@@ -58,3 +58,37 @@ def test_affected_cli_relation_filter_limits_reverse_traversal(monkeypatch, tmp_
assert "Relations: calls" in out
assert "X()" in out
assert "__init__.py" not in out
def test_affected_cli_forces_directed_on_undirected_graph(monkeypatch, tmp_path, capsys):
"""A graph persisted with directed=false must still recover caller->callee
direction (#1174): affected on the callee returns the caller, not the callee
or nothing. Without forcing directed=True, node_link_graph builds an
undirected Graph, predecessors() collapses, and the reverse traversal breaks.
"""
graph = nx.DiGraph()
graph.add_node("A", label="caller_fn", source_file="a.py", source_location="L1")
graph.add_node("B", label="callee_fn", source_file="b.py", source_location="L2")
graph.add_edge("A", "B", relation="calls", context="call", confidence="EXTRACTED")
data = json_graph.node_link_data(graph, edges="links")
# Persist as undirected on disk to reproduce the bug condition.
data["directed"] = False
graph_path = tmp_path / "graph.json"
graph_path.write_text(json.dumps(data), encoding="utf-8")
monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
monkeypatch.setattr(
mainmod.sys,
"argv",
["graphify", "affected", "B", "--relation", "calls", "--graph", str(graph_path)],
)
mainmod.main()
out = capsys.readouterr().out
# A (the caller) is affected by a change to B (the callee).
assert "caller_fn" in out
assert "calls" in out
# B is the query node, not an affected node, and the result is not empty.
assert "No affected nodes found." not in out
+64
View File
@@ -298,6 +298,43 @@ def test_detect_incremental_propagates_follow_symlinks(tmp_path, monkeypatch):
assert second["new_total"] == 0
def test_detect_incremental_survives_dict_valued_mtime(tmp_path, monkeypatch):
"""A schema-drifted manifest whose entry stores mtime as a nested dict
(instead of a float) must not crash detect_incremental (#1163). The guard
coerces the bad mtime to None so the file is re-verified by content hash and
treated as new, rather than blowing up on the int/float comparison.
"""
import json
monkeypatch.chdir(tmp_path)
src = tmp_path / "mod.py"
src.write_text("def f():\n return 1\n", encoding="utf-8")
manifest_dir = tmp_path / "graphify-out"
manifest_dir.mkdir()
manifest_path = str(manifest_dir / "manifest.json")
# Drifted entry: a non-empty ast_hash (so the dict branch reaches the mtime
# comparison) with mtime stored as a dict rather than a float. Absolute key
# so it matches detect's absolute file paths without re-anchoring.
drifted = {
str(src.resolve()): {
"mtime": {"mtime": 123.0},
"ast_hash": "deadbeef" * 4,
"semantic_hash": "cafebabe" * 4,
}
}
Path(manifest_path).write_text(json.dumps(drifted), encoding="utf-8")
# Must not raise (pre-fix: TypeError comparing float and dict).
result = detect_incremental(tmp_path, manifest_path)
# The drifted file is re-classified as new rather than silently skipped.
assert any("mod.py" in f for f in result["new_files"]["code"])
assert not any("mod.py" in f for f in result["unchanged_files"]["code"])
def test_classify_video_extensions():
"""Video and audio file extensions should classify as VIDEO."""
from graphify.detect import FileType
@@ -638,6 +675,33 @@ def test_sensitive_token_config_yaml():
assert _is_sensitive(Path("token_config.yaml"))
# ── Generic keywords must be load-bearing: topic slugs are not secret stores ──
# A keyword buried mid-phrase in a >=3-word descriptive name is a note ABOUT
# the topic, not a credential file. It must not be silently dropped.
def test_sensitive_does_not_flag_token_economics_note():
assert not _is_sensitive(Path("token-economics-of-recall.md"))
def test_sensitive_does_not_flag_password_policy_discussion():
assert not _is_sensitive(Path("password-policy-discussion.md"))
def test_sensitive_flags_keyword_at_end_of_long_name():
# Keyword as the final word names the file's contents — still a secret store.
assert _is_sensitive(Path("github-personal-access-token.txt"))
def test_sensitive_flags_my_private_key_txt():
# Multi-word keyword at end of stem (end-of-stem check runs before word
# counting, so splitting private_key on "_" cannot un-flag it).
assert _is_sensitive(Path("my_private_key.txt"))
def test_sensitive_flags_dotfile_token():
# Leading dot stripped before stem extraction; ".token" keeps its keyword.
assert _is_sensitive(Path(".token"))
def test_sensitive_flags_plural_tokens_txt():
assert _is_sensitive(Path("tokens.txt"))
# ── Issue #933: failed-chunk files must not be frozen in manifest ─────────────
def test_save_manifest_skips_semantic_hash_for_files_without_cache(tmp_path):
+117
View File
@@ -216,3 +216,120 @@ def test_hook_check_no_additionalContext(tmp_path):
assert result.returncode == 0
assert result.stdout == ""
assert result.stderr == ""
# ── #1161: background rebuild must not rely on nohup (missing on Git for Windows) ──
import ast # noqa: E402
import re # noqa: E402
from graphify.hooks import ( # noqa: E402
_HOOK_SCRIPT,
_CHECKOUT_SCRIPT,
_REBUILD_BODY_COMMIT,
_REBUILD_BODY_CHECKOUT,
_detached_launch,
)
_HOOK_SCRIPTS = [("post-commit", _HOOK_SCRIPT), ("post-checkout", _CHECKOUT_SCRIPT)]
@pytest.mark.parametrize("name,script", _HOOK_SCRIPTS)
def test_hooks_do_not_use_nohup(name, script):
"""Git for Windows' bundled shell ships no `nohup`/`setsid`, so the old
`nohup ... &` launch died with 'nohup: command not found' and the rebuild
silently never ran (#1161). The generated hooks must not reference either."""
assert "nohup" not in script, f"{name} still references nohup (#1161)"
assert "setsid" not in script, f"{name} still references setsid (#1161)"
assert "disown" not in script, f"{name} still uses disown (#1161)"
@pytest.mark.parametrize("name,script", _HOOK_SCRIPTS)
def test_hooks_use_cross_platform_detach(name, script):
"""The replacement detaches via Python: start_new_session on POSIX and
DETACHED_PROCESS|CREATE_NEW_PROCESS_GROUP on Windows (#1161)."""
assert "subprocess.Popen" in script
assert "start_new_session=True" in script, f"{name} missing POSIX detach"
assert "0x00000008" in script, f"{name} missing Windows DETACHED_PROCESS flag"
assert "0x00000200" in script, f"{name} missing CREATE_NEW_PROCESS_GROUP flag"
def _launcher_payload(script: str) -> str:
"""Extract the `python -c "<payload>"` the hook hands to GRAPHIFY_PYTHON.
The launcher is the only `-c` invocation whose body begins with
`import os, subprocess, sys` (the interpreter-detection probes in
_PYTHON_DETECT use `-c "import graphify"`)."""
m = re.search(r'-c "(import os, subprocess, sys.*?)"\n', script, re.DOTALL)
assert m, "launcher payload not found"
return m.group(1)
@pytest.mark.parametrize("name,script", _HOOK_SCRIPTS)
def test_launcher_payload_is_shell_quote_safe(name, script):
"""The launcher is carried inside a shell double-quoted `-c "..."` argument,
so it must contain no characters the shell would interpret there: an
unescaped double-quote, $, backtick or backslash would corrupt the hook."""
payload = _launcher_payload(script)
for bad in ('"', "$", "`", "\\"):
assert bad not in payload, f"{name} launcher payload contains unsafe {bad!r}"
@pytest.mark.parametrize("name,script", _HOOK_SCRIPTS)
def test_launcher_and_rebuild_body_are_valid_python(name, script):
"""Both the launcher and the rebuild body it re-executes must parse, so a
quoting slip can't ship a hook that crashes the moment git fires it."""
payload = _launcher_payload(script)
ast.parse(payload) # launcher itself
inner = re.search(r"_src = '''(.*?)'''", payload, re.DOTALL)
assert inner, f"{name}: embedded rebuild body not found"
ast.parse(inner.group(1)) # the detached child's source
def test_rebuild_bodies_are_shell_quote_safe():
"""The shared rebuild bodies are embedded verbatim into the launcher, so they
too must avoid characters unsafe inside a shell double-quoted argument."""
for body in (_REBUILD_BODY_COMMIT, _REBUILD_BODY_CHECKOUT):
for bad in ('"', "$", "`", "\\"):
assert bad not in body
assert "'''" not in body # would terminate the launcher's _src literal
@pytest.mark.parametrize(
"name,body",
[("post-commit", _REBUILD_BODY_COMMIT), ("post-checkout", _REBUILD_BODY_CHECKOUT)],
)
def test_rebuild_bodies_read_graphify_root(name, body):
"""The rebuild must honour the persisted scan root rather than hardcoding the
repo top (#1173). Both bodies read graphify-out/.graphify_root and pass the
recovered root to _rebuild_code instead of the bare Path('.')."""
assert "graphify-out/.graphify_root" in body, f"{name} ignores .graphify_root (#1173)"
# The recovered root is what gets rebuilt, not a hardcoded cwd.
assert "_rebuild_code(_root" in body, f"{name} does not pass the recovered root"
# Quote-safe inside the shell-double-quoted launcher: single quotes only.
assert "read_text(encoding='utf-8')" in body, f"{name} root read is not single-quoted"
def test_rebuild_bodies_with_graphify_root_are_valid_python():
"""The .graphify_root snippet must parse so a quoting slip can't ship a hook
that crashes the moment git fires it (#1173)."""
for body in (_REBUILD_BODY_COMMIT, _REBUILD_BODY_CHECKOUT):
ast.parse(body)
def test_detached_launch_targets_graphify_python():
"""The launcher must run via the resolved $GRAPHIFY_PYTHON, not a bare
`python`, so it uses the same interpreter the detection block selected."""
snippet = _detached_launch(_REBUILD_BODY_COMMIT)
assert snippet.startswith('"$GRAPHIFY_PYTHON" -c "')
assert "nohup" not in snippet
def test_installed_hooks_contain_no_nohup(tmp_path):
"""End-to-end: the files written to .git/hooks must be nohup-free (#1161)."""
repo = _make_git_repo(tmp_path)
install(repo)
for name in ("post-commit", "post-checkout"):
text = (repo / ".git" / "hooks" / name).read_text(encoding="utf-8")
assert "nohup" not in text, f"installed {name} still references nohup"
assert "start_new_session=True" in text
+345
View File
@@ -0,0 +1,345 @@
"""Tests for image-vision support across the direct extraction backends.
Covers the structured-message split (text vs raster image), the per-backend
payload rendering (Anthropic base64 blocks, OpenAI/Gemini image_url data URIs,
Bedrock raw-bytes Converse blocks, the claude-cli Read-tool path), and the vision-capability gating that sends pixels only to
backends whose model can see them.
Every backend is mocked (fake SDK module / subprocess), so the suite runs on CI
with no API keys, no network, and no `claude` binary.
"""
from __future__ import annotations
import json
import sys
import types
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from graphify import llm
# A 1x1 PNG is unnecessary — the renderers never decode pixels, they only base64
# the bytes — so any non-empty byte string stands in for image content.
_PNG_BYTES = b"\x89PNG\r\n\x1a\nFAKEPIXELDATA"
_NODE_JSON = json.dumps({
"nodes": [{"id": "x", "label": "L", "file_type": "image", "source_file": "a.png"}],
"edges": [],
"hyperedges": [],
})
def _make_corpus(tmp_path):
"""A corpus with one raster image, one svg (text), and one markdown doc."""
(tmp_path / "sub").mkdir()
img = tmp_path / "sub" / "diagram.png"
img.write_bytes(_PNG_BYTES)
svg = tmp_path / "icon.svg"
svg.write_text("<svg><rect/></svg>")
doc = tmp_path / "README.md"
doc.write_text("# Title\nbody")
return img, svg, doc
# ── pure helpers ──────────────────────────────────────────────────────────────
def test_pdf_routed_through_pypdf_not_readtext(tmp_path, monkeypatch):
# A PDF is binary; reading it as text yields garbage (the bug). It must be
# routed through the pypdf extractor, and the raw bytes must never reach the
# prompt.
pdf = tmp_path / "paper.pdf"
pdf.write_bytes(b"%PDF-1.4 RAWBINARYGARBAGE\x00\xff")
import graphify.detect as detect
monkeypatch.setattr(detect, "extract_pdf_text", lambda p: "EXTRACTED PDF TEXT")
out = llm._read_files([pdf], tmp_path)
assert "EXTRACTED PDF TEXT" in out
assert "RAWBINARYGARBAGE" not in out
def test_pdf_is_not_treated_as_vision_image(tmp_path):
pdf = tmp_path / "paper.pdf"
pdf.write_bytes(b"%PDF-1.4")
text_files, image_files = llm._partition_semantic_files([pdf])
assert text_files == [pdf] and image_files == []
def test_non_pdf_still_read_as_plain_text(tmp_path):
md = tmp_path / "a.md"
md.write_text("# hello")
assert "# hello" in llm._file_to_text(md)
def test_partition_splits_raster_from_text(tmp_path):
img, svg, doc = _make_corpus(tmp_path)
text_files, image_files = llm._partition_semantic_files([doc, img, svg])
assert image_files == [img]
# svg is XML markup, so it stays on the text side (read as source, not pixels)
assert set(text_files) == {doc, svg}
def test_build_image_refs_sets_rel_media_and_bytes(tmp_path):
img, _, _ = _make_corpus(tmp_path)
(ref,) = llm._build_image_refs([img], tmp_path)
assert ref.rel == "sub/diagram.png"
assert ref.media_type == "image/png"
assert ref.raw == _PNG_BYTES
assert ref.b64 # non-empty base64
assert ref.bedrock_format == "png"
def test_build_image_refs_drops_oversized(tmp_path, monkeypatch):
big = tmp_path / "big.jpg"
big.write_bytes(b"x" * 64)
monkeypatch.setattr(llm, "_MAX_IMAGE_BYTES", 8)
(ref,) = llm._build_image_refs([big], tmp_path)
assert ref.raw is None # too large -> reference node only, no pixels
assert ref.media_type == "image/jpeg"
def test_path_backend_skips_byte_read_and_size_cap(tmp_path, monkeypatch):
# Path-based backends (claude-cli) read the file themselves, so
# _build_image_refs(read_bytes=False) loads no bytes and applies no size cap.
big = tmp_path / "huge.png"
big.write_bytes(b"x" * 64)
monkeypatch.setattr(llm, "_MAX_IMAGE_BYTES", 8)
(ref,) = llm._build_image_refs([big], tmp_path, read_bytes=False)
assert ref.raw is None # never read
assert ref.rel == "huge.png" and ref.path.name == "huge.png" # path still usable
def test_claude_cli_passes_oversized_image_by_path(tmp_path, monkeypatch):
# An image over the inline base64 cap must still reach claude-cli by path —
# opus reads it via the Read tool, no size limit on that route.
big = tmp_path / "huge.png"
big.write_bytes(b"x" * 100)
monkeypatch.setattr(llm, "_MAX_IMAGE_BYTES", 8)
refs = llm._build_image_refs([big], tmp_path, read_bytes=False)
envelope = {"result": _NODE_JSON, "usage": {"output_tokens": 1}, "stop_reason": "end_turn"}
seen: dict = {}
def fake_run(args, **kw):
seen["input"] = kw.get("input", "")
return MagicMock(returncode=0, stdout=json.dumps(envelope), stderr="")
monkeypatch.setattr(llm, "_response_is_hollow", lambda r, p: False)
with patch("shutil.which", return_value="/fake/claude"), \
patch("subprocess.run", side_effect=fake_run):
llm._call_claude_cli("CORPUS", images=refs)
assert str(refs[0].path) in seen["input"]
def test_capability_flags(monkeypatch):
for b in ("claude", "claude-cli", "openai", "gemini", "bedrock", "kimi"):
assert llm._backend_supports_vision(b), b
assert not llm._backend_supports_vision("deepseek")
# ollama is opt-in via env (default model is text-only)
monkeypatch.delenv("GRAPHIFY_OLLAMA_VISION", raising=False)
assert not llm._backend_supports_vision("ollama")
monkeypatch.setenv("GRAPHIFY_OLLAMA_VISION", "1")
assert llm._backend_supports_vision("ollama")
def test_image_token_estimate_is_flat(tmp_path):
img, _, _ = _make_corpus(tmp_path)
assert llm._estimate_file_tokens(img) == llm._IMAGE_TOKEN_ESTIMATE
def test_chunk_packing_caps_images_per_chunk(tmp_path):
# Many images + a huge token budget must still cap images per chunk so a
# single request never exceeds provider image limits.
imgs = []
for i in range(llm._MAX_IMAGES_PER_CHUNK * 2 + 3):
p = tmp_path / f"img{i:03d}.png"
p.write_bytes(_PNG_BYTES)
imgs.append(p)
chunks = llm._pack_chunks_by_tokens(imgs, token_budget=10_000_000)
assert len(chunks) >= 3 # would be 1 chunk without the cap
for chunk in chunks:
n_imgs = sum(1 for p in chunk if llm._is_vision_image(p))
assert n_imgs <= llm._MAX_IMAGES_PER_CHUNK
# ── content builders ──────────────────────────────────────────────────────────
def test_anthropic_content_has_base64_block(tmp_path):
img, _, _ = _make_corpus(tmp_path)
refs = llm._build_image_refs([img], tmp_path)
content = llm._anthropic_content("CORPUS", refs)
assert isinstance(content, list)
assert content[0]["type"] == "image"
assert content[0]["source"] == {
"type": "base64", "media_type": "image/png", "data": refs[0].b64,
}
assert content[-1]["type"] == "text" and "CORPUS" in content[-1]["text"]
def test_openai_content_has_data_uri(tmp_path):
img, _, _ = _make_corpus(tmp_path)
refs = llm._build_image_refs([img], tmp_path)
content = llm._openai_content("CORPUS", refs)
assert content[0]["type"] == "text"
assert content[1]["type"] == "image_url"
assert content[1]["image_url"]["url"] == f"data:image/png;base64,{refs[0].b64}"
def test_bedrock_content_uses_raw_bytes(tmp_path):
img, _, _ = _make_corpus(tmp_path)
refs = llm._build_image_refs([img], tmp_path)
content = llm._bedrock_content("CORPUS", refs)
assert content[0]["image"]["format"] == "png"
# Converse takes raw bytes, NOT base64 (the SDK encodes on the wire)
assert content[0]["image"]["source"]["bytes"] == _PNG_BYTES
assert content[-1]["text"] and "CORPUS" in content[-1]["text"] # text block carries the corpus
def test_builders_fall_back_to_string_without_pixels(tmp_path):
img, _, _ = _make_corpus(tmp_path)
stripped = llm._strip_pixels(llm._build_image_refs([img], tmp_path))
# No pixels -> Anthropic/OpenAI render a plain string carrying the note
ac = llm._anthropic_content("CORPUS", stripped)
oc = llm._openai_content("CORPUS", stripped)
assert isinstance(ac, str) and "sub/diagram.png" in ac
assert isinstance(oc, str) and "sub/diagram.png" in oc
def test_no_images_is_byte_identical(tmp_path):
# With no image refs, the user content must be exactly the text blob.
assert llm._anthropic_content("PLAIN", []) == "PLAIN"
assert llm._openai_content("PLAIN", []) == "PLAIN"
# ── fake SDK modules ──────────────────────────────────────────────────────────
def _fake_anthropic(monkeypatch, captured):
class _Messages:
def create(self, **kw):
captured.update(kw)
return SimpleNamespace(
content=[SimpleNamespace(text=_NODE_JSON)],
usage=SimpleNamespace(input_tokens=5, output_tokens=7),
stop_reason="end_turn",
)
mod = types.ModuleType("anthropic")
mod.Anthropic = lambda **kw: SimpleNamespace(messages=_Messages())
monkeypatch.setitem(sys.modules, "anthropic", mod)
def _fake_openai(monkeypatch, captured):
class _Completions:
def create(self, **kw):
captured.update(kw)
return SimpleNamespace(
choices=[SimpleNamespace(
message=SimpleNamespace(content=_NODE_JSON), finish_reason="stop")],
usage=SimpleNamespace(prompt_tokens=3, completion_tokens=4),
)
mod = types.ModuleType("openai")
mod.OpenAI = lambda **kw: SimpleNamespace(chat=SimpleNamespace(completions=_Completions()))
monkeypatch.setitem(sys.modules, "openai", mod)
def _fake_boto3(monkeypatch, captured):
class _Client:
def converse(self, **kw):
captured.update(kw)
return {
"output": {"message": {"content": [{"text": _NODE_JSON}]}},
"usage": {"inputTokens": 1, "outputTokens": 2},
"stopReason": "end_turn",
}
boto3 = types.ModuleType("boto3")
boto3.Session = lambda **kw: SimpleNamespace(client=lambda svc: _Client())
monkeypatch.setitem(sys.modules, "boto3", boto3)
botocore = types.ModuleType("botocore")
exc = types.ModuleType("botocore.exceptions")
exc.ClientError = type("ClientError", (Exception,), {})
botocore.exceptions = exc
monkeypatch.setitem(sys.modules, "botocore", botocore)
monkeypatch.setitem(sys.modules, "botocore.exceptions", exc)
# ── backend payload shape (mocked) ────────────────────────────────────────────
def test_call_claude_sends_image_block(tmp_path, monkeypatch):
img, _, _ = _make_corpus(tmp_path)
refs = llm._build_image_refs([img], tmp_path)
captured: dict = {}
_fake_anthropic(monkeypatch, captured)
llm._call_claude("k", "claude-sonnet-4-6", "CORPUS", images=refs)
content = captured["messages"][0]["content"]
assert any(b.get("type") == "image" for b in content)
def test_call_openai_compat_sends_image_url(tmp_path, monkeypatch):
img, _, _ = _make_corpus(tmp_path)
refs = llm._build_image_refs([img], tmp_path)
captured: dict = {}
_fake_openai(monkeypatch, captured)
llm._call_openai_compat("http://x", "k", "gpt", "CORPUS", images=refs)
content = captured["messages"][1]["content"]
assert any(p.get("type") == "image_url" for p in content)
def test_call_openai_compat_text_only_without_images(monkeypatch):
captured: dict = {}
_fake_openai(monkeypatch, captured)
llm._call_openai_compat("http://x", "k", "gpt", "CORPUS", images=[])
assert captured["messages"][1]["content"] == "CORPUS"
def test_call_bedrock_sends_raw_image_bytes(tmp_path, monkeypatch):
img, _, _ = _make_corpus(tmp_path)
refs = llm._build_image_refs([img], tmp_path)
captured: dict = {}
_fake_boto3(monkeypatch, captured)
llm._call_bedrock("model", "CORPUS", images=refs)
content = captured["messages"][0]["content"]
img_block = next(b for b in content if "image" in b)
assert img_block["image"]["source"]["bytes"] == _PNG_BYTES
# ── CLI backends (mocked subprocess) ──────────────────────────────────────────
def test_claude_cli_adds_dir_and_read_instruction(tmp_path, monkeypatch):
img, _, _ = _make_corpus(tmp_path)
refs = llm._build_image_refs([img], tmp_path)
envelope = {"result": _NODE_JSON, "usage": {"output_tokens": 1}, "stop_reason": "end_turn"}
seen: dict = {}
def fake_run(args, **kw):
seen["args"] = args
seen["input"] = kw.get("input", "")
return MagicMock(returncode=0, stdout=json.dumps(envelope), stderr="")
monkeypatch.setattr(llm, "_response_is_hollow", lambda raw, parsed: False)
with patch("shutil.which", return_value="/fake/claude"), \
patch("subprocess.run", side_effect=fake_run):
llm._call_claude_cli("CORPUS", images=refs)
assert "--add-dir" in seen["args"]
assert str(refs[0].path.parent) in seen["args"]
# the prompt sent on stdin tells the model to Read the image path
assert "Read tool" in seen["input"] and str(refs[0].path) in seen["input"]
# ── dispatch-level vision gating ──────────────────────────────────────────────
def test_extract_files_direct_gates_pixels_by_capability(tmp_path, monkeypatch):
img, _, doc = _make_corpus(tmp_path)
captured: dict = {}
_fake_openai(monkeypatch, captured)
# vision backend (openai) -> content is a list carrying an image_url block
monkeypatch.setenv("OPENAI_API_KEY", "k")
llm.extract_files_direct([doc, img], backend="openai", root=tmp_path)
assert isinstance(captured["messages"][1]["content"], list)
# non-vision backend (deepseek) -> pixels stripped, content is a plain string
captured.clear()
monkeypatch.setenv("DEEPSEEK_API_KEY", "k")
llm.extract_files_direct([doc, img], backend="deepseek", root=tmp_path)
content = captured["messages"][1]["content"]
assert isinstance(content, str) and "sub/diagram.png" in content
+9 -9
View File
@@ -9,7 +9,7 @@ import pytest
PLATFORMS = {
"claude": (".claude/skills/graphify/SKILL.md",),
"codebuddy": (".codebuddy/skills/graphify/SKILL.md",),
"codex": (".agents/skills/graphify/SKILL.md",),
"codex": (".codex/skills/graphify/SKILL.md",),
"opencode": (".config/opencode/skills/graphify/SKILL.md",),
"kilo": (
".config/kilo/skills/graphify/SKILL.md",
@@ -47,7 +47,7 @@ def test_install_codebuddy(tmp_path):
def test_install_codex(tmp_path):
_install(tmp_path, "codex")
assert (tmp_path / ".agents" / "skills" / "graphify" / "SKILL.md").exists()
assert (tmp_path / ".codex" / "skills" / "graphify" / "SKILL.md").exists()
def test_install_opencode(tmp_path):
@@ -93,10 +93,10 @@ def test_install_project_codex_writes_skill_and_agents(tmp_path, monkeypatch):
monkeypatch.setattr(sys, "argv", ["graphify", "install", "--project", "--platform", "codex"])
with patch("graphify.__main__.Path.home", return_value=home):
main()
assert (project / ".agents" / "skills" / "graphify" / "SKILL.md").exists()
assert (project / ".codex" / "skills" / "graphify" / "SKILL.md").exists()
assert (project / "AGENTS.md").exists()
assert (project / ".codex" / "hooks.json").exists()
assert not (home / ".agents" / "skills" / "graphify" / "SKILL.md").exists()
assert not (home / ".codex" / "skills" / "graphify" / "SKILL.md").exists()
def test_claude_subcommand_project_install_and_uninstall_are_project_scoped(tmp_path, monkeypatch):
@@ -130,14 +130,14 @@ def test_codex_subcommand_project_install_and_uninstall_are_project_scoped(tmp_p
home = tmp_path / "home"
project = tmp_path / "project"
project.mkdir()
user_skill = home / ".agents" / "skills" / "graphify" / "SKILL.md"
user_skill = home / ".codex" / "skills" / "graphify" / "SKILL.md"
user_skill.parent.mkdir(parents=True)
user_skill.write_text("user skill")
monkeypatch.chdir(project)
with patch("graphify.__main__.Path.home", return_value=home):
monkeypatch.setattr(sys, "argv", ["graphify", "codex", "install", "--project"])
main()
assert (project / ".agents" / "skills" / "graphify" / "SKILL.md").exists()
assert (project / ".codex" / "skills" / "graphify" / "SKILL.md").exists()
assert (project / "AGENTS.md").exists()
assert (project / ".codex" / "hooks.json").exists()
assert user_skill.exists()
@@ -146,7 +146,7 @@ def test_codex_subcommand_project_install_and_uninstall_are_project_scoped(tmp_p
main()
assert user_skill.exists()
assert not (project / ".agents" / "skills" / "graphify" / "SKILL.md").exists()
assert not (project / ".codex" / "skills" / "graphify" / "SKILL.md").exists()
assert not (project / "AGENTS.md").exists()
hooks_path = project / ".codex" / "hooks.json"
assert hooks_path.exists()
@@ -406,7 +406,7 @@ def test_uninstall_project_removes_project_skill_only(tmp_path, monkeypatch):
home = tmp_path / "home"
project = tmp_path / "project"
project.mkdir()
user_skill = home / ".agents" / "skills" / "graphify" / "SKILL.md"
user_skill = home / ".codex" / "skills" / "graphify" / "SKILL.md"
user_skill.parent.mkdir(parents=True)
user_skill.write_text("user skill")
monkeypatch.chdir(project)
@@ -416,7 +416,7 @@ def test_uninstall_project_removes_project_skill_only(tmp_path, monkeypatch):
monkeypatch.setattr(sys, "argv", ["graphify", "uninstall", "--project", "--platform", "codex"])
main()
assert user_skill.exists()
assert not (project / ".agents" / "skills" / "graphify" / "SKILL.md").exists()
assert not (project / ".codex" / "skills" / "graphify" / "SKILL.md").exists()
assert not (project / "AGENTS.md").exists()
+76 -1
View File
@@ -8,7 +8,7 @@ from graphify.extract import (
extract_swift, extract_go, extract_julia, extract_js, extract_fortran,
extract_groovy, extract_sln, extract_csproj, extract_razor,
extract_dm, extract_dmi, extract_dmm, extract_dmf,
extract_powershell,
extract_powershell, extract_apex,
)
FIXTURES = Path(__file__).parent / "fixtures"
@@ -1553,3 +1553,78 @@ def test_razor_no_dangling_edges():
node_ids = {n["id"] for n in r["nodes"]}
for e in r["edges"]:
assert e["source"] in node_ids
# ---------------Salesforce Apex (.cls / .trigger)----------------------
def test_apex_class_extraction():
r = extract_apex(FIXTURES / "sample.cls")
labels = _labels(r)
assert "AccountService" in labels
def test_apex_enum_extraction():
r = extract_apex(FIXTURES / "sample.cls")
labels = _labels(r)
assert "AccountStatus" in labels
def test_apex_interface_extraction():
r = extract_apex(FIXTURES / "sample.cls")
labels = _labels(r)
assert "Notifiable" in labels
def test_apex_method_extraction():
r = extract_apex(FIXTURES / "sample.cls")
labels = _labels(r)
assert any("getAccounts" in l for l in labels)
assert any("updateAccountsAsync" in l for l in labels)
assert any("createAccounts" in l for l in labels)
assert any("deleteOldAccounts" in l for l in labels)
def test_apex_contains_and_method_relations():
r = extract_apex(FIXTURES / "sample.cls")
relations = _relations(r)
assert "contains" in relations
assert "method" in relations
def test_apex_soql_uses_edge():
r = extract_apex(FIXTURES / "sample.cls")
relations = _relations(r)
assert "uses" in relations
labels = _labels(r)
assert "Account" in labels
def test_apex_dml_uses_edge():
r = extract_apex(FIXTURES / "sample.cls")
dml_labels = {n["label"] for n in r["nodes"] if n["label"] in ("insert", "update", "delete", "upsert")}
assert len(dml_labels) > 0
def test_apex_file_node_present():
r = extract_apex(FIXTURES / "sample.cls")
labels = _labels(r)
assert "sample.cls" in labels
def test_apex_trigger_extraction():
r = extract_apex(FIXTURES / "sample.trigger")
labels = _labels(r)
assert "sample.trigger" in labels
assert "AccountTrigger" in labels
def test_apex_trigger_uses_sobject():
r = extract_apex(FIXTURES / "sample.trigger")
relations = _relations(r)
assert "uses" in relations
labels = _labels(r)
assert "Account" in labels
def test_apex_missing_file_returns_empty():
r = extract_apex(Path("nonexistent.cls"))
assert r["nodes"] == []
assert r["edges"] == []
def test_apex_no_dangling_edges():
for fixture in ("sample.cls", "sample.trigger"):
r = extract_apex(FIXTURES / fixture)
node_ids = {n["id"] for n in r["nodes"]}
for e in r["edges"]:
assert e["source"] in node_ids, f"dangling source in {fixture}: {e}"
assert e["target"] in node_ids, f"dangling target in {fixture}: {e}"
+80
View File
@@ -15,6 +15,9 @@ def _clear_backend_env(monkeypatch):
"MOONSHOT_API_KEY",
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"DEEPSEEK_API_KEY",
"AZURE_OPENAI_API_KEY",
"AZURE_OPENAI_ENDPOINT",
):
monkeypatch.delenv(env_key, raising=False)
@@ -513,3 +516,80 @@ def test_adaptive_retry_bisects_on_hollow_ollama_response(tmp_path):
"full chunk came back hollow"
)
assert calls["n"] == 3 # 1 hollow + 2 successful halves
# ---------------------------------------------------------------------------
# Azure backend
# ---------------------------------------------------------------------------
def _install_fake_azure_openai(monkeypatch, fake_resp):
"""Inject a stub openai module with AzureOpenAI so _call_azure and
_azure_client can run without the real SDK installed."""
import sys
import types
captured: dict = {}
class _FakeAzureOpenAI:
def __init__(self, *_, **kwargs):
captured["init_kwargs"] = kwargs
self.chat = self
self.completions = self
def create(self, **kwargs):
captured["create_kwargs"] = kwargs
return fake_resp
fake_module = types.ModuleType("openai")
fake_module.AzureOpenAI = _FakeAzureOpenAI
monkeypatch.setitem(sys.modules, "openai", fake_module)
return captured
def test_call_azure_uses_correct_client_params_and_max_completion_tokens(monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_VERSION", "2024-08-01-preview")
monkeypatch.delenv("GRAPHIFY_API_TIMEOUT", raising=False)
fake_resp = _fake_openai_response(
'{"nodes":[{"id":"a"}],"edges":[],"hyperedges":[]}',
finish_reason="stop",
prompt_tokens=100,
completion_tokens=50,
)
captured = _install_fake_azure_openai(monkeypatch, fake_resp)
result = llm._call_azure(
api_key="test-key",
endpoint="https://my-resource.openai.azure.com/",
model="gpt-4o",
user_message="test",
)
assert captured["init_kwargs"].get("azure_endpoint") == "https://my-resource.openai.azure.com/"
assert captured["init_kwargs"].get("api_version") == "2024-08-01-preview"
assert "max_completion_tokens" in captured["create_kwargs"], "must use max_completion_tokens not max_tokens"
assert "max_tokens" not in captured["create_kwargs"], "deprecated max_tokens must not be sent"
assert result["nodes"] == [{"id": "a"}]
def test_detect_backend_returns_azure_when_both_vars_set(monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "azure-key")
monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://my-resource.openai.azure.com/")
assert llm.detect_backend() == "azure"
assert llm._get_backend_api_key("azure") == "azure-key"
def test_detect_backend_azure_requires_endpoint_not_just_key(monkeypatch):
_clear_backend_env(monkeypatch)
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "azure-key")
# AZURE_OPENAI_ENDPOINT already cleared by _clear_backend_env
assert llm.detect_backend() != "azure"
def test_estimate_cost_azure_no_keyerror():
cost = llm.estimate_cost("azure", 1_000_000, 500_000)
assert cost == pytest.approx(2.50 + 5.00) # 1M in * $2.50/M + 0.5M out * $10.00/M
+275
View File
@@ -0,0 +1,275 @@
import sys
from unittest.mock import MagicMock, patch
import pytest
from pathlib import Path
from graphify.pg_introspect import introspect_postgres
from graphify.validate import validate_extraction
# ---------------------------------------------------------------------------
# Shared mock infrastructure
# ---------------------------------------------------------------------------
def _make_mock_psycopg(tables, views, routines, fks,
host="myhost", dbname="mydb",
connect_raises=None):
"""Return a mock psycopg module wired to the provided catalog data.
``routines`` rows must be 5-tuples: (schema, name, rtype, body, ext_lang).
``fks`` rows must be 7-tuples:
(constraint_name, t_schema, t_name, [cols], r_schema, r_name, [r_cols])
``connect_raises``, if set, is an exception *instance* raised by connect().
"""
class MockCursor:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
pass
def execute(self, query, params=None):
self.query = query
def fetchall(self):
q = self.query.strip().lower()
if "information_schema.tables" in q:
return tables
elif "information_schema.views" in q:
return views
elif "information_schema.routines" in q:
return routines
elif "information_schema.referential_constraints" in q:
return fks
return []
class MockConnection:
def execute(self, query):
pass
def cursor(self):
return MockCursor()
def close(self):
pass
@property
def info(self):
info_mock = MagicMock()
info_mock.dsn = f"host={host} dbname={dbname} user=myuser password=secret"
return info_mock
mock_psycopg = MagicMock()
if connect_raises is not None:
mock_psycopg.connect.side_effect = connect_raises
# Make the exception type available as an attribute so the module can
# reference psycopg.OperationalError in the except clause.
mock_psycopg.OperationalError = type(connect_raises)
else:
mock_psycopg.connect.return_value = MockConnection()
mock_psycopg.OperationalError = Exception # unused path but must exist
mock_psycopg.conninfo.conninfo_to_dict.return_value = {
"host": host,
"dbname": dbname,
}
return mock_psycopg
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _q(schema: str, name: str) -> str:
"""Return the label form that tree-sitter produces for a quoted identifier.
pg_introspect emits CREATE TABLE "schema"."name" tree-sitter reads the
object_reference text verbatim (quotes included), so the node label is
'"schema"."name"', not 'schema.name'.
"""
return f'"{schema}"."{name}"'
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_pg_introspect_success():
"""Baseline: tables, views, routines, and a single-column FK all survive."""
mock_tables = [
("public", "users", "BASE TABLE"),
("public", "orders", "BASE TABLE"),
]
mock_views = [
("public", "active_users", "SELECT * FROM public.users WHERE active = true"),
]
# 5-tuple: schema, name, rtype, body, ext_lang
mock_routines = [
("public", "calculate_total", "FUNCTION", "SELECT 42;", "SQL"),
("public", "do_nothing", "PROCEDURE", None, "PLPGSQL"),
]
# 7-tuple: constraint_name, t_schema, t_name, cols[], r_schema, r_name, r_cols[]
mock_fks = [
("fk_orders_user_id", "public", "orders", ["user_id"], "public", "users", ["id"]),
]
mock_psycopg = _make_mock_psycopg(mock_tables, mock_views, mock_routines, mock_fks)
with patch.dict("sys.modules", {"psycopg": mock_psycopg}):
res = introspect_postgres("postgresql://myuser:mypassword@myhost/mydb")
# 1. validate_extraction must pass
errors = validate_extraction(res)
assert errors == [], f"Validation errors: {errors}"
# 2. source_file must be the sanitized virtual path (no credentials)
expected_source = "postgresql:/myhost/mydb"
for node in res["nodes"]:
assert node["source_file"] == expected_source
for edge in res["edges"]:
assert edge["source_file"] == expected_source
# 3. Expected node labels. pg_introspect double-quotes identifiers in DDL,
# so tree-sitter returns the raw quoted text as the object_reference.
node_labels = {n["label"] for n in res["nodes"]}
assert _q("public", "users") in node_labels, f"users missing; got {node_labels}"
assert _q("public", "orders") in node_labels, f"orders missing; got {node_labels}"
# Views keep the schema-qualified label (quoted schema, unquoted body)
assert _q("public", "active_users") in node_labels, f"active_users missing; got {node_labels}"
# Functions: label is "<quoted-sig>()"
assert f'{_q("public", "calculate_total")}()' in node_labels, f"calculate_total() missing; got {node_labels}"
assert f'{_q("public", "do_nothing")}()' in node_labels, f"do_nothing() missing; got {node_labels}"
# 4. File node (label = dbname)
file_nodes = [n for n in res["nodes"] if n["file_type"] == "code" and n["label"] == "mydb"]
assert len(file_nodes) == 1
# 5. FK references edge: orders → users, exactly once
users_nid = next(n["id"] for n in res["nodes"] if n["label"] == _q("public", "users"))
orders_nid = next(n["id"] for n in res["nodes"] if n["label"] == _q("public", "orders"))
ref_edges = [
e for e in res["edges"]
if e["source"] == orders_nid and e["target"] == users_nid and e["relation"] == "references"
]
assert len(ref_edges) == 1, f"Expected exactly 1 references edge, got {len(ref_edges)}"
def test_pg_introspect_quoted_identifiers():
"""Reserved-word and special-character table names must survive DDL round-trip.
'order' is a SQL reserved word; 'user-data' contains a hyphen both would
produce invalid DDL without quoting, causing tree-sitter to silently drop
those tables and any FK touching them.
"""
mock_tables = [
("public", "order", "BASE TABLE"), # reserved word
("public", "user-data", "BASE TABLE"), # hyphen
]
mock_views = []
mock_routines = []
# FK: user-data.owner_id → order.id
mock_fks = [
("fk_userdata_order", "public", "user-data", ["owner_id"], "public", "order", ["id"]),
]
mock_psycopg = _make_mock_psycopg(mock_tables, mock_views, mock_routines, mock_fks)
with patch.dict("sys.modules", {"psycopg": mock_psycopg}):
res = introspect_postgres("postgresql://myuser:secret@myhost/mydb")
errors = validate_extraction(res)
assert errors == [], f"Validation errors: {errors}"
node_labels = {n["label"] for n in res["nodes"]}
# Both tables must appear as nodes (quoted form expected from tree-sitter)
assert _q("public", "order") in node_labels, \
f"'order' table missing; labels={node_labels}"
assert _q("public", "user-data") in node_labels, \
f"'user-data' table missing; labels={node_labels}"
# FK references edge must exist
ref_edges = [e for e in res["edges"] if e["relation"] == "references"]
assert len(ref_edges) >= 1, "Expected at least one references edge for the FK"
def test_pg_introspect_composite_fk():
"""A 2-column composite FK must produce exactly ONE references edge, not two.
The old code emitted one ADD CONSTRAINT per row of the FK query (one row
per key column), causing duplicate edges for composite keys.
"""
mock_tables = [
("public", "products", "BASE TABLE"),
("public", "order_items", "BASE TABLE"),
]
mock_views = []
mock_routines = []
# Single composite FK: order_items(order_id, product_id) → products(order_id, product_id)
mock_fks = [
(
"fk_order_items_composite",
"public", "order_items",
["order_id", "product_id"],
"public", "products",
["order_id", "product_id"],
),
]
mock_psycopg = _make_mock_psycopg(mock_tables, mock_views, mock_routines, mock_fks)
with patch.dict("sys.modules", {"psycopg": mock_psycopg}):
res = introspect_postgres("postgresql://myuser:secret@myhost/mydb")
errors = validate_extraction(res)
assert errors == [], f"Validation errors: {errors}"
products_nid = next(
n["id"] for n in res["nodes"] if n["label"] == _q("public", "products")
)
order_items_nid = next(
n["id"] for n in res["nodes"] if n["label"] == _q("public", "order_items")
)
ref_edges = [
e for e in res["edges"]
if e["source"] == order_items_nid
and e["target"] == products_nid
and e["relation"] == "references"
]
assert len(ref_edges) == 1, (
f"Expected exactly 1 references edge for composite FK, got {len(ref_edges)}"
)
def test_pg_introspect_connection_error():
"""A psycopg.OperationalError must be re-raised as ConnectionError with a
sanitized message (no DSN/credentials) and no stack-trace noise."""
class FakeOperationalError(Exception):
pass
raw_error = FakeOperationalError(
'connection to server at "myhost" (127.0.0.1), port 5432 failed: '
'FATAL: password authentication failed for user "myuser"\n'
"DETAIL: Connection matched pg_hba.conf line 1: …"
)
mock_psycopg = _make_mock_psycopg([], [], [], [], connect_raises=raw_error)
with patch.dict("sys.modules", {"psycopg": mock_psycopg}):
with pytest.raises(ConnectionError) as exc_info:
introspect_postgres("postgresql://myuser:secret@myhost/mydb")
msg = str(exc_info.value)
assert "could not connect to PostgreSQL" in msg
# Credentials must not appear in the surfaced message
assert "secret" not in msg
# Only the first line of the OperationalError should be present (no DETAIL)
assert "DETAIL" not in msg
def test_pg_introspect_import_error():
"""If psycopg is missing, introspect_postgres raises ImportError."""
with patch.dict("sys.modules", {"psycopg": None}):
with pytest.raises(ImportError, match="psycopg is required"):
introspect_postgres("postgresql://localhost/db")
+31
View File
@@ -87,6 +87,37 @@ def test_score_nodes_ignores_trailing_punctuation():
assert scored[0][1] == "n1"
def test_score_nodes_multiword_exact_label_outranks_superset():
"""A multi-word query equal to a whole label must resolve uniquely.
Regression for the `graphify path` "No path found" bug: every node sharing
the query's token set scored identically (no single token equals a
multi-word label, so the per-token exact tier never fired), the tie broke by
arbitrary node-id sort, and a wrong/disconnected endpoint was chosen. The
full-query tier in _score_nodes must make the exact label win strictly.
"""
G = nx.Graph()
# Reproduce the real graph: norm_label keeps punctuation (strip_diacritics +
# lower, NOT tokenized), so the ':' survives. A tokenized query can never
# equal that, which is exactly why the first-cut fix was a no-op for
# punctuated labels. The exact node must still win via the label's tokenized
# form.
def _add(nid, label, src):
G.add_node(nid, label=label, norm_label=label.lower(),
source_file=src, community=0)
_add("exact", "UOCE: Dehumidifier Driver", "uoce_dehumidifier.yaml")
_add("super", "UOCE: Dehumidifier Driver State Machine", "uoce_dehumidifier.yaml")
_add("decoy", "Dehumidifier Driver Helper", "uoce_dehumidifier.yaml")
# CLI resolves endpoints as [t.lower() for t in label.split()].
scored = _score_nodes(G, [t.lower() for t in "UOCE: Dehumidifier Driver".split()])
# Resolves uniquely to the exact label, strictly ahead of the superset.
assert scored[0][1] == "exact"
assert scored[0][0] > scored[1][0], "exact label must strictly outrank superset/token-bag matches"
def test_find_node_ignores_trailing_punctuation():
G = _make_graph()
assert _find_node(G, "extract?") == ["n1"]
+232
View File
@@ -0,0 +1,232 @@
"""Tests for the Streamable HTTP transport on the MCP server (issue #1143).
These exercise the ASGI wiring in-process (no uvicorn, no real socket) via
Starlette's TestClient, so they stay fast and offline. The stdio path is
unchanged and covered elsewhere.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
pytest.importorskip("mcp")
pytest.importorskip("starlette")
from starlette.testclient import TestClient # noqa: E402
from graphify import serve as serve_mod # noqa: E402
SAMPLE_GRAPH = {
"directed": True,
"nodes": [
{"id": "a", "label": "Alpha", "community": 0},
{"id": "b", "label": "Beta", "community": 0},
],
"edges": [
{"source": "a", "target": "b", "relation": "calls", "confidence": "EXTRACTED"},
],
}
_INIT_BODY = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "test", "version": "0"},
},
}
_MCP_HEADERS = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
def _graph_file(tmp_path: Path) -> str:
p = tmp_path / "graph.json"
p.write_text(json.dumps(SAMPLE_GRAPH), encoding="utf-8")
return str(p)
def _client(app) -> TestClient:
# Default host is 127.0.0.1, so the DNS-rebinding guard only accepts that
# Host header (TestClient otherwise sends the disallowed "testserver").
return TestClient(app, base_url="http://127.0.0.1")
def test_app_builds_and_initialize_succeeds(tmp_path):
app = serve_mod._build_http_app(_graph_file(tmp_path), json_response=True)
with _client(app) as client:
resp = client.post("/mcp", headers=_MCP_HEADERS, json=_INIT_BODY)
assert resp.status_code == 200
# json_response=True returns a single JSON-RPC envelope.
payload = resp.json()
assert payload["jsonrpc"] == "2.0"
assert payload["result"]["serverInfo"]["name"] == "graphify"
def test_unknown_path_is_404(tmp_path):
app = serve_mod._build_http_app(_graph_file(tmp_path), json_response=True)
with _client(app) as client:
resp = client.post("/nope", headers=_MCP_HEADERS, json=_INIT_BODY)
assert resp.status_code == 404
def test_api_key_missing_is_401(tmp_path):
app = serve_mod._build_http_app(_graph_file(tmp_path), api_key="s3cret", json_response=True)
with _client(app) as client:
resp = client.post("/mcp", headers=_MCP_HEADERS, json=_INIT_BODY)
assert resp.status_code == 401
assert resp.json()["error"] == "unauthorized"
def test_api_key_wrong_is_401(tmp_path):
app = serve_mod._build_http_app(_graph_file(tmp_path), api_key="s3cret", json_response=True)
with _client(app) as client:
resp = client.post(
"/mcp",
headers={**_MCP_HEADERS, "Authorization": "Bearer nope"},
json=_INIT_BODY,
)
assert resp.status_code == 401
def test_api_key_bearer_ok(tmp_path):
app = serve_mod._build_http_app(_graph_file(tmp_path), api_key="s3cret", json_response=True)
with _client(app) as client:
resp = client.post(
"/mcp",
headers={**_MCP_HEADERS, "Authorization": "Bearer s3cret"},
json=_INIT_BODY,
)
assert resp.status_code == 200
assert resp.json()["result"]["serverInfo"]["name"] == "graphify"
def test_api_key_x_api_key_header_ok(tmp_path):
app = serve_mod._build_http_app(_graph_file(tmp_path), api_key="s3cret", json_response=True)
with _client(app) as client:
resp = client.post(
"/mcp",
headers={**_MCP_HEADERS, "X-API-Key": "s3cret"},
json=_INIT_BODY,
)
assert resp.status_code == 200
def test_blank_api_key_means_no_auth(tmp_path):
# An empty/whitespace key must normalize to "no auth", not a key of "".
app = serve_mod._build_http_app(_graph_file(tmp_path), api_key=" ", json_response=True)
with _client(app) as client:
resp = client.post("/mcp", headers=_MCP_HEADERS, json=_INIT_BODY)
assert resp.status_code == 200
def test_api_key_bearer_scheme_case_insensitive(tmp_path):
app = serve_mod._build_http_app(_graph_file(tmp_path), api_key="s3cret", json_response=True)
with _client(app) as client:
resp = client.post(
"/mcp",
headers={**_MCP_HEADERS, "Authorization": "bearer s3cret"},
json=_INIT_BODY,
)
assert resp.status_code == 200
def test_custom_mount_path(tmp_path):
app = serve_mod._build_http_app(_graph_file(tmp_path), path="/graph", json_response=True)
with _client(app) as client:
ok = client.post("/graph", headers=_MCP_HEADERS, json=_INIT_BODY)
assert ok.status_code == 200
missing = client.post("/mcp", headers=_MCP_HEADERS, json=_INIT_BODY)
assert missing.status_code == 404
def test_tools_list_over_http(tmp_path):
"""A full initialize -> tools/list round trip works over the HTTP transport."""
app = serve_mod._build_http_app(_graph_file(tmp_path), json_response=True)
with _client(app) as client:
init = client.post("/mcp", headers=_MCP_HEADERS, json=_INIT_BODY)
assert init.status_code == 200
session_id = init.headers.get("mcp-session-id")
assert session_id, "stateful transport should return a session id"
notify_headers = {**_MCP_HEADERS, "mcp-session-id": session_id}
client.post(
"/mcp",
headers=notify_headers,
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
)
resp = client.post(
"/mcp",
headers=notify_headers,
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
)
assert resp.status_code == 200
names = {t["name"] for t in resp.json()["result"]["tools"]}
assert {"query_graph", "get_node", "graph_stats"} <= names
def test_stateless_mode_initialize(tmp_path):
app = serve_mod._build_http_app(_graph_file(tmp_path), stateless=True, json_response=True)
with _client(app) as client:
resp = client.post("/mcp", headers=_MCP_HEADERS, json=_INIT_BODY)
assert resp.status_code == 200
def test_stateless_with_timeout_does_not_raise(tmp_path):
# session_timeout must be forced to None in stateless mode (the SDK raises
# RuntimeError otherwise). Building + a request should just work.
app = serve_mod._build_http_app(
_graph_file(tmp_path), stateless=True, session_timeout=3600, json_response=True
)
with _client(app) as client:
assert client.post("/mcp", headers=_MCP_HEADERS, json=_INIT_BODY).status_code == 200
def test_session_timeout_zero_disables(tmp_path):
# 0 / non-positive must disable reaping without tripping the SDK's validation.
app = serve_mod._build_http_app(_graph_file(tmp_path), session_timeout=0, json_response=True)
with _client(app) as client:
assert client.post("/mcp", headers=_MCP_HEADERS, json=_INIT_BODY).status_code == 200
# --- CLI argument parsing -------------------------------------------------
def test_cli_defaults_to_stdio(monkeypatch):
calls = {}
monkeypatch.setattr(serve_mod, "serve", lambda gp: calls.setdefault("stdio", gp))
monkeypatch.setattr(
serve_mod, "serve_http", lambda *a, **k: calls.setdefault("http", (a, k))
)
serve_mod._main(["graphify-out/graph.json"])
assert calls.get("stdio") == "graphify-out/graph.json"
assert "http" not in calls
def test_cli_http_passes_flags(monkeypatch):
captured = {}
monkeypatch.setattr(serve_mod, "serve", lambda gp: captured.setdefault("stdio", gp))
monkeypatch.setattr(
serve_mod, "serve_http", lambda gp, **k: captured.update(gp=gp, **k)
)
serve_mod._main([
"g.json", "--transport", "http", "--host", "0.0.0.0",
"--port", "9000", "--api-key", "k", "--stateless",
])
assert captured["gp"] == "g.json"
assert captured["host"] == "0.0.0.0"
assert captured["port"] == 9000
assert captured["api_key"] == "k"
assert captured["stateless"] is True
def test_cli_api_key_from_env(monkeypatch):
captured = {}
monkeypatch.setenv("GRAPHIFY_API_KEY", "from-env")
monkeypatch.setattr(serve_mod, "serve_http", lambda gp, **k: captured.update(**k))
serve_mod._main(["g.json", "--transport", "http"])
assert captured["api_key"] == "from-env"
+17 -9
View File
@@ -455,12 +455,13 @@ def test_monolith_roundtrip_passes_for_aider_and_devin():
assert problems == [], f"[{key}]\n" + "\n".join(problems)
def test_monoliths_change_only_the_enum_and_the_description():
"""The rendered monolith differs from v8 on exactly the enum + description lines.
def test_monoliths_change_only_the_enum_description_and_chunk_cleanup():
"""The rendered monolith differs from v8 on exactly the allowed lines.
Two changes are now in play for the monoliths: the file_type enum unified to
the six-value superset (the prose guidance line + the schema line) and the
frontmatter description unified across all platforms. Nothing else may differ.
Three changes are now in play for the monoliths: the file_type enum unified to
the six-value superset (the prose guidance line + the schema line), the
frontmatter description unified across all platforms, and the shell-agnostic
chunk-cleanup rewrite (#1172). Nothing else may differ.
"""
platforms = gen.load_platforms()
for key in ("aider", "devin"):
@@ -468,11 +469,12 @@ def test_monoliths_change_only_the_enum_and_the_description():
original = gen._normalise(gen._git_show(platforms[key].roundtrip_ref)).splitlines()
assert len(rendered) == len(original), f"[{key}] line count changed"
diff_idx = [i for i, (r, o) in enumerate(zip(rendered, original)) if r != o]
# Exactly three lines change: the prose enum guidance, the schema line,
# and the frontmatter description.
assert len(diff_idx) == 3, f"[{key}] expected 3 changed lines, got {len(diff_idx)}"
# Exactly four lines change: the prose enum guidance, the schema line,
# the frontmatter description, and the chunk-cleanup rewrite.
assert len(diff_idx) == 4, f"[{key}] expected 4 changed lines, got {len(diff_idx)}"
enum_changes = 0
desc_changes = 0
cleanup_changes = 0
for i in diff_idx:
line = rendered[i]
if gen.ENUM_VALUES in line or gen.ENUM_PROSE in line:
@@ -482,12 +484,18 @@ def test_monoliths_change_only_the_enum_and_the_description():
assert UNIFIED_DESCRIPTION in line, (
f"[{key}] description line is not the unified text: {line!r}"
)
elif gen._is_chunk_cleanup_line(line):
cleanup_changes += 1
# The unmatched-glob abort is fixed: the rm no longer carries the
# bare chunk glob, and a find ... -delete sweeps the chunks.
assert ".graphify_chunk_*.json" not in line.split("find", 1)[0]
else:
raise AssertionError(
f"[{key}] changed line {i} is neither enum nor description: {line!r}"
f"[{key}] changed line {i} is none of enum/description/cleanup: {line!r}"
)
assert enum_changes == 2, f"[{key}] expected 2 enum line changes, got {enum_changes}"
assert desc_changes == 1, f"[{key}] expected 1 description change, got {desc_changes}"
assert cleanup_changes == 1, f"[{key}] expected 1 cleanup change, got {cleanup_changes}"
# The six-value superset replaced the five-value enum in both files.
assert any(gen.ENUM_VALUES in line for line in rendered)
+126
View File
@@ -207,6 +207,132 @@ def test_rebuild_code_evicts_nodes_from_deleted_files(tmp_path):
assert "login()" in node_labels_after, "nodes from surviving file must be kept"
def test_rebuild_code_evicts_removed_symbol_from_surviving_file(tmp_path):
"""#1116: graphify update (_rebuild_code with no changed_paths) must prune a
symbol removed from a file that still exists and its inbound call edge
without dropping genuine semantic nodes that share the surviving file."""
import json
from graphify.watch import _rebuild_code
corpus = tmp_path / "corpus"
corpus.mkdir()
(corpus / "a.py").write_text(
"def foo(): pass\ndef bar(): pass\n", encoding="utf-8"
)
(corpus / "b.py").write_text(
"from a import foo\n\ndef caller():\n foo()\n", encoding="utf-8"
)
assert _rebuild_code(corpus, acquire_lock=False) is True
graph_path = corpus / "graphify-out" / "graph.json"
data = json.loads(graph_path.read_text(encoding="utf-8"))
def labels(d):
return {n["label"] for n in d.get("nodes", [])}
def id_for(d, label):
return next(n["id"] for n in d.get("nodes", []) if n["label"] == label)
def edges(d):
return d.get("links", d.get("edges", []))
before = labels(data)
assert {"foo()", "bar()", "caller()"} <= before
foo_id = id_for(data, "foo()")
caller_id = id_for(data, "caller()")
assert any(
{e.get("source"), e.get("target")} == {caller_id, foo_id}
for e in edges(data)
), "cross-file caller->foo call edge must exist before removal"
# Pre-seed a semantic node on the surviving a.py (no AST id, no _origin
# marker). A naive "evict every re-extracted file's nodes by source_file"
# fix would wrongly delete this; the identity-based fix must keep it.
data["nodes"].append({
"id": "a_authconcept",
"label": "AuthConcept",
"file_type": "concept",
"source_file": "a.py",
})
graph_path.write_text(json.dumps(data), encoding="utf-8")
# Remove foo() from a.py (keep bar); leave b.py untouched.
(corpus / "a.py").write_text("def bar(): pass\n", encoding="utf-8")
assert _rebuild_code(corpus, acquire_lock=False, force=True) is True
after_data = json.loads(graph_path.read_text(encoding="utf-8"))
after = labels(after_data)
assert "foo()" not in after, "removed symbol must be pruned from surviving file"
assert not any(
e.get("source") == foo_id or e.get("target") == foo_id
for e in edges(after_data)
), "dangling edge to the removed symbol must be dropped"
assert "bar()" in after, "surviving symbol in the same file must be kept"
assert "caller()" in after, "unchanged file's nodes must be kept"
assert "AuthConcept" in after, "semantic node on a surviving file must not be evicted"
def test_rebuild_code_preupgrade_marker_less_node_one_cycle_lag(tmp_path):
"""#1118 backward-compat: a graph.json built before #1116 has no `_origin`
markers. On the first `graphify update` after upgrading, a symbol removed
from a surviving file is NOT pruned that cycle its old node carries no
marker, so the new drop-rule skips it. This is a deliberate one-cycle lag
(no data loss); it self-heals once the node has been stamped `_origin="ast"`
(which a full re-extraction does for every surviving symbol)."""
import json
from graphify.watch import _rebuild_code
corpus = tmp_path / "corpus"
corpus.mkdir()
(corpus / "a.py").write_text("def bar(): pass\n", encoding="utf-8")
assert _rebuild_code(corpus, acquire_lock=False) is True
graph_path = corpus / "graphify-out" / "graph.json"
data = json.loads(graph_path.read_text(encoding="utf-8"))
def labels(d):
return {n["label"] for n in d.get("nodes", [])}
# Simulate a pre-#1116 graph: strip every `_origin` marker, then inject a
# stale AST node for a symbol no longer present in a.py's source — also
# marker-less, exactly as a pre-upgrade graph would carry it.
for n in data["nodes"]:
n.pop("_origin", None)
data["nodes"].append({
"id": "a_foo",
"label": "foo()",
"file_type": "function",
"source_file": "a.py",
})
graph_path.write_text(json.dumps(data), encoding="utf-8")
# First update after "upgrade" (full rebuild, no changed_paths): the stale
# node has no marker, so the drop-rule skips it and it survives this cycle.
assert _rebuild_code(corpus, acquire_lock=False, force=True) is True
after = json.loads(graph_path.read_text(encoding="utf-8"))
assert "foo()" in labels(after), (
"pre-upgrade marker-less stale node must survive the first update — "
"documented one-cycle backward-compat lag (#1118)"
)
# Once stamped (a full re-extraction stamps every surviving symbol), the
# drop-rule applies on the next update and the stale node self-heals away.
for n in after["nodes"]:
if n["label"] == "foo()":
n["_origin"] = "ast"
graph_path.write_text(json.dumps(after), encoding="utf-8")
assert _rebuild_code(corpus, acquire_lock=False, force=True) is True
healed = json.loads(graph_path.read_text(encoding="utf-8"))
assert "foo()" not in labels(healed), (
"once carrying _origin=ast, the stale node is pruned on the next "
"update (self-heal)"
)
assert "bar()" in labels(healed), "surviving symbol must be kept throughout"
@pytest.mark.skipif(sys.platform == "win32", reason="fcntl-only (POSIX)")
def test_rebuild_lock_non_blocking_does_not_clobber_holder(tmp_path):
"""GH-858: a non-blocking caller that fails to acquire the lock must not
@@ -684,7 +684,7 @@ cost_path.write_text(json.dumps(cost, indent=2))
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f .graphify_detect.json .graphify_extract.json .graphify_ast.json .graphify_semantic.json .graphify_analysis.json .graphify_labels.json .graphify_chunk_*.json
rm -f .graphify_detect.json .graphify_extract.json .graphify_ast.json .graphify_semantic.json .graphify_analysis.json .graphify_labels.json; find . -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
@@ -520,7 +520,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
@@ -523,7 +523,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
@@ -520,7 +520,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
@@ -523,7 +523,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
@@ -801,7 +801,7 @@ cost_path.write_text(json.dumps(cost, indent=2))
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_labels.json graphify-out/.graphify_chunk_*.json graphify-out/.graphify_incremental.json graphify-out/.graphify_transcripts.json graphify-out/.graphify_old.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_labels.json graphify-out/.graphify_incremental.json graphify-out/.graphify_transcripts.json graphify-out/.graphify_old.json; find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
@@ -520,7 +520,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
@@ -523,7 +523,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
@@ -522,7 +522,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
@@ -515,7 +515,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
@@ -522,7 +522,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
@@ -521,7 +521,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
@@ -519,7 +519,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
@@ -545,7 +545,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
+2 -1
View File
@@ -523,7 +523,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
+1 -1
View File
@@ -684,7 +684,7 @@ cost_path.write_text(json.dumps(cost, indent=2))
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f .graphify_detect.json .graphify_extract.json .graphify_ast.json .graphify_semantic.json .graphify_analysis.json .graphify_labels.json .graphify_chunk_*.json
rm -f .graphify_detect.json .graphify_extract.json .graphify_ast.json .graphify_semantic.json .graphify_analysis.json .graphify_labels.json; find . -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
+2 -1
View File
@@ -457,7 +457,8 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_chunk_*.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
+1 -1
View File
@@ -801,7 +801,7 @@ cost_path.write_text(json.dumps(cost, indent=2))
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_labels.json graphify-out/.graphify_chunk_*.json graphify-out/.graphify_incremental.json graphify-out/.graphify_transcripts.json graphify-out/.graphify_old.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_labels.json graphify-out/.graphify_incremental.json graphify-out/.graphify_transcripts.json graphify-out/.graphify_old.json; find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
@@ -93,13 +93,18 @@ from graphify.detect import save_manifest
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
# the same file's nodes and can collapse same-named symbols across files (#1178).
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
prune = list(dict.fromkeys(deleted + changed)) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=deleted or None,
prune_sources=prune,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
+16 -2
View File
@@ -705,6 +705,19 @@ def _is_frontmatter_description_line(line: str) -> bool:
return line.lstrip().startswith("description:")
def _is_chunk_cleanup_line(line: str) -> bool:
"""Whether a line is the Step 9 chunk-file cleanup ``rm -f`` command.
The bare glob ``.graphify_chunk_*.json`` in the v8 cleanup line aborts the
whole ``rm`` under fish/zsh when no chunk files exist (no-match is a hard
error there, unlike bash). The fix (graphify #1172) drops the glob from the
``rm`` and deletes the chunk files with ``find ... -delete`` instead. That
rewrite touches the single cleanup line in place (no line added or removed),
so it joins the enum and description unifications as an allowed monolith diff.
"""
return line.lstrip().startswith("rm -f") and "find " in line and "-name '.graphify_chunk_" in line
def monolith_roundtrip(platform: Platform) -> list[str]:
"""Assert a monolith renders diff-clean vs its v8 blob modulo allowed changes.
@@ -736,8 +749,9 @@ def monolith_roundtrip(platform: Platform) -> list[str]:
for i, (r, o) in enumerate(zip(rendered_lines, original_lines), start=1):
if r == o:
continue
# The permitted diffs are the enum unification and the unified description.
if _is_enum_line(r) or _is_frontmatter_description_line(r):
# The permitted diffs are the enum unification, the unified description,
# and the shell-agnostic chunk-cleanup rewrite (#1172).
if _is_enum_line(r) or _is_frontmatter_description_line(r) or _is_chunk_cleanup_line(r):
continue
problems.append(
f"[{platform.key}] line {i} differs and is not an enum or description unification:\n"
Generated
+291 -92
View File
@@ -5,9 +5,12 @@ resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform == 'emscripten'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'",
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version < '3.11'",
]
@@ -98,7 +101,7 @@ name = "autograd"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "python_full_version < '3.14'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/67/1c/3c24ec03c8ba4decc742b1df5a10c52f98c84ca8797757f313e7bdcdf276/autograd-1.8.0.tar.gz", hash = "sha256:107374ded5b09fc8643ac925348c0369e7b0e73bbed9565ffd61b8fd04425683", size = 2562146, upload-time = "2025-05-05T12:49:02.502Z" }
wheels = [
@@ -512,7 +515,7 @@ resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
{ name = "numpy", marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" }
wheels = [
@@ -582,12 +585,16 @@ resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform == 'emscripten'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'",
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.11'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" }
wheels = [
@@ -847,7 +854,8 @@ name = "ctranslate2"
version = "4.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.11'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
{ name = "pyyaml", marker = "python_full_version >= '3.11'" },
{ name = "setuptools", marker = "python_full_version >= '3.11'" },
]
@@ -914,7 +922,8 @@ name = "datasketch"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
]
@@ -1106,10 +1115,10 @@ name = "gensim"
version = "4.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "python_full_version < '3.14'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "smart-open", marker = "python_full_version < '3.14'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "smart-open", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/80/fe9d2e1ace968041814dbcfce4e8499a643a36c41267fa4b6c4f54cce420/gensim-4.4.0.tar.gz", hash = "sha256:a3f5b626da5518e79a479140361c663089fe7998df8ba52d56e1ded71ac5bdf5", size = 23260095, upload-time = "2025-10-18T02:06:45.962Z" }
wheels = [
@@ -1137,7 +1146,7 @@ wheels = [
[[package]]
name = "graphifyy"
version = "0.8.31"
version = "0.8.33"
source = { editable = "." }
dependencies = [
{ name = "datasketch" },
@@ -1183,6 +1192,7 @@ all = [
{ name = "matplotlib" },
{ name = "mcp" },
{ name = "neo4j" },
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
{ name = "openai" },
{ name = "openpyxl" },
{ name = "pypdf" },
@@ -1241,11 +1251,15 @@ pdf = [
{ name = "markdownify" },
{ name = "pypdf" },
]
postgres = [
{ name = "psycopg", extra = ["binary"] },
]
sql = [
{ name = "tree-sitter-sql" },
]
svg = [
{ name = "matplotlib" },
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
]
terraform = [
{ name = "tree-sitter-hcl" },
@@ -1300,6 +1314,8 @@ requires-dist = [
{ name = "neo4j", marker = "extra == 'all'" },
{ name = "neo4j", marker = "extra == 'neo4j'" },
{ name = "networkx", specifier = ">=3.4" },
{ name = "numpy", marker = "python_full_version >= '3.13' and extra == 'all'", specifier = ">=2.0" },
{ name = "numpy", marker = "python_full_version >= '3.13' and extra == 'svg'", specifier = ">=2.0" },
{ name = "openai", marker = "extra == 'all'" },
{ name = "openai", marker = "extra == 'gemini'" },
{ name = "openai", marker = "extra == 'kimi'" },
@@ -1308,6 +1324,7 @@ requires-dist = [
{ name = "openpyxl", marker = "extra == 'all'" },
{ name = "openpyxl", marker = "extra == 'google'" },
{ name = "openpyxl", marker = "extra == 'office'" },
{ name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'" },
{ name = "pypdf", marker = "extra == 'all'" },
{ name = "pypdf", marker = "extra == 'pdf'" },
{ name = "python-docx", marker = "extra == 'all'" },
@@ -1354,7 +1371,7 @@ requires-dist = [
{ name = "yt-dlp", marker = "extra == 'all'" },
{ name = "yt-dlp", marker = "extra == 'video'" },
]
provides-extras = ["mcp", "neo4j", "pdf", "watch", "svg", "leiden", "office", "google", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "chinese", "sql", "dm", "terraform", "all"]
provides-extras = ["mcp", "neo4j", "pdf", "watch", "svg", "leiden", "office", "google", "postgres", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "chinese", "sql", "dm", "terraform", "all"]
[package.metadata.requires-dev]
dev = [
@@ -1381,26 +1398,26 @@ name = "graspologic"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anytree", marker = "python_full_version < '3.14'" },
{ name = "beartype", marker = "python_full_version < '3.14'" },
{ name = "future", marker = "python_full_version < '3.14'" },
{ name = "gensim", marker = "python_full_version < '3.14'" },
{ name = "graspologic-native", marker = "python_full_version < '3.14'" },
{ name = "hyppo", marker = "python_full_version < '3.14'" },
{ name = "joblib", marker = "python_full_version < '3.14'" },
{ name = "matplotlib", marker = "python_full_version < '3.14'" },
{ name = "anytree", marker = "python_full_version < '3.13'" },
{ name = "beartype", marker = "python_full_version < '3.13'" },
{ name = "future", marker = "python_full_version < '3.13'" },
{ name = "gensim", marker = "python_full_version < '3.13'" },
{ name = "graspologic-native", marker = "python_full_version < '3.13'" },
{ name = "hyppo", marker = "python_full_version < '3.13'" },
{ name = "joblib", marker = "python_full_version < '3.13'" },
{ name = "matplotlib", marker = "python_full_version < '3.13'" },
{ name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "numpy", marker = "python_full_version < '3.14'" },
{ name = "pot", marker = "python_full_version < '3.14'" },
{ name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
{ name = "pot", marker = "python_full_version < '3.13'" },
{ name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "seaborn", marker = "python_full_version < '3.14'" },
{ name = "statsmodels", marker = "python_full_version < '3.14'" },
{ name = "typing-extensions", marker = "python_full_version < '3.14'" },
{ name = "umap-learn", marker = "python_full_version < '3.14'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "seaborn", marker = "python_full_version < '3.13'" },
{ name = "statsmodels", marker = "python_full_version < '3.13'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "umap-learn", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/bb/0fe2ef85ea775e7b8416b2cf90097aa4b5e0c9c2271d7fe6789bab27d0ca/graspologic-3.4.4.tar.gz", hash = "sha256:79878caf367da3e89046a4ec94291c5b1a5da569f19fdd879d8b45c3563d7110", size = 5134258, upload-time = "2025-09-08T21:44:01.969Z" }
wheels = [
@@ -1535,18 +1552,18 @@ name = "hyppo"
version = "0.5.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "autograd", marker = "python_full_version < '3.14'" },
{ name = "future", marker = "python_full_version < '3.14'" },
{ name = "numba", marker = "python_full_version < '3.14'" },
{ name = "numpy", marker = "python_full_version < '3.14'" },
{ name = "autograd", marker = "python_full_version < '3.13'" },
{ name = "future", marker = "python_full_version < '3.13'" },
{ name = "numba", marker = "python_full_version < '3.13'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
{ name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "patsy", marker = "python_full_version < '3.14'" },
{ name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "patsy", marker = "python_full_version < '3.13'" },
{ name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "statsmodels", marker = "python_full_version < '3.14'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "statsmodels", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dd/a6/0d84fe8486a1447da8bdb8ebb249d525fd8c1d0fe038bceb003c6e0513f9/hyppo-0.5.2.tar.gz", hash = "sha256:4634d15516248a43d25c241ed18beeb79bb3210360f7253693b3f154fe8c9879", size = 125115, upload-time = "2025-05-24T18:33:27.418Z" }
wheels = [
@@ -2189,7 +2206,8 @@ dependencies = [
{ name = "cycler" },
{ name = "fonttools" },
{ name = "kiwisolver" },
{ name = "numpy" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
{ name = "packaging" },
{ name = "pillow" },
{ name = "pyparsing" },
@@ -2380,9 +2398,12 @@ resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform == 'emscripten'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'",
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
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 = [
@@ -2424,8 +2445,8 @@ name = "numba"
version = "0.65.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "llvmlite", marker = "python_full_version < '3.14'" },
{ name = "numpy", marker = "python_full_version < '3.14'" },
{ name = "llvmlite", marker = "python_full_version < '3.13'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload-time = "2026-04-24T02:02:56.551Z" }
wheels = [
@@ -2459,6 +2480,12 @@ wheels = [
name = "numpy"
version = "1.26.4"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version < '3.11'",
]
sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/94/ace0fdea5241a27d13543ee117cbc65868e82213fb31a8eb7fe9ff23f313/numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0", size = 20631468, upload-time = "2024-02-05T23:48:01.194Z" },
@@ -2487,13 +2514,101 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload-time = "2024-02-05T23:58:36.364Z" },
]
[[package]]
name = "numpy"
version = "2.4.6"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform == 'emscripten'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
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/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" },
{ url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" },
{ url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" },
{ url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" },
{ url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" },
{ url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" },
{ url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" },
{ url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" },
{ url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" },
{ url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" },
{ 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" },
{ url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" },
{ url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" },
{ url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" },
{ url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" },
{ url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" },
{ url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" },
{ url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" },
{ url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" },
{ url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" },
{ url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" },
{ url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" },
{ url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" },
{ url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" },
{ url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" },
{ url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" },
{ url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" },
{ url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" },
{ url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" },
{ url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" },
{ url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" },
{ url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" },
{ url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" },
{ url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" },
{ url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" },
]
[[package]]
name = "onnxruntime"
version = "1.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flatbuffers", marker = "python_full_version >= '3.11'" },
{ name = "numpy", marker = "python_full_version >= '3.11'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
{ name = "packaging", marker = "python_full_version >= '3.11'" },
{ name = "protobuf", marker = "python_full_version >= '3.11'" },
]
@@ -2581,7 +2696,7 @@ resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
{ name = "numpy", marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "python-dateutil", marker = "python_full_version < '3.11'" },
{ name = "pytz", marker = "python_full_version < '3.11'" },
{ name = "tzdata", marker = "python_full_version < '3.11'" },
@@ -2642,14 +2757,14 @@ name = "pandas"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'",
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "python-dateutil", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "tzdata", marker = "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32')" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "python-dateutil", marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "tzdata", marker = "(python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and python_full_version < '3.13' and 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 = [
@@ -2723,7 +2838,7 @@ name = "patsy"
version = "1.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "python_full_version < '3.14'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/44/ed13eccdd0519eff265f44b670d46fbb0ec813e2274932dc1c0e48520f7d/patsy-1.0.2.tar.gz", hash = "sha256:cdc995455f6233e90e22de72c37fcadb344e7586fb83f06696f54d92f8ce74c0", size = 399942, upload-time = "2025-10-20T16:17:37.535Z" }
wheels = [
@@ -2906,9 +3021,9 @@ name = "pot"
version = "0.9.6.post1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "python_full_version < '3.14'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/8b/5f939eaf1fbeb7ff914fe540d659486951a056e5537b8f454362045b6c72/pot-0.9.6.post1.tar.gz", hash = "sha256:9b6cc14a8daecfe1268268168cf46548f9130976b22b24a9e8ec62a734be6c43", size = 604243, upload-time = "2025-09-22T12:51:14.894Z" }
wheels = [
@@ -2973,6 +3088,86 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" },
]
[[package]]
name = "psycopg"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" },
]
[package.optional-dependencies]
binary = [
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
]
[[package]]
name = "psycopg-binary"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/bf/70d8a60488f9955cbbcd538beae44d56bb2f1d19e673b72788f2d343ff55/psycopg_binary-3.3.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b7bfff1ca23732b488cbca3076fc11bc98d520ee122514fdb17a8e20d3338f5a", size = 4609750, upload-time = "2026-05-01T23:24:20.06Z" },
{ url = "https://files.pythonhosted.org/packages/db/b0/29e98ba210c9dbc75a6dc91e3f99b9e06ea901a62ca95804e02a1ae13e6b/psycopg_binary-3.3.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:32a6fbf8481e3a370d0d72b860d35948a693cb01281da217f7b2f307636e591a", size = 4676700, upload-time = "2026-05-01T23:25:21.727Z" },
{ url = "https://files.pythonhosted.org/packages/8e/ab/3df087b3c12bf74e47c08204172b2fabb5a144679110d5c7ad12d9201323/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bdef84570ebbce1d42b4e7ea952d21c414c5f118ad02fee00c5625f35e134429", size = 5496319, upload-time = "2026-05-01T23:25:28.271Z" },
{ url = "https://files.pythonhosted.org/packages/87/9a/f088207b4cd6772f9e0d8a91807e79fa2458d4eb9eb1ae406c68415f2bec/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbc10768a796c96d3243656016bf4e337c81c71097270bb7b0ad6210d9765", size = 5171906, upload-time = "2026-05-01T23:25:34.004Z" },
{ url = "https://files.pythonhosted.org/packages/48/45/4523a857f253871d75c22e1c2e79fd47e599e736bcba1bad58d83e24be02/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf7f73a4a792bc5db58a4b385d8a1467e8d468f7548702fb0ed1e9b7501b1c13", size = 6762621, upload-time = "2026-05-01T23:25:41.392Z" },
{ url = "https://files.pythonhosted.org/packages/7c/d1/925bf776503345bef428e6c45fb017d0139ddbe0e211814b585c4253dca8/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7b4d40c153fa352ab3cca530f3a0baedf7621b2ebcbd7f084009522c21788fc", size = 5006319, upload-time = "2026-05-01T23:25:51.419Z" },
{ url = "https://files.pythonhosted.org/packages/6f/aa/99727337206fbba357ca084bf4ea8b29dc986f61842a2685859af61416db/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9b1c2533af01cd7648378599f82b0b8ae32f293296e6eec5753a625bc97ef28", size = 4535388, upload-time = "2026-05-01T23:25:57.957Z" },
{ url = "https://files.pythonhosted.org/packages/0b/a4/567ba2c37d19d8c2f63d836385dfd2495aa5897bbee6cfab104d9ee58624/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad3bc94054876155549fdaedf4a46d1ec69d39a5bcee377148afe498e84c4b8e", size = 4224544, upload-time = "2026-05-01T23:26:03.832Z" },
{ url = "https://files.pythonhosted.org/packages/b7/23/86457f5a82731685d7701de7bfaa5eb783dd1fecbf875321897d9d9ce33a/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb4eed2079c01a4850bf467deacfab56d356d4225040170af03dc9958321242d", size = 3956282, upload-time = "2026-05-01T23:26:09.983Z" },
{ url = "https://files.pythonhosted.org/packages/a7/d8/249456df16d47de082abd9b73bce8ccdeb0293eb12e590f9150c7cbdb788/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f80e3f2b5331dbbf0901bcb658056c03eeb2c1ef31d774afb0d61598b242e744", size = 4261736, upload-time = "2026-05-01T23:26:16.798Z" },
{ url = "https://files.pythonhosted.org/packages/15/6b/c4abe228acafd8a385c1fb615d4f1e3c9b8ad7a4e4f0e84118ba3ffeed9c/psycopg_binary-3.3.4-cp310-cp310-win_amd64.whl", hash = "sha256:574ea21a9651958f1535c5a1c649c7409e9168bcbffa29a3f2f961f58b322949", size = 3570620, upload-time = "2026-05-01T23:26:22.655Z" },
{ url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" },
{ url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" },
{ url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" },
{ url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" },
{ url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" },
{ url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" },
{ url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" },
{ url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" },
{ url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" },
{ url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" },
{ url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" },
{ url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" },
{ url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" },
{ url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" },
{ url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" },
{ url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" },
{ url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" },
{ url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" },
{ url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" },
{ url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" },
{ url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" },
{ url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" },
{ url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" },
{ url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" },
{ url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" },
{ url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" },
{ url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" },
{ url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" },
{ url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" },
{ url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" },
{ url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" },
{ url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" },
{ url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" },
{ url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" },
{ url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" },
{ url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" },
{ url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" },
{ url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" },
{ url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" },
]
[[package]]
name = "py-serializable"
version = "2.1.0"
@@ -3170,13 +3365,13 @@ name = "pynndescent"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "joblib", marker = "python_full_version < '3.14'" },
{ name = "llvmlite", marker = "python_full_version < '3.14'" },
{ name = "numba", marker = "python_full_version < '3.14'" },
{ name = "joblib", marker = "python_full_version < '3.13'" },
{ name = "llvmlite", marker = "python_full_version < '3.13'" },
{ name = "numba", marker = "python_full_version < '3.13'" },
{ name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4a/fb/7f58c397fb31666756457ee2ac4c0289ef2daad57f4ae4be8dec12f80b03/pynndescent-0.6.0.tar.gz", hash = "sha256:7ffde0fb5b400741e055a9f7d377e3702e02250616834231f6c209e39aac24f5", size = 2992987, upload-time = "2026-01-08T21:29:58.943Z" }
wheels = [
@@ -3884,7 +4079,7 @@ resolution-markers = [
]
dependencies = [
{ name = "joblib", marker = "python_full_version < '3.11'" },
{ name = "numpy", marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "threadpoolctl", marker = "python_full_version < '3.11'" },
]
@@ -3927,15 +4122,15 @@ name = "scikit-learn"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'",
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
dependencies = [
{ name = "joblib", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "numpy", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "threadpoolctl", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "joblib", marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "threadpoolctl", marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
wheels = [
@@ -3985,7 +4180,7 @@ resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
{ name = "numpy", marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" }
wheels = [
@@ -4044,12 +4239,16 @@ resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform == 'emscripten'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'",
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.11'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
]
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 = [
@@ -4120,10 +4319,10 @@ name = "seaborn"
version = "0.13.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "matplotlib", marker = "python_full_version < '3.14'" },
{ name = "numpy", marker = "python_full_version < '3.14'" },
{ name = "matplotlib", marker = "python_full_version < '3.13'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
{ name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" }
wheels = [
@@ -4162,7 +4361,7 @@ name = "smart-open"
version = "7.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wrapt", marker = "python_full_version < '3.14'" },
{ name = "wrapt", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c5/65/3ada667d32675399001bf022ad3d9f3989b57101351ebc71d6fbe2384634/smart_open-7.6.1.tar.gz", hash = "sha256:4347996e7ba21db7cd1e059632e0b30395407e4f6c660d2ddffc8f2a9ae5f990", size = 54754, upload-time = "2026-05-09T06:23:37.06Z" }
wheels = [
@@ -4227,13 +4426,13 @@ name = "statsmodels"
version = "0.14.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "python_full_version < '3.14'" },
{ name = "packaging", marker = "python_full_version < '3.14'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
{ name = "packaging", marker = "python_full_version < '3.13'" },
{ name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "patsy", marker = "python_full_version < '3.14'" },
{ name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "patsy", marker = "python_full_version < '3.13'" },
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0d/81/e8d74b34f85285f7335d30c5e3c2d7c0346997af9f3debf9a0a9a63de184/statsmodels-0.14.6.tar.gz", hash = "sha256:4d17873d3e607d398b85126cd4ed7aad89e4e9d89fc744cdab1af3189a996c2a", size = 20689085, upload-time = "2025-12-05T23:08:39.522Z" }
wheels = [
@@ -4987,14 +5186,14 @@ name = "umap-learn"
version = "0.5.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numba", marker = "python_full_version < '3.14'" },
{ name = "numpy", marker = "python_full_version < '3.14'" },
{ name = "pynndescent", marker = "python_full_version < '3.14'" },
{ name = "numba", marker = "python_full_version < '3.13'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
{ name = "pynndescent", marker = "python_full_version < '3.13'" },
{ name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "tqdm", marker = "python_full_version < '3.14'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "tqdm", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/ee/af4171241117f85c74b5ca6448ea1033cc28d599c13651d67289bacd4083/umap_learn-0.5.12.tar.gz", hash = "sha256:6aff02ecac5f2aad9f3c65ee518d7ae93e1a985ae38721fdcffceee4232c33c7", size = 96672, upload-time = "2026-04-08T20:03:54.012Z" }
wheels = [