DEV Community

Dakota Huang
Dakota Huang

Posted on

Grep Misses Runtime Imports. Pin Edges Before One Delete.

Grep cannot prove a file is dead.
Pin runtime import edges before any delete.
Then apply the smallest safe change only.

Messy repos hide live modules behind dynamic imports.
Plugin loaders never mention the filename in source.
A delete still breaks a path your search missed.

This workflow pins three observables first.
It then allows one shim, not a rewrite.
Treat every code sample as an unexecuted proposal.

Why grep fails on messy trees

Filename search looks at static text only.
Dynamic loaders keep the module name in config.
Entry points keep the module name in packaging metadata.

importlib.import_module will still load that path.
pkgutil.walk_packages will still load that path.
Pytest plugins will still load that path.

Zero grep hits is not a delete license.
Zero grep hits means the measurement is missing.
Measure load edges under the real test command.

String-built names defeat repository search tools.
__import__(kind + '_utils') leaves no filename literal.
Setuptools entry points live outside your src tree.

Collection-time imports evade test-body grep as well.
conftest.py can import a module no test names.
A green suite can still depend on that file.

Three facts to pin

Pin three facts for one candidate file.

  1. Which module names resolve to that file.
  2. Which lines run during the existing suite.
  3. Which exception type appears if it vanishes.

Do not move the candidate file yet.
Do not rename any packages in this pass.
Do not clean sibling files in this pass.

Limit work to one file, one harness, one change.
Folder-wide cleanup hides the first broken import.
One path keeps the JSON small enough to read.

Artifact: import-edge and line-hit recorder

The recorder uses only the Python standard library.
It wraps __import__ and importlib.import_module.
It also installs a line tracer for one path.

Save it as tools/pin_import_edges.py.
Point the first argument at the suspect file.
Run it as the parent of your test driver.

# tools/pin_import_edges.py
# Proposal: characterization wrapper. Not production telemetry.
from __future__ import annotations

import importlib
import json
import runpy
import sys
import traceback
from builtins import __import__ as real_import
from importlib import import_module as real_import_module
from pathlib import Path

CANDIDATE = Path(sys.argv[1]).resolve()
OUT = Path(sys.argv[2]).resolve()
CHILD_ARGV = sys.argv[3:]

edges: list[dict[str, str]] = []
line_hits: set[int] = set()


def _matches(mod) -> bool:
    filename = getattr(mod, '__file__', None)
    if not filename:
        return False
    try:
        return Path(filename).resolve() == CANDIDATE
    except OSError:
        return False


def _note(reason: str, mod) -> None:
    if not _matches(mod):
        return
    edges.append(
        {
            'reason': reason,
            'module': getattr(mod, '__name__', '?'),
            'file': str(Path(mod.__file__).resolve()),
        }
    )


def hooked_import(name, globals=None, locals=None, fromlist=(), level=0):
    mod = real_import(name, globals, locals, fromlist, level)
    _note('__import__:' + name, mod)
    if fromlist and fromlist != ('*',):
        pkg = sys.modules.get(name)
        if pkg is not None:
            _note('from:' + name, pkg)
    return mod


def hooked_import_module(name, package=None):
    mod = real_import_module(name, package)
    _note('import_module:' + name, mod)
    return mod


def tracer(frame, event, arg):
    if event != 'line':
        return tracer
    filename = frame.f_code.co_filename
    try:
        resolved = Path(filename).resolve()
    except OSError:
        return tracer
    if resolved == CANDIDATE:
        line_hits.add(int(frame.f_lineno))
    return tracer


def main() -> None:
    if len(CHILD_ARGV) < 1:
        raise SystemExit('usage: pin_import_edges.py FILE OUT.json DRIVER.py [args...]')

    import builtins

    builtins.__import__ = hooked_import
    importlib.import_module = hooked_import_module
    sys.settrace(tracer)

    rc = 0
    err = None
    try:
        sys.argv = CHILD_ARGV
        runpy.run_path(CHILD_ARGV[0], run_name='__main__')
    except SystemExit as exc:
        code = exc.code
        if code is None:
            rc = 0
        elif isinstance(code, int):
            rc = code
        else:
            rc = 1
    except Exception as exc:
        rc = 1
        err = {
            'type': type(exc).__name__,
            'msg': str(exc),
            'tail': traceback.format_exc().splitlines()[-8:],
        }
    finally:
        sys.settrace(None)

    payload = {
        'candidate': str(CANDIDATE),
        'child': CHILD_ARGV,
        'returncode': rc,
        'edge_count': len(edges),
        'unique_modules': sorted({row['module'] for row in edges}),
        'edges': edges[:50],
        'line_hits': sorted(line_hits),
        'line_hit_count': len(line_hits),
        'error': err,
    }
    OUT.write_text(json.dumps(payload, indent=2), encoding='utf-8')
    raise SystemExit(rc)


