spanda
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
python3is usually well behind.uv tool installanduvxfetch 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.gitignoreof*: 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 scanslists 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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1b26f9779a4b5fe16e293be56c908ee5e5793a03cca03c90e62b447a2b064310
|
|
| MD5 |
7b7e344d5cc92e4b763ec5fab5d9a624
|
|
| BLAKE2b-256 |
ed633a7dddcc36e685579a29f038b453b4af84c495ae7dc0a6b5bed43f2d6ea0
|
Provenance
The following attestation bundles were made for spanda_graph-0.1.0.tar.gz:
Publisher:
publish.yml on pr0digy91/spanda
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
spanda_graph-0.1.0.tar.gz -
Subject digest:
1b26f9779a4b5fe16e293be56c908ee5e5793a03cca03c90e62b447a2b064310 - Sigstore transparency entry: 2683891030
- Sigstore integration time:
-
Permalink:
pr0digy91/spanda@e8d4696192bd0b575150c2b6be5c31559837c656 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/pr0digy91
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@e8d4696192bd0b575150c2b6be5c31559837c656 -
Trigger Event:
release
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
498ff8f9bd969df4c23f5795c533e54039085eba3a64eb0803b890cee442111d
|
|
| MD5 |
e79724eade33106e8e4857b4ce40f4d3
|
|
| BLAKE2b-256 |
b4e028e4fe716a60ef2cd642f7b6d76f266343ed7cd0e1557b49fa4048770999
|
Provenance
The following attestation bundles were made for spanda_graph-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on pr0digy91/spanda
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
spanda_graph-0.1.0-py3-none-any.whl -
Subject digest:
498ff8f9bd969df4c23f5795c533e54039085eba3a64eb0803b890cee442111d - Sigstore transparency entry: 2683891069
- Sigstore integration time:
-
Permalink:
pr0digy91/spanda@e8d4696192bd0b575150c2b6be5c31559837c656 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/pr0digy91
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@e8d4696192bd0b575150c2b6be5c31559837c656 -
Trigger Event:
release
-
Statement type: