A Python static-analysis toolkit — the CLDK backend that emits the canonical schema v2 Code Property Graph, as analysis.json or a Neo4j property graph.
canpy is a static analyzer for Python built on Jedi,
PyCG, and Tree-sitter. It
emits the canonical CodeLLM-DevKit (CLDK) schema v2 — a single, additive Code Property Graph
tree — either as analysis.json or projected into a Neo4j property graph. It is the Python
backend behind CLDK, mirroring its
TypeScript (cants) and
Java siblings.
The payload is one tree grown one layer at a time across four analysis levels (-a 1|2|3|4): a
symbol table, a call graph, intraprocedural control- and data-dependence graphs, and a whole-program
interprocedural system dependence graph. Each level is a strict superset of the one below it
(analysis.json(-a 1) ⊆ … ⊆ analysis.json(-a 4)), so a consumer can request exactly the depth it
needs.
- Features
- Installation
- Usage
- Analysis levels
- Architecture & Tooling
- Output shape (canonical schema v2)
- Output targets
- Development
- License
- Canonical schema v2 — one additive Code Property Graph tree (
schema_version2.0.0), stamped withlanguage,max_level,analyzer{name,version}, and (at L3+)k_limit, rooted at a singleapplicationnode with durablecan://ids on every callable and above. - Symbol table — modules, classes, functions, methods, variables, decorators, imports, and
docstrings, with precise byte-offset source spans; each module carries its
sourceonce. - Call graph — Jedi's lexical resolver at level 1, enriched with PyCG-resolved edges at level 2 (provenance-tagged, coupling-aware sharding for large apps).
- Dataflow graphs — native, per-callable exceptional CFG plus control- and
data-dependence edges (
cfg/cdg/ddg) at level 3, stitched into a whole-program interprocedural SDG (synthetic parameter vertices,param_in/param_out/summary, alias-aware DDG) at level 4 — all built in-process from the stdlibast. - Neo4j output — project the analysis into a labeled property graph: a self-contained
graph.cyphersnapshot, or an incremental push to a live database over Bolt. - Versioned schema — a machine-readable, version-stamped Neo4j schema contract (
--emit schema), checked in asschema.neo4j.json(2.0.0) and shipped with every release. - Incremental cache — per-file results are cached under
.codeanalyzer;--lazy(default) reuses them,--eagerforces a clean rebuild.--raydistributes the work across cores. - Compact output — canonical
analysis.json, or binaryanalysis.msgpackfor smaller artifacts.
-
Python 3.10 or newer.
-
A C toolchain and the
venv/ development headers — the analyzer builds an isolated virtual environment per project (via Python'svenv) so Jedi can resolve types and imports:# Ubuntu / Debian sudo apt install python3-venv python3-dev build-essential # Fedora / RHEL / CentOS sudo dnf group install "Development Tools" && sudo dnf install python3-venv python3-devel # macOS xcode-select --install
pip install codeanalyzer-python
canpy --helpFor the optional live Neo4j push (--emit neo4j --neo4j-uri …), install the neo4j extra:
pip install 'codeanalyzer-python[neo4j]'For the Scalpel-backed points-to oracle at level 4, install the scalpel extra. It is optional:
when it is absent, level 4 automatically falls back to the built-in type-based oracle.
pip install 'codeanalyzer-python[scalpel]'Install the CLI as an isolated tool with the one-line installer (provisions via uv / pipx / pip):
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/codellm-devkit/codeanalyzer-python/releases/latest/download/canpy-installer.sh | shbrew install codellm-devkit/tap/codeanalyzer-pythonThe formula depends on uv and installs canpy as an isolated,
version-pinned uv tool (the package and its dependencies are resolved and cached on first run).
This project uses uv for dependency management.
git clone https://github.com/codellm-devkit/codeanalyzer-python
cd codeanalyzer-python
uv sync --all-groups
uv run canpy --helpcanpy --input /path/to/python/projectWith no --output, the analysis is printed to stdout as compact JSON; with --output <dir> it is
written to analysis.json (or graph.cypher for --emit neo4j, or analysis.msgpack with
--format msgpack) in that directory.
$ canpy --help
Usage: canpy [OPTIONS] COMMAND [ARGS]...
Static Analysis on Python source code using Jedi, PyCG and Tree sitter.
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --version Show the canpy │
│ version and │
│ exit. │
│ --input -i PATH Path to the │
│ project root │
│ directory (not │
│ required for │
│ --emit schema). │
│ --output -o PATH Output directory │
│ for artifacts. │
│ --format -f [json|msgpack] Output format │
│ for --emit json: │
│ json or msgpack. │
│ [default: json] │
│ --emit [json|neo4j|sche Output target: │
│ ma] json │
│ (analysis.json, │
│ default) | neo4j │
│ (graph.cypher or │
│ live Bolt push) │
│ | schema (the │
│ Neo4j │
│ schema.json │
│ contract). │
│ [default: json] │
│ --app-name TEXT Logical │
│ application name │
│ for the graph │
│ :PyApplication │
│ anchor (default: │
│ input dir name). │
│ --neo4j-uri TEXT Push the graph │
│ to a live Neo4j │
│ over Bolt │
│ (incremental); │
│ omit to write │
│ graph.cypher. │
│ [env var: │
│ NEO4J_URI] │
│ --neo4j-user TEXT Neo4j username. │
│ [env var: │
│ NEO4J_USERNAME] │
│ [default: neo4j] │
│ --neo4j-password TEXT Neo4j password. │
│ Prefer the env │
│ var over the │
│ flag (the flag │
│ is visible in │
│ shell history / │
│ process list). │
│ [env var: │
│ NEO4J_PASSWORD] │
│ [default: neo4j] │
│ --neo4j-database TEXT Neo4j database │
│ name (default: │
│ server default). │
│ [env var: │
│ NEO4J_DATABASE] │
│ --analysis-level -a INTEGER RANGE Analysis depth: │
│ [1<=x<=4] 1=symbol │
│ table+Jedi call │
│ graph, 2=+PyCG │
│ call graph, │
│ 3=+native │
│ intraprocedural │
│ dataflow │
│ (CFG/PDG), │
│ 4=+interprocedu… │
│ SDG │
│ (param/summary │
│ edges, │
│ alias-aware │
│ DDG). │
│ [default: 1] │
│ --graphs TEXT Level 3+ only: │
│ comma-separated │
│ program-graph │
│ sections to emit │
│ (cfg, dfg, pdg, │
│ sdg). Default: │
│ cfg,dfg,pdg. │
│ `dfg` emits the │
│ PDG's data edges │
│ only; `sdg` │
│ requires -a 4. │
│ [default: │
│ cfg,dfg,pdg] │
│ --graph-field-de… INTEGER RANGE Level 3 only: │
│ [x>=1] k-limit on │
│ access-path │
│ depth (x.f.g.h │
│ with k=3 becomes │
│ x.f.g.*). │
│ Mandatory bound │
│ — it is what │
│ guarantees the │
│ interprocedural │
│ fixpoint │
│ terminates. │
│ [default: 3] │
│ --ray --no-ray Enable Ray for │
│ distributed │
│ analysis. │
│ [default: │
│ no-ray] │
│ --eager --lazy Enable eager or │
│ lazy analysis. │
│ Defaults to │
│ lazy. │
│ [default: lazy] │
│ --skip-tests --include-tests Skip test files │
│ in analysis. │
│ [default: │
│ skip-tests] │
│ --no-venv --venv Skip virtualenv │
│ creation and │
│ dependency │
│ installation; │
│ resolve imports │
│ against the │
│ ambient Python │
│ environment │
│ instead. │
│ [default: venv] │
│ --file-name PATH Analyze only the │
│ specified file │
│ (relative to │
│ input │
│ directory). │
│ --cache-dir -c PATH Directory to │
│ store analysis │
│ cache. Defaults │
│ to │
│ '.codeanalyzer' │
│ in the input │
│ directory. │
│ --clear-cache --keep-cache Clear cache │
│ after analysis. │
│ By default, │
│ cache is │
│ retained. │
│ [default: │
│ keep-cache] │
│ -v INTEGER Increase │
│ verbosity: -v, │
│ -vv, -vvv │
│ [default: 0] │
│ --pycg-shard --no-pycg-shard Shard PyCG │
│ call-graph │
│ analysis by │
│ Python package │
│ (level 2 only). │
│ When the project │
│ exceeds the │
│ 500-file │
│ ceiling, PyCG is │
│ run │
│ independently │
│ per top-level │
│ package with │
│ cross-package │
│ imports treated │
│ as ghost nodes. │
│ Without this │
│ flag, projects │
│ over the ceiling │
│ fall back to │
│ Jedi-only edges. │
│ [default: │
│ no-pycg-shard] │
│ --pycg-shard-cei… INTEGER RANGE Maximum files │
│ [x>=1] per shard when │
│ --pycg-shard is │
│ active (default │
│ 100). Shards │
│ exceeding this │
│ limit are │
│ skipped; their │
│ call edges are │
│ omitted from the │
│ call graph (Jedi │
│ edges for those │
│ packages are │
│ still included). │
│ Lower values are │
│ safer for │
│ packages with │
│ deep class │
│ hierarchies or │
│ heavy import │
│ graphs. │
│ [default: 100] │
│ --pycg-shard-tim… INTEGER RANGE Per-shard │
│ [x>=0] wall-clock │
│ timeout in │
│ seconds when │
│ --pycg-shard is │
│ active (default │
│ 120). A shard │
│ that exceeds │
│ this limit is │
│ skipped │
│ gracefully. │
│ PyCG's fixpoint │
│ is bimodal: it │
│ either converges │
│ quickly or │
│ diverges │
│ indefinitely, so │
│ the timeout acts │
│ as a final │
│ safety net after │
│ the file-count │
│ ceiling. Set to │
│ 0 to disable. │
│ POSIX only │
│ (macOS / Linux); │
│ ignored on │
│ Windows. │
│ [default: 120] │
│ --pycg-shard-str… [jedi|package] How --pycg-shard │
│ groups files │
│ (level 2 only). │
│ 'jedi' (default) │
│ partitions the │
│ Jedi │
│ module-dependen… │
│ graph (SCC + │
│ Louvain) so │
│ tightly-coupled │
│ modules │
│ co-compute and │
│ few call edges │
│ are severed │
│ between shards; │
│ import cycles │
│ are never split. │
│ 'package' uses │
│ the legacy │
│ one-shard-per-p… │
│ grouping. │
│ [default: jedi] │
│ --pycg-max-iter INTEGER RANGE Cap on PyCG's │
│ [x>=-1] fixpoint passes │
│ per │
│ shard/project │
│ (level 2; │
│ default 50). │
│ PyCG iterates │
│ until its │
│ points-to state │
│ stops changing, │
│ but its │
│ access-path │
│ domain has no │
│ convergence │
│ bound, so heavy │
│ metaclass/mixin │
│ code (e.g. an │
│ ORM) can loop │
│ with each pass │
│ costing seconds. │
│ The cap returns │
│ a │
│ sound-but-incom… │
│ call graph │
│ instead of │
│ looping until │
│ the timeout │
│ kills it. Set to │
│ -1 for PyCG's │
│ unbounded │
│ run-to-converge… │
│ behaviour. │
│ [default: 50] │
│ --help Show this │
│ message and │
│ exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
-
Basic analysis to stdout, or to a file:
canpy --input ./my-python-project # compact JSON on stdout canpy --input ./my-python-project --output ./out # → ./out/analysis.json
-
Binary output (msgpack):
canpy --input ./my-python-project --output ./out --format msgpack # → ./out/analysis.msgpack -
Enrich the call graph with PyCG (level 2):
canpy --input ./my-python-project -a 2
Level 1 edges come from Jedi's lexical resolution.
-a 2runs PyCG and merges its flow-sensitive edges in (RPC / third-party / dynamically-dispatched targets), backfilling callees Jedi could not resolve. Every edge is provenance-tagged (e.g.jedi,pycg). -
Emit a Neo4j snapshot, or push to a live database:
canpy --input ./my-python-project --emit neo4j --output ./out # → ./out/graph.cypher canpy --input ./my-python-project --emit neo4j \ --neo4j-uri bolt://localhost:7687 --neo4j-user neo4j --neo4j-password secret -
Emit the Neo4j schema contract:
canpy --emit schema # print schema.json to stdout (no project needed) canpy --emit schema --output ./out # → ./out/schema.json
-
Force a clean rebuild with a custom cache directory:
canpy --input ./my-python-project --eager --cache-dir /path/to/custom-cache
-
Dataflow graphs — intraprocedural (level 3) and interprocedural (level 4):
canpy --input ./my-python-project -a 3 --output ./out # per-callable cfg/cdg/ddg canpy --input ./my-python-project -a 4 --output ./out # + interprocedural SDG canpy --input ./my-python-project -a 3 --graphs cfg,pdg # scope the emitted sections canpy --input ./my-python-project -a 4 --graphs sdg # sdg requires -a 4 canpy --input ./my-python-project -a 3 --graph-field-depth 2 # tighter access-path k-limit
Levels 3 and 4 also enrich the Neo4j projection (
--emit neo4j) with the CPG overlay (:PyCFGNodenodes wired byPY_CFG_NEXT/PY_CDG/PY_DDG, plus the level-4PY_PARAM_IN/PY_PARAM_OUT/PY_SUMMARYedges — the cross-language dataflow vocabulary, PY_-namespaced like every other row family so multi-language databases never mingle analyzers' edges).
Each level is the same tree grown one layer deeper, plus the edge family over that new layer. The
levels are cumulative and additive — analysis.json(-a 1) ⊆ … ⊆ analysis.json(-a 4).
| Level | Flag | What it adds | Where it lands |
|---|---|---|---|
| 1 | -a 1 (default) |
Symbol table, Jedi call graph, and call nodes in each callable's body |
body calls (callee: null) |
| 2 | -a 2 |
PyCG call-graph enrichment; each call's callee backfilled to a can:// id |
call_graph, body callees |
| 3 | -a 3 |
Native intraprocedural CFG/CDG/DDG (syntactic, name-equality, prov: ["ssa"]) |
cfg, cdg, ddg, @entry/@exit on each callable |
| 4 | -a 4 |
Interprocedural SDG: synthetic param vertices, alias-aware DDG (prov: ["points-to"]) |
param_in, param_out, summary, semantic ddg |
-a 1/-a 2 timings and output are unaffected by the heavier levels — nothing at level 3+ runs
unless requested. Flag gating: --graphs sdg requires -a 4; --graphs cfg,dfg,pdg and
--graph-field-depth require -a 3.
The dataflow substrate is hand-built from the standard library so every graph node joins back to a symbol-table signature by construction (#67):
- CFG source: a hand-built exceptional control-flow graph from the stdlib
astmodule — the same parse the symbol-table builder uses. One synthetic@entry/@exitper callable, statement-level nodes keyedline:colin source order, with exception /yield/awaitedges first-class. - Def-use source: hand-built reaching definitions (a classic forward worklist) over
k-limited access paths (
--graph-field-depth, default 3) — there is no usable SSA library for Python. This yields the level-3 syntactic DDG (name-equality,prov: ["ssa"]). - Points-to oracle (level 4): the Scalpel may-alias oracle —
ScalpelAliasOracle(codeanalyzer/dataflow/scalpel_oracle.py) — consumes Scalpel's SSA copy/const facts to answermay_alias(path_a, path_b), adding the alias-aware DDG edges (prov: ["points-to"]) and the interprocedural summaries.python-scalpelis an optional dependency (pip install 'codeanalyzer-python[scalpel]'); when it is absent or cannot resolve a construct, the analyzer automatically falls back to the built-inTypeBasedAliasOracle(Jedi-inferred types; unknown types conservatively alias), keeping themay_aliasinterface total. Call dispatch comes from the merged Jedi(+PyCG) call graph, treated as a frozen oracle. - Summaries: relational formal-in → formal-out flows composed bottom-up over the Tarjan SCC condensation of the call graph, a monotone fixpoint within SCCs; globals ride as extra formals, closure captures bind at definition sites.
- Slicing and taint are the SDK's responsibility. A backward slicer ships in-process
(
codeanalyzer.dataflow.slicing), but only as an internal validation utility for the L3/L4 gates — it is not a product surface. Once the SDG is emitted, slicing and taint become language-independent labeled reachability and belong to the CLDK SDK across the provider/client boundary; the analyzer emits thesummarysubstrate and notaint_flowssection. - Precision posture: sound-leaning and over-approximate — prefer false positives to missed
flows. Known unsoundness (documented, not silently absorbed):
eval/exec, reflection (getattr/setattrwith dynamic names), monkey-patching, C extensions,importside effects, and module top-level statements (globals are modeled as formals instead).
Every run produces the same envelope — an Analysis document — regardless of level; deeper levels
just populate more of the same tree:
A callable (function or method) carries its own CPG, keyed by node id:
{
"id": "can://…/main(a)", "kind": "function",
"span": { "start": [5, 0], "end": [7, 12], "bytes": [43, 86] }, // byte offsets into module.source
"body": { // node id → node
"@entry": { "kind": "entry" },
"6:4": { "kind": "statement", "span": { … } },
"6:8": { "kind": "call", "span": { … }, "callee": "can://…/helper(x)" }, // callee null until L2
"@formal_in:0": { "kind": "formal_in", "of": "a" }, // L4 param vertices
"6:4/actual_in:0": { "kind": "actual_in", "of": "a", "parent": "6:4" },
"@exit": { "kind": "exit" }
},
"cfg": [ { "src": "@entry", "dst": "6:4", "kind": "fallthrough" } ], // L3
"cdg": [ { "src": "@entry", "dst": "6:4" } ], // L3
"ddg": [ { "src": "6:4", "dst": "7:4", "var": "h", "prov": ["ssa"] } ], // L3 ssa / L4 points-to
"summary": [ { "src": "6:4/actual_in:0", "dst": "6:4/actual_out" } ] // L4
}Notable properties:
- Durable
can://ids identify every node at callable granularity and above (can://python/<app>/<file>/<callable-sig>); nodes below a callable use ordinal ids (@entry,@exit,line:col,@formal_in:N,line:col/actual_in:N). sourcelives once per module; every node's text is themodule.source[span.bytes]slice.- Cross-function edges —
call_graph,param_in,param_out— live at application scope; the intraproceduralcfg/cdg/ddgand thesummaryedges live on the callable. - No dangling endpoints — every
call_graphsrc/dstjoins the id space: declared callables by their tree id, imported/builtin targets by a…/@external/<module>/<name>id homed inapplication.external_symbols. - Breaking change from v1: there is no more flat top-level
symbol_table/call_graph, and no separate program-graphs section. Everything now hangs offapplication, and the dataflow graphs are inlined on each callable. Readanalysis.application.symbol_table(wasanalysis.symbol_table) andanalysis.application.call_graph(wasanalysis.call_graph).
canpy builds one analysis in memory and can emit it three ways (--emit):
The Analysis envelope described above. By default it is printed to stdout as JSON; with --output
it is written to analysis.json (or analysis.msgpack with --format msgpack, a more compact
binary format).
--emit neo4j projects the same schema v2.0.0 analysis into a labeled property graph. Every node
label is Py-prefixed and every relationship type is PY_-prefixed (e.g. :PyClass, PY_CALLS)
so multiple language analyzers can share one database without label or relationship-type collisions.
Declarations are keyed by their can:// id under a shared :PySymbol label; calls, imports,
inheritance, decorators, and call sites are relationships. At -a 3/-a 4 the projection gains the
CPG overlay — :PyCFGNode nodes (statements, and at level 4 the parameter vertices) wired by
PY_CFG_NEXT/PY_CDG/PY_DDG, plus the level-4 PY_PARAM_IN/PY_PARAM_OUT/PY_SUMMARY edges:
- Without
--neo4j-uri— writes a self-containedgraph.cypher(constraints + indexes, a scoped wipe, then batchedMERGEs). Load it withcypher-shell < graph.cypher. Needs no extra dependencies. - With
--neo4j-uri— pushes to a live Neo4j over Bolt incrementally: only modules whose content hash changed are rewritten, and on a full run modules whose source file vanished are pruned. Requires theneo4jextra. Every graph carries aschema_versionon its:PyApplicationnode.
Call-graph endpoints that aren't present in the symbol table (third-party / framework / RPC targets)
are materialized as :PyExternal ghost nodes, mirroring the analyzer's own ghost-node behaviour.
The connection options also read from the standard Neo4j environment variables — NEO4J_URI,
NEO4J_USERNAME, NEO4J_PASSWORD, NEO4J_DATABASE — when the corresponding flag is omitted (an
explicit flag wins). Prefer the env var for the password so it doesn't land in shell history or the
process list:
export NEO4J_URI=bolt://localhost:7687
export NEO4J_PASSWORD=secret
canpy -i ./my-project --emit neo4j # credentials picked up from the environment--emit schema writes the machine-readable, version-stamped Neo4j schema (schema.json: node labels,
relationships, properties, constraints, and indexes; currently schema_version 2.0.0). It needs no
project and is checked into the repo as schema.neo4j.json and bundled in every release as a GitHub
Release asset, so a consumer can validate producer/consumer compatibility without invoking the tool.
The shape of the contract matches the
codeanalyzer-typescript backend.
A UML of the analysis.json schema (the PyApplication containment tree) is checked in as
schema-uml.drawio, and the property-graph schema as
neo4j-schema.drawio.
This project uses uv.
uv sync --all-groups
uv run canpy --input /path/to/project # run from source
uv run canpy --emit schema > schema.neo4j.json # regenerate the checked-in schema contract
uv run python scripts/update_readme.py # regenerate the canpy --help block above
uv run pytest # run the test suiteThe Neo4j schema-conformance test always runs. The Neo4j bolt integration test spins up a real Neo4j via Testcontainers and is opt-in — it needs a container runtime (Docker or Podman) and is enabled with an environment variable:
RUN_CONTAINER_TESTS=1 uv run pytest test/test_neo4j_bolt.py -sApache 2.0 — see LICENSE.
{ "schema_version": "2.0.0", "language": "python", "max_level": 4, // the level this run was produced at "k_limit": 3, // access-path depth bound (--graph-field-depth); L3+ only "analyzer": { "name": "codeanalyzer-python", "version": "1.0.0" }, "application": { "id": "can://python/<app>", "kind": "application", "symbol_table": { // relative POSIX path → module "pkg/mod.py": { "id": "can://python/<app>/pkg/mod.py", "kind": "module", "source": "…full file text, stored once per module…", "types": { "<Class>": { "id": "…", "kind": "class", "callables": { /* methods */ } } }, "functions": { "<sig>": { /* callable, see below */ } } } }, "call_graph": [ { "src": "can://…/main(a)", "dst": "can://…/helper(x)", "weight": 1, "prov": ["jedi", "pycg"] } ], "external_symbols": { // imported/builtin call targets, keyed by id "can://python/<app>/@external/os/getcwd": { "id": "can://python/<app>/@external/os/getcwd", "kind": "external", "name": "getcwd", "module": "os" } }, "param_in": [ { "src": "can://…/main(a)@6:4/actual_in:0", "dst": "can://…/helper(x)@formal_in:0" } ], "param_out": [ { "src": "can://…/helper(x)@formal_out", "dst": "can://…/main(a)@6:4/actual_out" } ] } }