graphify-sf 0.4.0__tar.gz → 0.4.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/PKG-INFO +35 -31
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/README.md +19 -14
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/__main__.py +137 -54
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/_bundled_skill.md +1 -1
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/cluster.py +52 -15
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/export.py +1 -1
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/__init__.py +40 -3
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/apex.py +170 -18
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/doc.py +99 -1
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/llm.py +124 -39
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/serve.py +222 -36
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf.egg-info/PKG-INFO +35 -31
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf.egg-info/SOURCES.txt +2 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf.egg-info/requires.txt +14 -20
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/pyproject.toml +22 -19
- graphify_sf-0.4.2/tests/test_cli.py +817 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_cluster.py +106 -0
- graphify_sf-0.4.2/tests/test_export_svg.py +23 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_extract_apex.py +161 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_extract_doc.py +53 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_extract_pipeline.py +103 -0
- graphify_sf-0.4.2/tests/test_llm.py +775 -0
- graphify_sf-0.4.2/tests/test_llm_sdk_contract.py +155 -0
- graphify_sf-0.4.2/tests/test_multigraph.py +560 -0
- graphify_sf-0.4.0/tests/test_cli.py +0 -438
- graphify_sf-0.4.0/tests/test_llm.py +0 -358
- graphify_sf-0.4.0/tests/test_multigraph.py +0 -266
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/LICENSE +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/__init__.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/analyze.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/build.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/cache.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/detect.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/_ids.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/agentforce.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/aura.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/automation.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/config.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/flow.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/layout.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/lwc.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/object.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/profile.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/reporting.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/rules.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/sharing.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/visualforce.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/report.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/security.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/validate.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/watch.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf.egg-info/dependency_links.txt +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf.egg-info/entry_points.txt +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf.egg-info/top_level.txt +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/setup.cfg +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_analyze.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_automation_coverage.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_build.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_datamodel_coverage.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_detect.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_detect_docs.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_detect_skip_dirs.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_extract_agentforce.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_extract_config.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_extract_flow.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_extract_lwc.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_extract_object.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_integration_coverage.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_reporting_packaging.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_security_sharing.py +0 -0
- {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_ui_linkage.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: graphify-sf
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Turn any Salesforce SFDX project into a queryable knowledge graph — fully offline, no org connection required.
|
|
5
5
|
Author: Ray Kuo
|
|
6
6
|
License-Expression: MIT
|
|
@@ -17,55 +17,54 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.11
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.12
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
21
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
22
|
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
22
23
|
Requires-Python: >=3.10
|
|
23
24
|
Description-Content-Type: text/markdown
|
|
24
25
|
License-File: LICENSE
|
|
25
26
|
Requires-Dist: networkx>=3.0
|
|
26
|
-
Requires-Dist: datasketch>=1.6
|
|
27
|
-
Requires-Dist: rapidfuzz>=3.0
|
|
28
27
|
Requires-Dist: pathspec>=0.12
|
|
29
28
|
Requires-Dist: idna>=3.15
|
|
30
29
|
Provides-Extra: leiden
|
|
31
|
-
Requires-Dist: graspologic;
|
|
30
|
+
Requires-Dist: graspologic-native>=1.2.5; extra == "leiden"
|
|
32
31
|
Provides-Extra: neo4j
|
|
33
32
|
Requires-Dist: neo4j; extra == "neo4j"
|
|
34
33
|
Provides-Extra: svg
|
|
35
34
|
Requires-Dist: matplotlib; extra == "svg"
|
|
35
|
+
Requires-Dist: scipy; extra == "svg"
|
|
36
36
|
Provides-Extra: watch
|
|
37
37
|
Requires-Dist: watchdog>=3.0; extra == "watch"
|
|
38
38
|
Provides-Extra: mcp
|
|
39
|
-
Requires-Dist: mcp>=1.0; extra == "mcp"
|
|
40
39
|
Provides-Extra: claude
|
|
41
40
|
Requires-Dist: anthropic>=0.30; extra == "claude"
|
|
42
41
|
Provides-Extra: kimi
|
|
43
|
-
Requires-Dist: openai>=1.
|
|
42
|
+
Requires-Dist: openai>=1.58; extra == "kimi"
|
|
44
43
|
Provides-Extra: gemini
|
|
45
|
-
Requires-Dist: openai>=1.
|
|
44
|
+
Requires-Dist: openai>=1.58; extra == "gemini"
|
|
46
45
|
Provides-Extra: openai
|
|
47
|
-
Requires-Dist: openai>=1.
|
|
46
|
+
Requires-Dist: openai>=1.58; extra == "openai"
|
|
48
47
|
Provides-Extra: bedrock
|
|
49
|
-
Requires-Dist: boto3>=1.
|
|
48
|
+
Requires-Dist: boto3>=1.34.116; extra == "bedrock"
|
|
50
49
|
Provides-Extra: ollama
|
|
51
|
-
Requires-Dist: openai>=1.
|
|
50
|
+
Requires-Dist: openai>=1.58; extra == "ollama"
|
|
52
51
|
Provides-Extra: llm
|
|
53
|
-
Requires-Dist: openai>=1.
|
|
52
|
+
Requires-Dist: openai>=1.58; extra == "llm"
|
|
54
53
|
Requires-Dist: anthropic>=0.30; extra == "llm"
|
|
55
54
|
Provides-Extra: docs
|
|
56
|
-
Requires-Dist: pypdf; extra == "docs"
|
|
55
|
+
Requires-Dist: pypdf>=6.19; extra == "docs"
|
|
57
56
|
Requires-Dist: python-docx; extra == "docs"
|
|
58
57
|
Requires-Dist: openpyxl; extra == "docs"
|
|
59
58
|
Provides-Extra: all
|
|
60
|
-
Requires-Dist: graspologic;
|
|
59
|
+
Requires-Dist: graspologic-native>=1.2.5; extra == "all"
|
|
61
60
|
Requires-Dist: neo4j; extra == "all"
|
|
62
61
|
Requires-Dist: matplotlib; extra == "all"
|
|
62
|
+
Requires-Dist: scipy; extra == "all"
|
|
63
63
|
Requires-Dist: watchdog>=3.0; extra == "all"
|
|
64
|
-
Requires-Dist:
|
|
65
|
-
Requires-Dist: openai>=1.0; extra == "all"
|
|
64
|
+
Requires-Dist: openai>=1.58; extra == "all"
|
|
66
65
|
Requires-Dist: anthropic>=0.30; extra == "all"
|
|
67
|
-
Requires-Dist: boto3>=1.
|
|
68
|
-
Requires-Dist: pypdf; extra == "all"
|
|
66
|
+
Requires-Dist: boto3>=1.34.116; extra == "all"
|
|
67
|
+
Requires-Dist: pypdf>=6.19; extra == "all"
|
|
69
68
|
Requires-Dist: python-docx; extra == "all"
|
|
70
69
|
Requires-Dist: openpyxl; extra == "all"
|
|
71
70
|
Provides-Extra: dev
|
|
@@ -301,15 +300,17 @@ The `--backend` flag adds an AI-powered extraction layer on top of the static XM
|
|
|
301
300
|
|
|
302
301
|
### Supported Backends
|
|
303
302
|
|
|
304
|
-
| Backend | Flag | API Key env var |
|
|
305
|
-
|
|
306
|
-
| Claude | `--backend claude` | `ANTHROPIC_API_KEY` |
|
|
307
|
-
| Gemini | `--backend gemini` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` |
|
|
308
|
-
| Kimi
|
|
309
|
-
| OpenAI | `--backend openai` | `OPENAI_API_KEY` |
|
|
310
|
-
| AWS Bedrock | `--backend bedrock` | `AWS_PROFILE` / `AWS_REGION` |
|
|
311
|
-
| Ollama (local) | `--backend ollama` | `OLLAMA_BASE_URL` | free |
|
|
312
|
-
| Auto-detect | `--backend auto` | _(first found)_ | varies |
|
|
303
|
+
| Backend | Flag | API Key env var | Default model | Price per 1M tokens (in / out) |
|
|
304
|
+
|---------|------|-----------------|---------------|--------------------------------|
|
|
305
|
+
| Claude | `--backend claude` | `ANTHROPIC_API_KEY` | `claude-sonnet-4-6` | $3.00 / $15.00 |
|
|
306
|
+
| Gemini | `--backend gemini` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | `gemini-3.6-flash` | $0.75 / $3.75 |
|
|
307
|
+
| Kimi | `--backend kimi` | `MOONSHOT_API_KEY` | `kimi-k3` | $3.00 / $15.00 |
|
|
308
|
+
| OpenAI | `--backend openai` | `OPENAI_API_KEY` | `gpt-4.1-mini` | $0.40 / $1.60 |
|
|
309
|
+
| AWS Bedrock | `--backend bedrock` | `AWS_PROFILE` / `AWS_REGION` | `us.anthropic.claude-sonnet-4-6` | $3.30 / $16.50 |
|
|
310
|
+
| Ollama (local) | `--backend ollama` | `OLLAMA_BASE_URL` | `qwen2.5-coder:7b` | free |
|
|
311
|
+
| Auto-detect | `--backend auto` | _(first found)_ | — | varies |
|
|
312
|
+
|
|
313
|
+
Prices mirror `BACKENDS` in `graphify_sf/llm.py`, which the CLI also uses for its cost estimates. The estimates assume the default model, even if you override it. Gemini 3.x and Kimi K3 always reason, and their thinking tokens are billed as output. The Bedrock default is a US geo inference profile: outside US regions, set `GRAPHIFY_SF_BEDROCK_MODEL` (e.g. `eu.anthropic.claude-sonnet-4-6` or `global.anthropic.claude-sonnet-4-6`). Gemini, Kimi, OpenAI and Bedrock defaults can be overridden with the model variables below (Ollama via `OLLAMA_MODEL`). The Claude model is fixed, and the CLI has no `--model` flag.
|
|
313
314
|
|
|
314
315
|
### Install the SDK
|
|
315
316
|
|
|
@@ -333,12 +334,13 @@ pip install graphify-sf[llm]
|
|
|
333
334
|
|----------|-------------|
|
|
334
335
|
| `ANTHROPIC_API_KEY` | Claude API key |
|
|
335
336
|
| `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Gemini API key |
|
|
336
|
-
| `MOONSHOT_API_KEY` | Kimi
|
|
337
|
+
| `MOONSHOT_API_KEY` | Kimi API key |
|
|
337
338
|
| `OPENAI_API_KEY` | OpenAI API key |
|
|
338
339
|
| `AWS_PROFILE` / `AWS_REGION` | AWS credentials for Bedrock |
|
|
339
340
|
| `OLLAMA_BASE_URL` | Ollama server URL (default: `http://localhost:11434/v1`) |
|
|
340
341
|
| `OLLAMA_MODEL` | Ollama model name (default: `qwen2.5-coder:7b`) |
|
|
341
342
|
| `GRAPHIFY_SF_GEMINI_MODEL` | Override Gemini model |
|
|
343
|
+
| `GRAPHIFY_SF_KIMI_MODEL` | Override Kimi model (e.g. `kimi-k2.6`; K2.x runs with thinking disabled) |
|
|
342
344
|
| `GRAPHIFY_SF_OPENAI_MODEL` | Override OpenAI model |
|
|
343
345
|
| `GRAPHIFY_SF_BEDROCK_MODEL` | Override Bedrock model |
|
|
344
346
|
| `GRAPHIFY_SF_MAX_OUTPUT_TOKENS` | Override max output tokens per LLM call |
|
|
@@ -572,6 +574,7 @@ graphify-sf serve --graph path/to/graph.json
|
|
|
572
574
|
| `god_nodes` | Highest-degree nodes — the most central metadata in the org |
|
|
573
575
|
| `list_communities` | All communities with their labels and member counts |
|
|
574
576
|
| `get_community` | All members of a specific community (by id or label) |
|
|
577
|
+
| `bfs_impact` | Bounded, direction-aware impact traversal (`forward`/`reverse`/`both`, `max_depth`, `relation_filter`, EXTRACTED-only unless `include_inferred`) |
|
|
575
578
|
|
|
576
579
|
**Available resources** (via `resources/list` and `resources/read`):
|
|
577
580
|
|
|
@@ -742,7 +745,7 @@ force-app/archived/**
|
|
|
742
745
|
## Optional Extras
|
|
743
746
|
|
|
744
747
|
```bash
|
|
745
|
-
# Better community detection (Leiden algorithm
|
|
748
|
+
# Better community detection (Leiden algorithm via graspologic-native)
|
|
746
749
|
pip install graphify-sf[leiden]
|
|
747
750
|
|
|
748
751
|
# Neo4j direct push support
|
|
@@ -754,7 +757,8 @@ pip install graphify-sf[svg]
|
|
|
754
757
|
# Watchdog for efficient file-system events in watch mode
|
|
755
758
|
pip install graphify-sf[watch]
|
|
756
759
|
|
|
757
|
-
# MCP
|
|
760
|
+
# MCP server — no extra needed (`graphify-sf serve` is self-contained);
|
|
761
|
+
# kept as an empty alias so existing install commands still work
|
|
758
762
|
pip install graphify-sf[mcp]
|
|
759
763
|
|
|
760
764
|
# LLM semantic extraction — Claude
|
|
@@ -782,7 +786,7 @@ pip install graphify-sf[all]
|
|
|
782
786
|
|
|
783
787
|
- Python 3.10+
|
|
784
788
|
- No Salesforce CLI, no org connection, no API keys
|
|
785
|
-
- Core dependencies: `networkx`, `
|
|
789
|
+
- Core dependencies: `networkx`, `pathspec`, `idna`
|
|
786
790
|
|
|
787
791
|
---
|
|
788
792
|
|
|
@@ -827,7 +831,7 @@ graphify_sf/
|
|
|
827
831
|
├── export.py All export formats — HTML, SVG, Obsidian, GraphML, Cypher, wiki, ...
|
|
828
832
|
├── llm.py LLM semantic extraction — 6 backends, SF-specific prompt, adaptive retry
|
|
829
833
|
├── watch.py File-system watcher — fcntl rebuild lock, resource limits, callflow regen
|
|
830
|
-
├── serve.py MCP stdio server —
|
|
834
|
+
├── serve.py MCP stdio server — 9 tools, 6 resources, blank-stdin filter
|
|
831
835
|
├── cache.py Per-file extraction cache — SHA-256 keyed, atomic writes
|
|
832
836
|
├── validate.py Extraction schema validation — catches bad extractors before graph assembly
|
|
833
837
|
├── security.py Input sanitization — sanitize_label, validate_graph_path
|
|
@@ -225,15 +225,17 @@ The `--backend` flag adds an AI-powered extraction layer on top of the static XM
|
|
|
225
225
|
|
|
226
226
|
### Supported Backends
|
|
227
227
|
|
|
228
|
-
| Backend | Flag | API Key env var |
|
|
229
|
-
|
|
230
|
-
| Claude | `--backend claude` | `ANTHROPIC_API_KEY` |
|
|
231
|
-
| Gemini | `--backend gemini` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` |
|
|
232
|
-
| Kimi
|
|
233
|
-
| OpenAI | `--backend openai` | `OPENAI_API_KEY` |
|
|
234
|
-
| AWS Bedrock | `--backend bedrock` | `AWS_PROFILE` / `AWS_REGION` |
|
|
235
|
-
| Ollama (local) | `--backend ollama` | `OLLAMA_BASE_URL` | free |
|
|
236
|
-
| Auto-detect | `--backend auto` | _(first found)_ | varies |
|
|
228
|
+
| Backend | Flag | API Key env var | Default model | Price per 1M tokens (in / out) |
|
|
229
|
+
|---------|------|-----------------|---------------|--------------------------------|
|
|
230
|
+
| Claude | `--backend claude` | `ANTHROPIC_API_KEY` | `claude-sonnet-4-6` | $3.00 / $15.00 |
|
|
231
|
+
| Gemini | `--backend gemini` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | `gemini-3.6-flash` | $0.75 / $3.75 |
|
|
232
|
+
| Kimi | `--backend kimi` | `MOONSHOT_API_KEY` | `kimi-k3` | $3.00 / $15.00 |
|
|
233
|
+
| OpenAI | `--backend openai` | `OPENAI_API_KEY` | `gpt-4.1-mini` | $0.40 / $1.60 |
|
|
234
|
+
| AWS Bedrock | `--backend bedrock` | `AWS_PROFILE` / `AWS_REGION` | `us.anthropic.claude-sonnet-4-6` | $3.30 / $16.50 |
|
|
235
|
+
| Ollama (local) | `--backend ollama` | `OLLAMA_BASE_URL` | `qwen2.5-coder:7b` | free |
|
|
236
|
+
| Auto-detect | `--backend auto` | _(first found)_ | — | varies |
|
|
237
|
+
|
|
238
|
+
Prices mirror `BACKENDS` in `graphify_sf/llm.py`, which the CLI also uses for its cost estimates. The estimates assume the default model, even if you override it. Gemini 3.x and Kimi K3 always reason, and their thinking tokens are billed as output. The Bedrock default is a US geo inference profile: outside US regions, set `GRAPHIFY_SF_BEDROCK_MODEL` (e.g. `eu.anthropic.claude-sonnet-4-6` or `global.anthropic.claude-sonnet-4-6`). Gemini, Kimi, OpenAI and Bedrock defaults can be overridden with the model variables below (Ollama via `OLLAMA_MODEL`). The Claude model is fixed, and the CLI has no `--model` flag.
|
|
237
239
|
|
|
238
240
|
### Install the SDK
|
|
239
241
|
|
|
@@ -257,12 +259,13 @@ pip install graphify-sf[llm]
|
|
|
257
259
|
|----------|-------------|
|
|
258
260
|
| `ANTHROPIC_API_KEY` | Claude API key |
|
|
259
261
|
| `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Gemini API key |
|
|
260
|
-
| `MOONSHOT_API_KEY` | Kimi
|
|
262
|
+
| `MOONSHOT_API_KEY` | Kimi API key |
|
|
261
263
|
| `OPENAI_API_KEY` | OpenAI API key |
|
|
262
264
|
| `AWS_PROFILE` / `AWS_REGION` | AWS credentials for Bedrock |
|
|
263
265
|
| `OLLAMA_BASE_URL` | Ollama server URL (default: `http://localhost:11434/v1`) |
|
|
264
266
|
| `OLLAMA_MODEL` | Ollama model name (default: `qwen2.5-coder:7b`) |
|
|
265
267
|
| `GRAPHIFY_SF_GEMINI_MODEL` | Override Gemini model |
|
|
268
|
+
| `GRAPHIFY_SF_KIMI_MODEL` | Override Kimi model (e.g. `kimi-k2.6`; K2.x runs with thinking disabled) |
|
|
266
269
|
| `GRAPHIFY_SF_OPENAI_MODEL` | Override OpenAI model |
|
|
267
270
|
| `GRAPHIFY_SF_BEDROCK_MODEL` | Override Bedrock model |
|
|
268
271
|
| `GRAPHIFY_SF_MAX_OUTPUT_TOKENS` | Override max output tokens per LLM call |
|
|
@@ -496,6 +499,7 @@ graphify-sf serve --graph path/to/graph.json
|
|
|
496
499
|
| `god_nodes` | Highest-degree nodes — the most central metadata in the org |
|
|
497
500
|
| `list_communities` | All communities with their labels and member counts |
|
|
498
501
|
| `get_community` | All members of a specific community (by id or label) |
|
|
502
|
+
| `bfs_impact` | Bounded, direction-aware impact traversal (`forward`/`reverse`/`both`, `max_depth`, `relation_filter`, EXTRACTED-only unless `include_inferred`) |
|
|
499
503
|
|
|
500
504
|
**Available resources** (via `resources/list` and `resources/read`):
|
|
501
505
|
|
|
@@ -666,7 +670,7 @@ force-app/archived/**
|
|
|
666
670
|
## Optional Extras
|
|
667
671
|
|
|
668
672
|
```bash
|
|
669
|
-
# Better community detection (Leiden algorithm
|
|
673
|
+
# Better community detection (Leiden algorithm via graspologic-native)
|
|
670
674
|
pip install graphify-sf[leiden]
|
|
671
675
|
|
|
672
676
|
# Neo4j direct push support
|
|
@@ -678,7 +682,8 @@ pip install graphify-sf[svg]
|
|
|
678
682
|
# Watchdog for efficient file-system events in watch mode
|
|
679
683
|
pip install graphify-sf[watch]
|
|
680
684
|
|
|
681
|
-
# MCP
|
|
685
|
+
# MCP server — no extra needed (`graphify-sf serve` is self-contained);
|
|
686
|
+
# kept as an empty alias so existing install commands still work
|
|
682
687
|
pip install graphify-sf[mcp]
|
|
683
688
|
|
|
684
689
|
# LLM semantic extraction — Claude
|
|
@@ -706,7 +711,7 @@ pip install graphify-sf[all]
|
|
|
706
711
|
|
|
707
712
|
- Python 3.10+
|
|
708
713
|
- No Salesforce CLI, no org connection, no API keys
|
|
709
|
-
- Core dependencies: `networkx`, `
|
|
714
|
+
- Core dependencies: `networkx`, `pathspec`, `idna`
|
|
710
715
|
|
|
711
716
|
---
|
|
712
717
|
|
|
@@ -751,7 +756,7 @@ graphify_sf/
|
|
|
751
756
|
├── export.py All export formats — HTML, SVG, Obsidian, GraphML, Cypher, wiki, ...
|
|
752
757
|
├── llm.py LLM semantic extraction — 6 backends, SF-specific prompt, adaptive retry
|
|
753
758
|
├── watch.py File-system watcher — fcntl rebuild lock, resource limits, callflow regen
|
|
754
|
-
├── serve.py MCP stdio server —
|
|
759
|
+
├── serve.py MCP stdio server — 9 tools, 6 resources, blank-stdin filter
|
|
755
760
|
├── cache.py Per-file extraction cache — SHA-256 keyed, atomic writes
|
|
756
761
|
├── validate.py Extraction schema validation — catches bad extractors before graph assembly
|
|
757
762
|
├── security.py Input sanitization — sanitize_label, validate_graph_path
|
|
@@ -37,6 +37,78 @@ def _load_graph_from_json(path: Path):
|
|
|
37
37
|
return _jg.node_link_graph(raw), raw
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
def _load_graph_json_raw(path: Path) -> dict:
|
|
41
|
+
"""Load a graph.json's raw ``nodes``/``links`` lists (no NetworkX collapse).
|
|
42
|
+
|
|
43
|
+
Returns ``{"nodes": [...], "links": [...]}`` — empty lists if the file is
|
|
44
|
+
missing or unreadable. The merge paths use this instead of a ``nx.Graph()``
|
|
45
|
+
union so same-direction parallel edges survive until the multigraph build.
|
|
46
|
+
"""
|
|
47
|
+
if not path.exists():
|
|
48
|
+
return {"nodes": [], "links": []}
|
|
49
|
+
try:
|
|
50
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
51
|
+
if "links" not in raw and "edges" in raw:
|
|
52
|
+
raw["links"] = raw["edges"]
|
|
53
|
+
return raw
|
|
54
|
+
except Exception:
|
|
55
|
+
return {"nodes": [], "links": []}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_UNION_EDGE_IGNORED_FIELDS = frozenset({"key", "source_file"})
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _union_edge_identity(edge: dict) -> str:
|
|
62
|
+
"""Canonical dedup identity for a link during union.
|
|
63
|
+
|
|
64
|
+
Hashes the full edge dict minus the per-graph fields that legitimately vary
|
|
65
|
+
across inputs (``key`` — each source graph numbers its own parallel edges
|
|
66
|
+
from 0; ``source_file`` — an absolute path unique to the machine that built
|
|
67
|
+
that graph). Every other field participates, so edges that differ only in
|
|
68
|
+
``operation`` (e.g. ``dml`` create vs. update on the same source→target
|
|
69
|
+
pair, which ``extract/apex.py`` and ``extract/flow.py`` deliberately emit as
|
|
70
|
+
distinct edges) are kept apart, while genuinely-identical duplicates (the
|
|
71
|
+
common base/ours/theirs 3-way case) still collapse to one.
|
|
72
|
+
"""
|
|
73
|
+
identity = {k: v for k, v in edge.items() if k not in _UNION_EDGE_IGNORED_FIELDS}
|
|
74
|
+
return json.dumps(identity, sort_keys=True, default=str)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _union_graph_json(datas: list[dict]) -> tuple[list[dict], list[dict]]:
|
|
78
|
+
"""Union raw graph.json node/link lists across multiple graphs.
|
|
79
|
+
|
|
80
|
+
Nodes are deduped by ``id`` (first occurrence wins). Links are unioned and
|
|
81
|
+
deduped on a canonical hash of the full edge dict (minus the per-graph
|
|
82
|
+
``key``/``source_file`` fields), so distinct parallel edges are ALL
|
|
83
|
+
preserved — both those differing in ``relation`` (e.g. ``queries`` + ``dml``
|
|
84
|
+
on the same pair) and those differing only in ``operation`` (e.g. two
|
|
85
|
+
``dml`` edges, one create + one update, on the same pair). A plain
|
|
86
|
+
``nx.Graph()`` union would collapse the former, and a coarse
|
|
87
|
+
``(source, target, relation)`` key would collapse the latter, before the
|
|
88
|
+
multigraph build ever runs. Genuinely-identical duplicate edges (the
|
|
89
|
+
base/ours/theirs 3-way merge case) still coalesce to one.
|
|
90
|
+
"""
|
|
91
|
+
all_nodes: dict[str, dict] = {}
|
|
92
|
+
all_edges: list[dict] = []
|
|
93
|
+
seen_edges: set[str] = set()
|
|
94
|
+
for data in datas:
|
|
95
|
+
for node in data.get("nodes", []):
|
|
96
|
+
nid = node.get("id", "")
|
|
97
|
+
if nid and nid not in all_nodes:
|
|
98
|
+
all_nodes[nid] = node
|
|
99
|
+
links_key = "links" if "links" in data else "edges"
|
|
100
|
+
for edge in data.get(links_key, []):
|
|
101
|
+
identity = _union_edge_identity(edge)
|
|
102
|
+
if identity not in seen_edges:
|
|
103
|
+
seen_edges.add(identity)
|
|
104
|
+
# Drop the exported per-graph "key" — each source graph numbers its
|
|
105
|
+
# own parallel edges from 0, so two distinct relations on the same
|
|
106
|
+
# pair both arrive as key=0 and would collapse on rebuild. Stripping
|
|
107
|
+
# it lets the MultiDiGraph assign fresh keys per parallel edge.
|
|
108
|
+
all_edges.append({k: v for k, v in edge.items() if k != "key"})
|
|
109
|
+
return list(all_nodes.values()), all_edges
|
|
110
|
+
|
|
111
|
+
|
|
40
112
|
def _derive_community_labels(G, communities: dict) -> dict[int, str]:
|
|
41
113
|
"""Derive community label from the highest-degree non-method node."""
|
|
42
114
|
labels: dict[int, str] = {}
|
|
@@ -184,6 +256,7 @@ def _run_pipeline(
|
|
|
184
256
|
backend: str | None = None,
|
|
185
257
|
token_budget: int = 40_000,
|
|
186
258
|
respect_ignore: bool = True,
|
|
259
|
+
verbose: bool = False,
|
|
187
260
|
) -> None:
|
|
188
261
|
"""Full pipeline: detect → extract → [LLM] → build → cluster → report → export."""
|
|
189
262
|
from graphify_sf.analyze import god_nodes, suggest_questions, surprising_connections
|
|
@@ -241,7 +314,9 @@ def _run_pipeline(
|
|
|
241
314
|
_run_llm_extraction(target, detection, extraction, backend, token_budget, token_cost)
|
|
242
315
|
|
|
243
316
|
# Dedup by label
|
|
317
|
+
n_extracted = len(extraction["nodes"])
|
|
244
318
|
nodes, edges = deduplicate_by_label(extraction["nodes"], extraction["edges"])
|
|
319
|
+
n_deduped = len(nodes)
|
|
245
320
|
|
|
246
321
|
new_extraction = {"nodes": nodes, "edges": edges}
|
|
247
322
|
|
|
@@ -254,6 +329,34 @@ def _run_pipeline(
|
|
|
254
329
|
|
|
255
330
|
print(f"[graphify-sf] graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges")
|
|
256
331
|
|
|
332
|
+
if verbose:
|
|
333
|
+
# Honest node-drop accounting (closes the unanswered BUGS_FOUND question:
|
|
334
|
+
# "N nodes dropped between extract and build — dedup or dangling pruning?").
|
|
335
|
+
# Real pipeline stages, verified against build.py:
|
|
336
|
+
# * deduplicate_by_label MERGES same-(sf_type,label) nodes → removes nodes
|
|
337
|
+
# * build_from_json ADDS stub nodes for prefix-matched missing edge
|
|
338
|
+
# endpoints, and PRUNES dangling EDGES (edges to still-absent nodes) —
|
|
339
|
+
# it removes edges, never nodes.
|
|
340
|
+
# "Build-added stubs" are counted as final node ids absent from the deduped
|
|
341
|
+
# set (NOT the `stub` attribute, which the extractors also set on stubs they
|
|
342
|
+
# inject before dedup). For a fresh build: final == deduped + build_stubs.
|
|
343
|
+
n_final = G.number_of_nodes()
|
|
344
|
+
deduped_ids = {n["id"] for n in nodes if isinstance(n, dict) and "id" in n}
|
|
345
|
+
# In incremental mode G is merged with the ENTIRE prior graph.json, so nodes
|
|
346
|
+
# retained unchanged from that prior run are absent from this run's own
|
|
347
|
+
# deduped_ids and would be miscounted as build-injected stubs. Gate stub
|
|
348
|
+
# reporting to the fresh-build path, same as n_pruned_edges below.
|
|
349
|
+
n_build_stubs = sum(1 for nid in G.nodes() if nid not in deduped_ids) if not incremental else None
|
|
350
|
+
stub_str = "n/a (incremental merge)" if n_build_stubs is None else f"+{n_build_stubs} stub"
|
|
351
|
+
n_pruned_edges = max(0, len(edges) - G.number_of_edges()) if not incremental else None
|
|
352
|
+
pruned_str = "n/a (incremental merge)" if n_pruned_edges is None else str(n_pruned_edges)
|
|
353
|
+
print(
|
|
354
|
+
f"[graphify-sf] node accounting: {n_extracted} extracted "
|
|
355
|
+
f"→ {n_deduped} deduped-by-label ({n_extracted - n_deduped} merged) "
|
|
356
|
+
f"→ {stub_str} → {n_final} final nodes "
|
|
357
|
+
f"({pruned_str} dangling edge(s) pruned)"
|
|
358
|
+
)
|
|
359
|
+
|
|
257
360
|
if G.number_of_nodes() == 0:
|
|
258
361
|
print("[graphify-sf] error: graph is empty after extraction", file=sys.stderr)
|
|
259
362
|
sys.exit(1)
|
|
@@ -500,15 +603,20 @@ def _cmd_path(source_label: str, target_label: str, graph_path: Path) -> None:
|
|
|
500
603
|
print(f"No path found between '{source_label}' and '{target_label}'.")
|
|
501
604
|
sys.exit(0)
|
|
502
605
|
|
|
503
|
-
from graphify_sf.build import
|
|
606
|
+
from graphify_sf.build import edge_datas
|
|
504
607
|
|
|
505
608
|
hops = len(path_nodes) - 1
|
|
506
609
|
segments = []
|
|
507
610
|
for i in range(len(path_nodes) - 1):
|
|
508
611
|
u, v = path_nodes[i], path_nodes[i + 1]
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
612
|
+
# MultiDiGraph: a hop may carry several parallel edges (e.g. queries +
|
|
613
|
+
# dml). Show every relation on the hop, not an arbitrary first one.
|
|
614
|
+
edatas = edge_datas(G, u, v)
|
|
615
|
+
rels = [ed.get("relation", "") for ed in edatas] or [""]
|
|
616
|
+
confs = [ed.get("confidence", "") for ed in edatas] or [""]
|
|
617
|
+
rel = "/".join(r for r in rels if r) or (rels[0] if rels else "")
|
|
618
|
+
conf_parts = [c for c in confs if c]
|
|
619
|
+
conf = "/".join(dict.fromkeys(conf_parts)) if conf_parts else ""
|
|
512
620
|
conf_str = f" [{conf}]" if conf else ""
|
|
513
621
|
if i == 0:
|
|
514
622
|
segments.append(G.nodes[u].get("label", u))
|
|
@@ -733,23 +841,23 @@ def _cmd_merge_graphs(graph_paths: list[Path], out_path: Path, *, no_viz: bool =
|
|
|
733
841
|
print(f"error: graph not found: {gp}", file=sys.stderr)
|
|
734
842
|
sys.exit(1)
|
|
735
843
|
|
|
736
|
-
# Load all graphs
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
844
|
+
# Load all graphs as raw JSON node/link lists and union them WITHOUT going
|
|
845
|
+
# through a plain nx.Graph(), which would collapse same-direction parallel
|
|
846
|
+
# edges before the multigraph-aware build ever runs. Mirrors the edge-safe
|
|
847
|
+
# union already used by _cmd_merge_driver.
|
|
848
|
+
raw_datas = []
|
|
740
849
|
for gp in graph_paths:
|
|
741
850
|
print(f"[graphify-sf] loading {gp}...")
|
|
742
|
-
|
|
743
|
-
print(f" {
|
|
744
|
-
|
|
851
|
+
raw_i = _load_graph_json_raw(gp)
|
|
852
|
+
print(f" {len(raw_i.get('nodes', []))} nodes, {len(raw_i.get('links', raw_i.get('edges', [])))} edges")
|
|
853
|
+
raw_datas.append(raw_i)
|
|
745
854
|
|
|
746
|
-
|
|
855
|
+
nodes_data, edges_data = _union_graph_json(raw_datas)
|
|
856
|
+
print(f"[graphify-sf] merged: {len(nodes_data)} nodes, {len(edges_data)} edges (before dedup)")
|
|
747
857
|
|
|
748
|
-
# Dedup by label
|
|
749
|
-
nodes_data = [{"id": nid, **data} for nid, data in merged.nodes(data=True)]
|
|
750
|
-
edges_data = [{"source": u, "target": v, **data} for u, v, data in merged.edges(data=True)]
|
|
858
|
+
# Dedup by label, then build the multigraph so parallel edges survive.
|
|
751
859
|
nodes_dedup, edges_dedup = deduplicate_by_label(nodes_data, edges_data)
|
|
752
|
-
G = build_from_json({"nodes": nodes_dedup, "edges": edges_dedup})
|
|
860
|
+
G = build_from_json({"nodes": nodes_dedup, "edges": edges_dedup}, multigraph=True)
|
|
753
861
|
|
|
754
862
|
print(f"[graphify-sf] after dedup: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges")
|
|
755
863
|
|
|
@@ -823,44 +931,13 @@ def _cmd_merge_driver(base: Path, ours: Path, theirs: Path) -> None:
|
|
|
823
931
|
from graphify_sf.cluster import cluster
|
|
824
932
|
from graphify_sf.export import to_json
|
|
825
933
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
return raw
|
|
834
|
-
except Exception:
|
|
835
|
-
return {"nodes": [], "links": []}
|
|
836
|
-
|
|
837
|
-
base_data = _load(base)
|
|
838
|
-
our_data = _load(ours)
|
|
839
|
-
their_data = _load(theirs)
|
|
840
|
-
|
|
841
|
-
# Union all nodes and edges by id
|
|
842
|
-
all_nodes: dict[str, dict] = {}
|
|
843
|
-
all_edges: list[dict] = []
|
|
844
|
-
seen_edges: set[tuple] = set()
|
|
845
|
-
|
|
846
|
-
for data in (base_data, our_data, their_data):
|
|
847
|
-
for node in data.get("nodes", []):
|
|
848
|
-
nid = node.get("id", "")
|
|
849
|
-
if nid and nid not in all_nodes:
|
|
850
|
-
all_nodes[nid] = node
|
|
851
|
-
links_key = "links" if "links" in data else "edges"
|
|
852
|
-
for edge in data.get(links_key, []):
|
|
853
|
-
src = edge.get("source", "")
|
|
854
|
-
tgt = edge.get("target", "")
|
|
855
|
-
rel = edge.get("relation", "")
|
|
856
|
-
key = (src, tgt, rel)
|
|
857
|
-
if key not in seen_edges:
|
|
858
|
-
seen_edges.add(key)
|
|
859
|
-
all_edges.append(edge)
|
|
860
|
-
|
|
861
|
-
extraction = {"nodes": list(all_nodes.values()), "edges": all_edges}
|
|
862
|
-
nodes_dedup, edges_dedup = deduplicate_by_label(extraction["nodes"], extraction["edges"])
|
|
863
|
-
G = build_from_json({"nodes": nodes_dedup, "edges": edges_dedup})
|
|
934
|
+
# Union raw node/link lists (dedup nodes by id, links by (src,tgt,rel)) so
|
|
935
|
+
# parallel edges survive; see _union_graph_json.
|
|
936
|
+
all_nodes, all_edges = _union_graph_json(
|
|
937
|
+
[_load_graph_json_raw(base), _load_graph_json_raw(ours), _load_graph_json_raw(theirs)]
|
|
938
|
+
)
|
|
939
|
+
nodes_dedup, edges_dedup = deduplicate_by_label(all_nodes, all_edges)
|
|
940
|
+
G = build_from_json({"nodes": nodes_dedup, "edges": edges_dedup}, multigraph=True)
|
|
864
941
|
communities = cluster(G)
|
|
865
942
|
to_json(G, communities, str(ours), force=True)
|
|
866
943
|
print(f"[graphify-sf] merge-driver: merged {G.number_of_nodes()} nodes, {G.number_of_edges()} edges → {ours}")
|
|
@@ -1578,6 +1655,7 @@ def main() -> None:
|
|
|
1578
1655
|
print(" --update incremental update (re-extract changed files)")
|
|
1579
1656
|
print(" --directed build directed graph")
|
|
1580
1657
|
print(" --no-viz skip graph.html generation")
|
|
1658
|
+
print(" --verbose, -v print node-drop accounting (extract→dedup→build)")
|
|
1581
1659
|
print(" --force overwrite graph.json even if new graph is smaller")
|
|
1582
1660
|
print(" --include-ignored scan files matched by .gitignore/.forceignore")
|
|
1583
1661
|
print(" (default: those files are skipped)")
|
|
@@ -1947,6 +2025,7 @@ def main() -> None:
|
|
|
1947
2025
|
backend: str | None = None
|
|
1948
2026
|
token_budget = 40_000
|
|
1949
2027
|
respect_ignore = True
|
|
2028
|
+
verbose = False
|
|
1950
2029
|
|
|
1951
2030
|
i = 1
|
|
1952
2031
|
while i < len(args):
|
|
@@ -1966,6 +2045,9 @@ def main() -> None:
|
|
|
1966
2045
|
elif a == "--no-viz":
|
|
1967
2046
|
no_viz = True
|
|
1968
2047
|
i += 1
|
|
2048
|
+
elif a in ("--verbose", "-v"):
|
|
2049
|
+
verbose = True
|
|
2050
|
+
i += 1
|
|
1969
2051
|
elif a == "--force":
|
|
1970
2052
|
force = True
|
|
1971
2053
|
i += 1
|
|
@@ -2005,6 +2087,7 @@ def main() -> None:
|
|
|
2005
2087
|
backend=backend,
|
|
2006
2088
|
token_budget=token_budget,
|
|
2007
2089
|
respect_ignore=respect_ignore,
|
|
2090
|
+
verbose=verbose,
|
|
2008
2091
|
)
|
|
2009
2092
|
return
|
|
2010
2093
|
|
|
@@ -20,7 +20,7 @@ Turn any Salesforce SFDX project into a navigable knowledge graph with community
|
|
|
20
20
|
/graphify-sf <path> --force # overwrite graph.json even if new graph is smaller
|
|
21
21
|
/graphify-sf <path> --max-workers N # parallel extraction worker count
|
|
22
22
|
/graphify-sf <path> --backend claude # add AI semantic layer (business rules, dead metadata)
|
|
23
|
-
/graphify-sf <path> --backend gemini # Gemini backend
|
|
23
|
+
/graphify-sf <path> --backend gemini # Gemini backend
|
|
24
24
|
/graphify-sf <path> --backend auto # auto-detect backend from available API keys
|
|
25
25
|
/graphify-sf <path> --backend claude --token-budget 20000 # smaller chunks for large orgs
|
|
26
26
|
/graphify-sf cluster-only <path> # rerun clustering on existing graph
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"""Community detection on NetworkX graphs.
|
|
2
2
|
|
|
3
|
-
Uses Leiden (graspologic) if available, falls back to Louvain (networkx).
|
|
3
|
+
Uses Leiden (graspologic-native) if available, falls back to Louvain (networkx).
|
|
4
4
|
Splits oversized communities. Returns cohesion scores.
|
|
5
5
|
|
|
6
|
-
Adapted from Graphify's cluster.py
|
|
7
|
-
|
|
6
|
+
Adapted from Graphify's cluster.py; the Leiden path has since diverged
|
|
7
|
+
(graspologic-native engine, parallel-edge aggregation, deterministic labels).
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
@@ -22,23 +22,60 @@ def _suppress_output():
|
|
|
22
22
|
return contextlib.redirect_stdout(io.StringIO())
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
def _leiden_edge_list(G: nx.Graph) -> list[tuple[str, str, float]]:
|
|
26
|
+
"""Collapse G into one weighted edge per unordered node pair, keyed by str ids.
|
|
27
|
+
|
|
28
|
+
graspologic-native keeps only the first weight it sees for a pair (later
|
|
29
|
+
duplicates, including the reversed pair, are dropped), so parallel edges are
|
|
30
|
+
summed here — the same treatment Louvain gives multigraphs. Self-loops are
|
|
31
|
+
skipped. Sorted, so the result never depends on G's iteration order (which,
|
|
32
|
+
for small networkx subgraph views, follows Python's randomized str hashing).
|
|
33
|
+
"""
|
|
34
|
+
weights: dict[tuple[str, str], float] = {}
|
|
35
|
+
for u, v, w in G.edges(data="weight", default=1.0):
|
|
36
|
+
su, sv = str(u), str(v)
|
|
37
|
+
if su == sv:
|
|
38
|
+
continue
|
|
39
|
+
pair = (su, sv) if su < sv else (sv, su)
|
|
40
|
+
weights[pair] = weights.get(pair, 0.0) + float(w)
|
|
41
|
+
return sorted((a, b, w) for (a, b), w in weights.items())
|
|
42
|
+
|
|
43
|
+
|
|
25
44
|
def _partition(G: nx.Graph) -> dict[str, int]:
|
|
26
45
|
"""Run community detection. Returns {node_id: community_id}.
|
|
27
46
|
|
|
28
|
-
Tries Leiden (graspologic) first — best quality.
|
|
29
|
-
Falls back to Louvain (built into networkx) if graspologic is not installed.
|
|
47
|
+
Tries Leiden (graspologic-native) first — best quality.
|
|
48
|
+
Falls back to Louvain (built into networkx) if graspologic-native is not installed.
|
|
30
49
|
"""
|
|
31
50
|
try:
|
|
32
|
-
from
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
51
|
+
from graspologic_native import leiden
|
|
52
|
+
|
|
53
|
+
edges = _leiden_edge_list(G)
|
|
54
|
+
native: dict[str, int] = {}
|
|
55
|
+
if edges:
|
|
56
|
+
old_stderr = sys.stderr
|
|
57
|
+
try:
|
|
58
|
+
sys.stderr = io.StringIO()
|
|
59
|
+
with _suppress_output():
|
|
60
|
+
_, native = leiden(
|
|
61
|
+
edges=edges,
|
|
62
|
+
resolution=1.0,
|
|
63
|
+
randomness=0.001,
|
|
64
|
+
iterations=1,
|
|
65
|
+
use_modularity=True,
|
|
66
|
+
seed=42,
|
|
67
|
+
trials=1,
|
|
68
|
+
)
|
|
69
|
+
finally:
|
|
70
|
+
sys.stderr = old_stderr
|
|
71
|
+
# Relabel in sorted-id order: the engine's own labels (and dict order) vary
|
|
72
|
+
# between runs even with a fixed seed, which would reshuffle cluster()'s
|
|
73
|
+
# size-tie ordering. Nodes whose only edges are self-loops never reach the
|
|
74
|
+
# engine and get a community of their own.
|
|
75
|
+
labels: dict[object, int] = {}
|
|
76
|
+
return {
|
|
77
|
+
n: labels.setdefault(native.get(str(n), ("self-loop", n)), len(labels)) for n in sorted(G.nodes(), key=str)
|
|
78
|
+
}
|
|
42
79
|
except ImportError:
|
|
43
80
|
pass
|
|
44
81
|
|
|
@@ -1025,7 +1025,7 @@ def to_svg(
|
|
|
1025
1025
|
) -> None:
|
|
1026
1026
|
"""Export the graph as an SVG image using matplotlib + networkx layout.
|
|
1027
1027
|
|
|
1028
|
-
Requires: pip install graphify-sf[svg] (installs matplotlib)
|
|
1028
|
+
Requires: pip install graphify-sf[svg] (installs matplotlib + scipy)
|
|
1029
1029
|
|
|
1030
1030
|
Args:
|
|
1031
1031
|
G: NetworkX graph.
|