if __name__ == '__main__':
    main()
Enter fullscreen mode Exit fullscreen mode

Label this runner as an unexecuted proposal.
Prefer a tiny pytest driver as the child process.
Keep the child as a real .py file.

# tools/run_pytest_driver.py
# Proposal: keep argv explicit for the wrapper.
import sys
import pytest

raise SystemExit(pytest.main(sys.argv[1:]))
Enter fullscreen mode Exit fullscreen mode

runpy.run_path needs a file path child.
A python -m pytest child will not match this wrapper.
Keep the driver boring so argv stays inspectable.

Step 1: Pick one candidate, not a folder

Choose a single path with a clear delete itch.
Do not pass a package directory to the harness.
Directories hide mixed live and dead modules.

Record the current git head as well.

git rev-parse HEAD
git status --porcelain src/legacy/report_utils.py
Enter fullscreen mode Exit fullscreen mode

Stop if the file already has local diffs.
Characterization needs a stable baseline tree.
Commit or stash first, then measure the path.

Name the candidate in the branch, not in chat.
fix/pin-report-utils-edges is searchable later.
A generic cleanup branch hides the measurement.

Step 2: Capture the live import edges

Run the wrapper under the same test command.
Do not switch Python versions for this capture.
Do not enable extra plugins for this capture.

Store JSON under a dedicated pins directory.

mkdir -p tmp/pins
python tools/pin_import_edges.py \
  src/legacy/report_utils.py \
  tmp/pins/report_utils.edges.json \
  tools/run_pytest_driver.py \
  -q tests
Enter fullscreen mode Exit fullscreen mode

Inspect edges and line hits as captured data.
Do not argue from memory about callers.
The JSON file is the only delete input.

Edge lists can contain duplicates from nested imports.
Count unique module values, not raw edge_count alone.
A unique set of zero is the only delete candidate.

python -c "import json; p=json.load(open('tmp/pins/report_utils.edges.json')); print(p['returncode'], p['edge_count'], p['unique_modules'], p['line_hit_count'])"
Enter fullscreen mode Exit fullscreen mode

Read reason fields before you trust a zero.
__import__ and import_module are different loaders.
A single loader gap can hide a live plugin path.

Step 3: Probe the missing-file failure

Move the candidate aside while git still tracks it.
Do not use rm for this probe.
A missing file should produce a typed failure.

mkdir -p tmp/held
git mv src/legacy/report_utils.py tmp/held/report_utils.py
python tools/run_pytest_driver.py -q tests; echo $?
Enter fullscreen mode Exit fullscreen mode

Record returncode, exception type, and first failing node.
Restore the file before any other edit.

git mv tmp/held/report_utils.py src/legacy/report_utils.py
Enter fullscreen mode Exit fullscreen mode

A green suite is not proof of death.
It often means the suite never imported the module.
Add a characterization test that imports the module.

# tests/test_report_utils_exists.py
# Proposal: pins presence, not behavior.
from pathlib import Path
import importlib


def test_report_utils_importable():
    mod = importlib.import_module('legacy.report_utils')
    assert Path(mod.__file__).name == 'report_utils.py'
Enter fullscreen mode Exit fullscreen mode

Re-run the edge recorder after that test lands.
You now have a tripwire for the next change.
Keep that tripwire until the shim commit is green.

If the missing-file probe raises ModuleNotFoundError, record it.
If it raises ImportError from a circular import, record that too.
Exception type is part of the pin, not a nuisance.

Step 4: Read the decision table

Use the table and do not improvise a cleanup.

unique_modules line_hit_count missing-file suite next change
empty 0 still green add import tripwire, then archive
empty 0 fails find the loader; do not delete
non-empty 0 fails pin the import; keep the file
non-empty >0 fails extract body, leave a shim
empty >0 fails distrust paths; fix the recorder

