Merge origin/v8 into pr-1136 — resolve codex .codex path + version bump
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Vendored
+55
@@ -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());
|
||||
}
|
||||
}
|
||||
Vendored
+8
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user