I've been bitten by the same bug more times than I'd like to admit. I change a "small" helper in a Python codebase, the tests I thought to run pass, and two days later something three modules away breaks because it called that helper in a way I never knew about.
grep helps a little, but it tells you where a name appears, not what depends on it. A name like charge or total shows up in comments, strings, unrelated classes and dead code. What I actually wanted to know before touching a function was:
- Who calls this directly, and from which line?
- Who calls those callers, a couple of hops out?
- Which tests actually reach this code, so I can run those first?
So I wrote a small CLI that answers exactly that. It's called blastradius, it's MIT licensed, and it has no dependencies.
Install
pip install git+https://github.com/enhanciar/blastradius
You need Python 3.10+. It needs no API key and makes no network calls. Everything runs locally on your source code.
A tiny example
Say you have this layout. It's a simplified version of the tests/fixtures/shop fixture in the repo, and the output below comes from that fixture, so the line numbers differ slightly from the snippet:
# shop/money.py
def apply_tax(amount, rate=0.18):
return round(amount * (1 + rate), 2)
# shop/billing.py
from shop.money import apply_tax
class Invoice:
def total(self):
return apply_tax(sum(self.lines))
def charge(invoice):
return gateway.pay(invoice.total())
# shop/api.py
from shop.billing import charge
def checkout(cart):
return charge(cart.invoice)
# tests/test_billing.py
def test_charge():
...
You want to change how apply_tax rounds. Run:
blastradius . shop/money.py::apply_tax
blastradius: 6 files, 11 definitions, 13 edges (0.00s)
Target: shop/money.py::apply_tax
defined at shop/money.py:5
Direct callers (1)
shop/billing.py:12 Invoice.total -> apply_tax
Transitive dependents (3, up to 3 hops)
hop 2 shop/billing.py:15 charge (via Invoice.total <- apply_tax)
hop 3 shop/api.py:4 checkout (via charge <- Invoice.total)
hop 3 tests/test_billing.py:4 test_charge (via charge <- Invoice.total) [test]
Affected tests (1)
tests/test_billing.py:4 test_charge
Summary: 1 direct, 3 transitive, 3 other files (2 non-test), 1 tests
That's the whole point. Your rounding change reaches checkout in your API layer, and test_charge is the test to run.
On a real repo
On psf/requests, asking about get_netrc_auth with --depth 2 parses 37 files and 787 definitions in about 0.08 seconds. It finds 5 direct callers, 14 transitive dependents (including Session.request), and 15 test functions that reach it. That's a much better list than "run everything and hope".
Useful flags
blastradius <repo> <target> [--depth N] [--json] [--exclude PATH] [--all]
- The target can be a bare name (
charge), a method (Invoice.total), a qualified name (src/billing.py::charge), or a whole file. -
--depthsets how many hops out to walk. The default is 3. -
--jsongives machine-readable output for CI, editor plugins or coding agents. - The exit code is
0if the target is found and1if it isn't. When it isn't found you get close-match suggestions.
You can also use it as a library:
from blastradius import build_graph, compute_impact
g = build_graph("path/to/repo", exclude=["vendor"])
report = compute_impact(g, "billing.py::charge", depth=3)
How it works
It walks every .py file and parses it with the standard-library ast module. It records definitions, calls and imports (including relative imports and aliases), then resolves each call in a fixed order:
-
self.x()on the enclosing class - explicit imports, then
module.x() - the same file
- a name that's unique in the repo
The key design choice is this: if a call is ambiguous, it is dropped, not guessed. I'd rather the graph miss an edge than invent one, because a fake caller wastes your time and erodes trust in the tool.
Then it does a breadth-first walk backwards from your target.
Limits (please read)
This is static analysis, so read the output as a strong hint, not proof.
- Python only for now.
-
Dynamic calls are invisible. That covers
getattr, callbacks passed as values, dependency injection, decorators that swap functions, and string dispatch. -
Method calls on objects of unknown type (
obj.save()) only resolve when the name is unique or imported. Common names likegetandrunare often dropped. - Inheritance isn't followed yet.
- Tests only count if they reach the target through the call graph. Tests that go over HTTP or only through fixtures won't show up.
Why I built it
I'm building a bigger product (Enhanciar, a company brain that answers questions with links to sources), and "what does this change affect?" kept coming up. This piece is useful on its own, so I pulled it out and open-sourced it.
If you try it on your codebase, I'd love to hear where it got something wrong. Missed edges are the most useful bug reports. The repo is https://github.com/enhanciar/blastradius.
What do you use today before a risky refactor?

Top comments (0)