Skip to main content

spanda

A thin arc of light and its reflection meeting at two points on still water

CI License: MIT Python 3.9+ uv

Find out who calls a function — and be told, plainly, when nobody can know.

Every static analyser has a blind spot. Ask most of them who calls an event hook, a route handler, or anything reached through getattr, and you get 0 callers — which reads as safe to delete. It isn't. It means the tool couldn't see.

spanda reports that difference:

$ spanda callers . _apply_rls_context

models.py:57  function _apply_rls_context

  no static callers found

  ...but this symbol is dispatched at runtime. Whatever calls it is not
     visible in the source, so the count above is not the whole story.

That is the whole idea. Everything else is in service of it.

No LLM calls, no network, no telemetry. Zero runtime dependencies — standard library only. Same input, same output, every time.

Install

uv tool install spanda-graph      # recommended: a standalone `spanda` on your PATH
pip install spanda-graph          # or into the current environment

The published distribution is spanda-graph; the command and the import package are both spanda.

Other ways to install
uvx --from spanda-graph spanda gaps .        # try it without installing anything
uv add spanda-graph                          # as a project dependency
uv tool install git+https://github.com/pr0digy91/spanda    # from source, no release needed
pip install git+https://github.com/pr0digy91/spanda

To hack on it:

git clone https://github.com/pr0digy91/spanda && cd spanda
uv sync --all-extras && uv run spanda --help

Run it on the newest Python you have. A parser cannot read syntax from a release later than its own, so an old interpreter records valid files as syntax errors. 3.9 is the supported floor, not the version to use — and on macOS the built-in python3 is usually well behind. uv tool install and uvx fetch a current interpreter for you.

Using it, in order

1. Index the codebase

spanda index .

Parses everything, stores it at .spanda/index.db inside the codebase itself, and audits its own work: every name brought in by an import is traced to its definition, and anything it could not place is reported rather than dropped. Safe to re-run — symbols keep their identity across scans.

2. Find out what it cannot see

spanda gaps .

Read this before you trust anything else. It is the map of the blind spot, grouped by why each symbol is invisible:

Decorated with something that dispatches at runtime — the framework calls these,
  and no reference in this codebase names them:

  models.py:57
      _apply_rls_context
      @event.listens_for(Session, 'before_flush')
  (4)

Decorated with something on neither list, and nothing names them. Not a claim
  that a framework calls these — a statement that spanda does not know. Vet, then
  add a line to dynamic_dispatch.txt either way:

  middleware.py:45
      nightly_cleanup
      @scheduler.scheduled_job('cron', hour=3)
  (1)

String literals that spell the name of a symbol defined elsewhere (heuristic —
  a name match is not a call, and must never become an edge):

  dynamic.py:14
      "on_created" names a symbol defined at handlers.py:9
  (3)

That last group is the one that makes dead-code detection dangerous: handlers.on_created is called at runtime through a string in a dispatch table. Nothing in the source names it. spanda will not call it dead, and will not call it alive — it tells you where to look.

Add --unreferenced to also list symbols nothing references, split by whether the silence is explained.

3. Ask about a specific symbol

spanda callers . create_invoice

Gives you the callers it can prove, plus anything that might call it but cannot be proven to — with the reason attached, as in the example at the top.

4. Record what a person decides

Static analysis eventually runs out. When it does, someone looks at the symbol and decides — and that decision belongs in the index, not in memory:

spanda vet . --alive "tasks.py::nightly_cleanup" --note "APScheduler, see config/jobs.py"
spanda vet .

Re-running spanda vet checks every recorded decision against the newest scan: which verdicts the code now contradicts, which patterns the alive ones imply (a decorator vetted alive three times belongs in dynamic_dispatch.txt), and what to look at next. --export and --from move verdicts between indexes.

What it refuses to guess

Four shapes make a caller invisible to any reader of the source. Each is reported as such, never as an absence:

shape example what spanda says
Framework dispatch @app.post(...), @event.listens_for(...) dispatched at runtime; callers not in the source
Runtime name assembly getattr(mod, name), a dict of handler strings the call site is certain, the target is not
Inheritance from an absent base overriding a method from an installed library maybe inherited, not absent
Attribute on an unknown type x.method() where x has no annotation unresolved, with the reason attached

