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.
- Which module names resolve to that file.
- Which lines run during the existing suite.
- 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()
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:]))
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
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
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'])"
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 $?
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
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'
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']
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
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')
python tools/compare_pins.py \
tmp/pins/report_utils.edges.json \
tmp/pins/report_utils.after.json
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)