DEV Community

Cover image for How I find what breaks before I change a Python function
Enhanciar AI
Enhanciar AI

Posted on

How I find what breaks before I change a Python function

blastradius demo

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:

  1. Who calls this directly, and from which line?
  2. Who calls those callers, a couple of hops out?
  3. 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
Enter fullscreen mode Exit fullscreen mode

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():
    ...
Enter fullscreen mode Exit fullscreen mode

You want to change how apply_tax rounds. Run:

blastradius . shop/money.py::apply_tax
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode
  • The target can be a bare name (charge), a method (Invoice.total), a qualified name (src/billing.py::charge), or a whole file.
  • --depth sets how many hops out to walk. The default is 3.
  • --json gives machine-readable output for CI, editor plugins or coding agents.
  • The exit code is 0 if the target is found and 1 if 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)
Enter fullscreen mode Exit fullscreen mode

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:

  1. self.x() on the enclosing class
  2. explicit imports, then module.x()
  3. the same file
  4. 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 like get and run are 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)