Skip to content

Latest commit

 

History

History
701 lines (616 loc) · 43.7 KB

File metadata and controls

701 lines (616 loc) · 43.7 KB
CodeLLM-DevKit

codeanalyzer-python (canpy)

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.

PyPI GitHub release Release License


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.

Table of Contents

Features

  • Canonical schema v2 — one additive Code Property Graph tree (schema_version 2.0.0), stamped with language, max_level, analyzer{name,version}, and (at L3+) k_limit, rooted at a single application node with durable can:// 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 source once.
  • 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 stdlib ast.
  • Neo4j output — project the analysis into a labeled property graph: a self-contained graph.cypher snapshot, or an incremental push to a live database over Bolt.
  • Versioned schema — a machine-readable, version-stamped Neo4j schema contract (--emit schema), checked in as schema.neo4j.json (2.0.0) and shipped with every release.
  • Incremental cache — per-file results are cached under .codeanalyzer; --lazy (default) reuses them, --eager forces a clean rebuild. --ray distributes the work across cores.
  • Compact output — canonical analysis.json, or binary analysis.msgpack for smaller artifacts.

Installation

Prerequisites

  • Python 3.10 or newer.

  • A C toolchain and the venv / development headers — the analyzer builds an isolated virtual environment per project (via Python's venv) 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

Install via pip (PyPI)

pip install codeanalyzer-python
canpy --help

For 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 via shell script

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 | sh

Install via Homebrew

brew install codellm-devkit/tap/codeanalyzer-python

The 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).

Build from source

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 --help

Usage

canpy --input /path/to/python/project

With 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.

Options

$ 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.            │
╰──────────────────────────────────────────────────────────────────────────────╯

Examples

  1. 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
  2. Binary output (msgpack):

    canpy --input ./my-python-project --output ./out --format msgpack   # → ./out/analysis.msgpack
  3. 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 2 runs 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).

  4. 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
  5. Emit the Neo4j schema contract:

    canpy --emit schema                   # print schema.json to stdout (no project needed)
    canpy --emit schema --output ./out    # → ./out/schema.json
  6. Force a clean rebuild with a custom cache directory:

    canpy --input ./my-python-project --eager --cache-dir /path/to/custom-cache
  7. 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 (:PyCFGNode nodes wired by PY_CFG_NEXT/PY_CDG/PY_DDG, plus the level-4 PY_PARAM_IN/PY_PARAM_OUT/PY_SUMMARY edges — the cross-language dataflow vocabulary, PY_-namespaced like every other row family so multi-language databases never mingle analyzers' edges).

Analysis levels

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.

Architecture & Tooling

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 ast module — the same parse the symbol-table builder uses. One synthetic @entry/@exit per callable, statement-level nodes keyed line:col in source order, with exception / yield / await edges 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 answer may_alias(path_a, path_b), adding the alias-aware DDG edges (prov: ["points-to"]) and the interprocedural summaries. python-scalpel is 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-in TypeBasedAliasOracle (Jedi-inferred types; unknown types conservatively alias), keeping the may_alias interface 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 the summary substrate and no taint_flows section.
  • Precision posture: sound-leaning and over-approximate — prefer false positives to missed flows. Known unsoundness (documented, not silently absorbed): eval/exec, reflection (getattr/setattr with dynamic names), monkey-patching, C extensions, import side effects, and module top-level statements (globals are modeled as formals instead).

Output shape (canonical schema v2)

Every run produces the same envelope — an Analysis document — regardless of level; deeper levels just populate more of the same tree:

{
  "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" } ]
  }
}

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).
  • source lives once per module; every node's text is the module.source[span.bytes] slice.
  • Cross-function edgescall_graph, param_in, param_out — live at application scope; the intraprocedural cfg/cdg/ddg and the summary edges live on the callable.
  • No dangling endpoints — every call_graph src/dst joins the id space: declared callables by their tree id, imported/builtin targets by a …/@external/<module>/<name> id homed in application.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 off application, and the dataflow graphs are inlined on each callable. Read analysis.application.symbol_table (was analysis.symbol_table) and analysis.application.call_graph (was analysis.call_graph).

Output targets

canpy builds one analysis in memory and can emit it three ways (--emit):

analysis.json (default)

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).

Neo4j graph

--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-contained graph.cypher (constraints + indexes, a scoped wipe, then batched MERGEs). Load it with cypher-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 the neo4j extra. Every graph carries a schema_version on its :PyApplication node.

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

Schema contract

--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.

Development

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 suite

The 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 -s

License

Apache 2.0 — see LICENSE.