Archive means git mv into attic/, not rm.
Attic keeps blame and history searchable later.
rm hides the last known good bytes.

Do not optimize the table for speed.
The cheap move is still the wrong move.
Unique module names decide whether the file is live.

Step 5: Smallest safe change is a shim

When edges and hits are both positive, stop.
The file is live, so a delete is out of scope.
Move the implementation and keep the import path.

# src/legacy/report_utils.py
# Shim only. Behavior lives in app/report_utils.py.
from app.report_utils import (  # noqa: F401
    build_report,
    iter_rows,
    FORMAT_VERSION,
)

__all__ = ['build_report', 'iter_rows', 'FORMAT_VERSION']
Enter fullscreen mode Exit fullscreen mode

That is the entire change for this step.
Do not edit callers in the same commit.
Do not reformat the new module in the same commit.

Re-run the recorder after the shim lands.

python tools/pin_import_edges.py \
  src/legacy/report_utils.py \
  tmp/pins/report_utils.after.json \
  tools/run_pytest_driver.py \
  -q tests
Enter fullscreen mode Exit fullscreen mode

Compare returncode against the baseline JSON file.
Compare whether unique modules stayed non-empty.
Any new exception type means the shim is wrong.

# tools/compare_pins.py
# Proposal: fail on returncode or edge presence drift.
import json
import sys
from pathlib import Path

before = json.loads(Path(sys.argv[1]).read_text())
after = json.loads(Path(sys.argv[2]).read_text())

if before['returncode'] != after['returncode']:
    raise SystemExit('returncode drifted')
if bool(before['unique_modules']) != bool(after['unique_modules']):
    raise SystemExit('edge presence drifted')
print('pin match')
Enter fullscreen mode Exit fullscreen mode
python tools/compare_pins.py \
  tmp/pins/report_utils.edges.json \
  tmp/pins/report_utils.after.json
Enter fullscreen mode Exit fullscreen mode

A shim commit should leave caller files untouched.
Caller edits belong to a later, measured pass.
Mixing both hides which change broke the pin.

Step 6: Where a free model and free server fit

Drafting a shim from JSON is mechanical work.
A model can propose __all__ from the edge list.
A remote runner can execute the wrapper off-laptop.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode offers free model access and a free server option.
Use the model to draft the shim from edges.json only.
Use the server to run pin_import_edges.py against the suite.

Do not paste secrets into that prompt.
Do not ask the model to invent missing callers.
Feed the JSON, take the shim, and re-run the pin.

The model does not replace the recorder.
The server does not replace the decision table.
Both stay optional once the JSON exists.

Limitations

sys.settrace misses C extensions and some decorators.
It also slows the suite, which is acceptable here.
It is not acceptable as always-on production tracing.

The __import__ hook misses some importlib loaders.
Frozen modules and namespace portions may not appear.
Treat zero edges as a recorder gap until the probe.

Line hits are not behavioral equality checks.
They do not pin return values or file writes.
They only answer whether this path ran.

The shim keeps the old public names only.
Private helpers imported by tests still break.
Pin those names in __all__ before the move.

Do not combine this recorder with pytest-xdist workers.
Each worker would write a partial JSON file.
Run the pin on one process only.

Relative imports through level > 0 can look noisy.
Record the resolved __file__, not the request name.
Path identity is the fact that licenses a delete.

Who should skip this approach

Do not use this on generated code you do not own.
Do not use this as a substitute for lockfiles.
Do not use this to justify a multi-package rewrite.

Skip it when the file is a security hotspot.
A shim can extend the life of a bad API.
Hotspots need a tracked removal, not an attic.

Skip it when you lack a test command.
No suite means no honest edge capture.
Write the import tripwire first, then measure.

Skip it for data files that are not imported.
JSON fixtures need checksum pins, not import edges.
This harness answers load questions only.

What done means

Done is not a cleaner folder tree.
Done is a JSON pair with matching returncodes.
Done is one shim commit and a green suite.

Delete only after unique modules stay empty.
Delete only after the missing-file probe stays green.
Delete only after the tripwire is removed on purpose.

Messy repos shrink one path at a time.
Measure that path under the real test command.
Then change only that path in git.

Top comments (0)