A heuristic stays labelled a heuristic and never becomes an edge in the graph. Framework knowledge lives in dynamic_dispatch.txt as configuration, one glob per line — and spanda parse ends with a census of every decorator your codebase actually uses, so you grow that file from evidence rather than memory.

If you find a symbol reported with no callers that something really does call, please file it. That is the exact failure this project exists to eliminate.

Other questions it answers

command what it answers
spanda loops . where the loops are — including nesting that spans a function call, and database calls inside them
spanda profile . what the code keeps doing: re-implemented names, annotation rates, churn
spanda drift . what changed between two scans
spanda backfill . --last 10 replay past commits, so drift has real history to read today
spanda imports . which file each import points at, and what imports circularly
spanda find . "Order*" look up symbols by name
spanda scans . every run, with its timestamp, commit and fingerprint
spanda guide . --write a note on reading this index, with that index's own numbers in it
spanda parse . --out out/ one inspectable JSON record per source file, storing nothing
spanda resolve . --reasons 3 link every reference to a definition, listing the failures

spanda loops reads nesting off the call graph, not just out of one file — a one-loop function called from another loop is three deep, and no single file shows that. Every line is a place to look, never a score: spanda computes no complexity number, because a number invites a threshold and a threshold invites gaming.

What it does not do

  • No LLM calls, no network, no telemetry. This is the deterministic layer. A description layer would consume its output, not live inside it.
  • Python only, and no framework-specific parsing. Framework knowledge enters as configuration, never as code.
  • No type inference. Annotations are used where the code provides them; where it does not, the reference is reported unresolved rather than guessed.
  • No refactoring. spanda reads; it never edits your code.
  • No quality score or grade.
Notes on the index
  • Lives at <path>/.spanda/index.db, inside the codebase it describes — one authoritative index per codebase. .spanda/ ignores itself with a .gitignore of *: an index is derived data and never belongs in a commit.
  • Re-indexing is incremental in a git repository — only what changed is re-read, which is the difference between 52 seconds and 12 minutes over 425 commits.
  • Memory depends on the largest file, not the codebase. Indexing streams one file at a time: 680 files index in 52 MB and about 3 seconds.
  • A syntax error is recorded, not fatal. The file is marked unparseable with the interpreter's message, and the run completes.
  • History lives in the index, not in filenames. spanda scans lists every run with its timestamp, commit and content fingerprint.
  • Nothing is async, deliberately: parsing is CPU-bound and never waits, so async would add machinery and no speed.

Contributing

Bug reports and pull requests are welcome — see CONTRIBUTING.md.

License

MIT — see LICENSE.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

spanda_graph-0.1.0.tar.gz (127.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

spanda_graph-0.1.0-py3-none-any.whl (98.6 kB view details)

Uploaded Python 3

File details

Details for the file spanda_graph-0.1.0.tar.gz.

File metadata

  • Download URL: spanda_graph-0.1.0.tar.gz
  • Upload date:
  • Size: 127.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for spanda_graph-0.1.0.tar.gz
Algorithm Hash digest
SHA256 1b26f9779a4b5fe16e293be56c908ee5e5793a03cca03c90e62b447a2b064310
MD5 7b7e344d5cc92e4b763ec5fab5d9a624
BLAKE2b-256 ed633a7dddcc36e685579a29f038b453b4af84c495ae7dc0a6b5bed43f2d6ea0

See more details on using hashes here.

Provenance

The following attestation bundles were made for spanda_graph-0.1.0.tar.gz:

Publisher: publish.yml on pr0digy91/spanda

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file spanda_graph-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: spanda_graph-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 98.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for spanda_graph-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 498ff8f9bd969df4c23f5795c533e54039085eba3a64eb0803b890cee442111d
MD5 e79724eade33106e8e4857b4ce40f4d3
BLAKE2b-256 b4e028e4fe716a60ef2cd642f7b6d76f266343ed7cd0e1557b49fa4048770999

See more details on using hashes here.

Provenance

The following attestation bundles were made for spanda_graph-0.1.0-py3-none-any.whl:

Publisher: publish.yml on pr0digy91/spanda

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.2.0

2 files

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page