Large File Refactor Plan
EAS Station™ documentation
Large File Refactor Plan
Status: In progress · Started: 2026-08-06
docs/development/AGENTS.md sets the size guidance for this repository:
Aim to keep Python modules under ~400 lines and HTML templates under ~300 lines. When adding more than one new class or multiple functions to a module already above 350 lines, create or use a sibling module/package instead of expanding the existing file.
When this plan was written (2026-08-06) the tree violated that guidance in 131 Python modules, 75 templates and 16 JavaScript files. That number will not go to zero in one pass, and trying would produce an unreviewable diff across the whole codebase. This document is the running plan: it records the inventory, the extraction strategy per file, and which phases have landed.
Do not read those figures as current. Counting every tracked *.py over
400 lines outside __pycache__/node_modules/venv/migrations:
| 2026-08-06 | 2026-08-08 | |
|---|---|---|
| Python modules > 400 lines | 131 | 162 (137 excluding tests/) |
| Templates > 300 lines | 75 | 75 |
The Python figure went up while eight files were being split, because the tree is also growing — the phases below retired roughly 20,000 lines of oversized module, and new work added more oversized modules than that removed. The template count has not moved at all: Phase 5 has not started. Treat the per-phase tables as the source of truth for what has actually landed, and re-run the count rather than trusting any number written here.
Ground rules for every extraction
These are what make a split reviewable and safe. Follow them on every file.
- Move code verbatim. A refactor commit changes where code lives, not what it does. Behavioural changes go in a separate commit, so a reviewer can read the split diff as pure motion.
- Keep the old import path working. The original module becomes a shim that
re-exports the public names from the new package. Nothing outside the
refactored area should need to change in the same commit —
from app_core.radio.demodulation import FMDemodulatormust keep working. - One seam per module. Split along a real boundary (data vs. logic, DSP kernels vs. protocol decode, route handlers vs. helpers), never at an arbitrary line number just to get under the cap.
- Preserve
__all__and the module docstring on the shim sohelp()and star-imports behave as before. - Run the tests that cover the file before and after, and name them in the commit message. If a file has no coverage, that is worth knowing before it is moved.
- Audit every
__file__-relative path before moving a module. This is the one way "identical code" can still change behaviour: a module in a new package sits one directory deeper, soos.path.dirname(os.path.dirname(__file__))now points somewhere else. AST-equality checking cannot catch it — the code really is identical, it just means something different. Grep for__file__in the file you are about to split, and assert the resolved values against the pre-split module afterwards. This bit Phase 2b: the brand logo path and the tile disk-cache path both silently resolved one level short of the repository root, and neither failure raises —_load_logo()swallows the error and renders a card with no logo. Both are now pinned by tests. - Follow the existing naming convention. When a module is superseded, the
old one is renamed with an
_oldsuffix — never_newfor the replacement. In practice most splits here need no rename because the original path stays as the shim.
The repository already has a worked example of this pattern:
webapp/routes_audio_archive.py (928 lines) became the webapp/audio_archive/
package — fsutil.py, config.py, metadata.py, routes.py — in 2.133.1.
New splits should look like that one.
Phase 1 — Data/code separation ✅
The cheapest wins: modules that are mostly a static data table with a thin layer of lookup logic wrapped around it. Nothing executable moves, so the risk is close to zero and the line-count win is large.
| File | Before | After | Action |
|---|---|---|---|
app_utils/fips_codes.py |
3887 | ~660 | ✅ 3,225-line US_FIPS_COUNTY_TABLE string literal moved to app_utils/data/us_fips_counties.txt, read at import |
Landed in 2.134.0. The table is FCC/Census reference data, not code —
keeping it inline meant every reader of the lookup helpers scrolled past 3,200
lines of county names, and every diff touching the module rendered them. The
data file keeps the identical FIPS|ST|Name pipe format, so it stays greppable
and diffable, and it is resolved relative to __file__ the same way the module
already resolves the NWS partial-county .dbf from assets/. (The project is
deployed as a git checkout, not an installed wheel, so no package_data entry
is needed.)
Remaining Phase 1 candidates
None found above 800 lines. app_utils/event_codes.py and
app_utils/zone_catalog.py carry data tables but are already under the cap.
Phase 2 — Signal-processing and rendering packages
Large single-purpose modules with clean internal seams. These are pure library code with no Flask involvement, which makes them the safest of the big splits.
| File | Lines | Planned layout | Status |
|---|---|---|---|
app_core/radio/demodulation.py |
5355 | app_core/radio/demod/ package |
✅ landed (shim now 95 lines) |
app_utils/image_export.py |
3391 | app_utils/image_export/ package |
✅ landed |
app_utils/gpio.py |
3149 | app_utils/gpio/ package |
✅ landed |
app_core/gps/gps_manager.py |
2893 | timing math (2d) + NMEA parsing (2e) extracted | 🚧 2313 — see 2d, 2e |
app_core/radio/drivers.py |
2187 | one module per driver family | ⏳ not started — one 1822-line class |
The last two files are not the same kind of problem as the first three. 2a–2c were each several independent top-level definitions sharing one file, so splitting them was pure motion verifiable by
ast.dump()comparison. These two are each one god-class:GPSManagerwas 2741 ofgps_manager.py's 2893 lines, and_SoapySDRReceiveris 1822 ofdrivers.py's 2187. Module-level splitting cannot shrink a single class, so a claim in the 2c pull request that Phase 2 was complete was wrong.The way through is two different techniques, in order:
- Move the stateless parts as motion (2d) — verify with
ast.dump().- Extract collaborators for the stateful parts (2e) —
ast.dump()is useless here since the code is deliberately restructured, so verify with a characterization harness built before the refactor: snapshot all mutated state across a realistic input stream, confirm the baseline is discriminating, then diff after.
gps_manager.pyhas had both applied (2893 → 2313).drivers.pyhas had neither.
2a. app_core/radio/demodulation.py → app_core/radio/demod/ ✅
The single largest module in the tree, and six unrelated concerns in one file:
Numba JIT kernels, generic DSP helpers, configuration dataclasses, the RBDS
(Radio Broadcast Data System) decoder, the FM demodulator and the AM
demodulator. The RBDS decoder alone is ~2,300 lines and has its own test file
(tests/test_rbds_demodulation.py), yet could not be imported without pulling
in the whole FM chain.
| New module | Lines | Contents |
|---|---|---|
demod/kernels.py |
426 | Numba @jit kernels — FM discriminator, de-click, Costas loop, Mueller–Müller timing, syndrome calc, presync scan — plus the Numba availability probe and log-level pinning |
demod/rbds_constants.py |
144 | RT+ AID and content types, RBDS language codes, NRSC-4-B call-sign table, pi_to_call_sign |
demod/types.py |
279 | DemodulatorConfig, RBDSData, RBDSDecoderStats, DemodulatorStatus |
demod/dsp.py |
332 | fm_discriminator*, fast_decimate, FIR design, resample_to, StreamingResampler |
demod/rbds_decoder.py |
1238 | RBDSDecoder |
demod/rbds_worker.py |
2293 | RBDSWorker |
demod/fm.py |
795 | FMDemodulator |
demod/am.py |
96 | AMDemodulator |
demod/factory.py |
48 | create_demodulator |
The dependency graph is strictly one-directional, with no cycles:
kernels ─┬─> dsp ─┬──────────────┐
│ │ ├─> fm ──┐
└────────┴─> rbds_worker┘ ├─> factory
rbds_constants ─> rbds_decoder ─┘ │
types ─────────────────────────> am ──────┘
app_core/radio/demodulation.py remains as a re-export shim so every existing
from app_core.radio.demodulation import … keeps resolving — including the
private _NUMBA_AVAILABLE that webapp/routes_monitoring.py reads.
Verification. The split was checked to be pure motion by comparing ast.dump()
of every top-level definition before and after: 23 definitions, 23 matches, zero
AST differences. tests/test_rbds_demodulation.py, tests/test_fm_stereo_decoder.py,
tests/test_early_decimation.py and tests/test_eas_resampler.py pass unchanged
(103 tests).
Still over the cap — follow-up needed. rbds_worker.py (2293) and
rbds_decoder.py (1238) are each a single class, so module-level splitting
cannot shrink them further. RBDSWorker has 27 methods covering pilot
estimation, interference notching, timing recovery, Costas carrier recovery and
group decoding — those are five collaborators wearing one class. Bringing them
under the cap means extracting mixins or helper objects, which is a
behaviour-adjacent change and belongs in its own commit with its own review.
Tracked as Phase 2a-ii. fm.py (795) needs the same treatment, at lower
priority.
2b. app_utils/image_export.py → app_utils/image_export/ ✅
The alert share-image renderer, whose seams were already marked by section comments in the file.
| New module | Lines | Contents |
|---|---|---|
logo.py |
71 | Brand logo raster and its cache |
layout.py |
145 | _Layout and the landscape/square/portrait/story presets, plus the backwards-compatible FB_WIDTH-style constants |
palette.py |
75 | Colour palette, severity/threat colour maps, _darken, _lighten, _pct_bar_color |
fonts.py |
116 | Font loading and caching, _tw/_th/_truncate |
text.py |
252 | Local-time formatting and the ALL-CAPS → sentence-case humanizer |
icons.py |
81 | _icon_wind, _icon_hail, _icon_tornado, _ICON_FN |
theme.py |
435 | Hazard-family themes, tier badges, urgency heat |
drawing.py |
143 | _draw_pill, _composite, _round_image_corners, _section_header, _card_row |
weather_fx.py |
429 | Lightning, snow, rain, sun, embers, wind, haze, _draw_themed_header |
tiles.py |
257 | Slippy-tile maths, bbox/centroid/zoom, memory LRU + disk cache, _fetch_tile |
map_style.py |
193 | Basemap toning, vignette, collision-avoiding label placement |
map_data.py |
229 | County outlines, SAME geocodes, county-union fallback geometry (PostGIS) |
storm_overlay.py |
278 | _draw_storm_track — cone of uncertainty, arrow, callout |
maps.py |
473 | _render_map, _crop_window, scale bar |
nws_text.py |
272 | NWS tagged-bullet parser (parse_nws_segments, select_share_segments), areaDesc compaction, URL stripping |
panels_text.py |
367 | The prose info-panel drawers: headline, description (labelled NWS outline), action |
panels.py |
383 | The remaining info-panel section drawers: threats, coverage, areas, compass |
render.py |
492 | generate_alert_image |
Dependency graph, generated from the actual imports and verified acyclic:
logo, layout, palette, fonts, text, icons leaves
theme -> palette
drawing -> fonts, palette
weather_fx -> drawing, layout, theme
tiles -> layout
map_style -> fonts
storm_overlay -> fonts, layout, tiles
maps -> fonts, layout, map_data, map_style, palette,
storm_overlay, theme, tiles
panels_text -> drawing, fonts, nws_text, palette, text
panels -> drawing, fonts, icons, nws_text, palette, panels_text
render -> drawing, fonts, layout, logo, maps, palette, panels,
panels_text, text, theme, weather_fx
Here the package __init__.py is the compatibility shim — it re-exports all
125 names the single-file module exposed (including logger), so
from app_utils.image_export import … is unchanged for
app_core/notifications/alert_image.py and webapp/admin/api/.
Verification. 68 top-level definitions, 68 ast.dump() matches, zero
differences. The slicing script also asserted that every non-blank line of the
original landed in exactly one module — nothing silently dropped.
tests/test_image_export_themes.py passes (123 tests, up from 120).
Follow-up (2.152.0). maps.py was split again when the map gained its
basemap treatment: the storm overlay and the PostGIS lookups moved to
siblings, bringing it from 904 back to 473 lines.
Follow-up (2.151.0). panels.py was still over the ~400-line guidance, so
the three prose drawers moved into a new panels_text.py alongside the new
nws_text.py parser they depend on. panels re-exports them, so no caller
changed.
One real bug was introduced and caught. _LOGO_PATH and
_TILE_DISK_CACHE_DIR_DEFAULT are built by walking up two directories from
__file__. That was the repository root when the renderer was a single
app_utils/image_export.py; inside the package every module is one level
deeper, so both resolved one short — the share card rendered with no brand
logo, and OSM tiles cached into app_utils/data/tile-cache (colliding with
the directory Phase 1 had just created for the FIPS table). Neither failure
raises, and the 120 existing tests all still passed. It surfaced only because
a stray app_utils/data/tile-cache/ appeared in git status. Both constants
now derive from a named _REPO_ROOT with a comment explaining the depth, and
three tests pin the resolved paths — verified to fail without the fix.
The test needed two changes, and they are worth understanding before the next split. Both come from the same fact: a package has more than one namespace where the module had one.
- The test deliberately loads the renderer by file path rather than importing
it, to avoid pulling in the whole
app_utilspackage. Loading a package that way needssubmodule_search_locationson the spec, otherwise itsfrom .theme import …internal imports resolve back throughapp_utilsand undo the isolation. - Nine
monkeypatch.setattr(image_export, …)calls had to move to the module that calls the patched name (tilesfor_http,mapsfor_fetch_tileand_fetch_county_outlines,renderfor_render_map). Rebinding a name on the re-exporting package does not change whatmaps._render_mapsees in its own globals. This was verified to be load-bearing rather than assumed: pointing the patches back at the package makes 5 tests fail.
Pre-existing issues found, deliberately left alone (fixing them is a
behaviour change and does not belong in a pure-motion commit):
maps.py has an unused shadow local (F841, present in the monolith), and
test_render_map_draws_counties_and_scale_bar asserts only the output image's
size and mode — despite its name and docstring it never checks that county
outlines or the scale bar were drawn, so it passes whether or not its stubs
take effect.
Import style
Both packages use relative intra-package imports (from .theme import …),
matching webapp/audio_archive/, app_core/flask/, app_core/config/ and
app_core/database/. Beyond consistency this is what lets a test load the
package standalone, as tests/test_image_export_themes.py does. demod/ was
converted from absolute to relative in the same release for this reason.
2c. app_utils/gpio.py → app_utils/gpio/ ✅
Four distinct subsystems shared one file: the GPIO backend abstraction
(lgpio/sysfs/null), the GPIOController + behaviour manager, the NeoPixel
controller, and the tower-light controller.
| New module | Lines | Contents |
|---|---|---|
pin_types.py |
166 | GPIOState, GPIOActivationType, GPIOBehavior, the behaviour label/pulse tables, GPIOActivationEvent, GPIOPinConfig, flash-interval bounds |
backends.py |
426 | GPIOBackend Protocol, _LGPIOBackend, _SysfsGPIOBackend, _NullGPIOBackend, gpiozero pin-factory setup, _BackendPinDevice |
tower_light.py |
410 | Adafruit and ANDONT command protocols, TowerLightConfig, TowerLightController |
neopixel.py |
336 | Strip-type constants, NeopixelConfig, _NullNeopixelStrip, _make_neo_color, NeopixelController |
controller.py |
1003 | GPIOController |
behavior.py |
546 | GPIOBehaviorManager |
config_loaders.py |
440 | The four load_*_from_db functions and behaviour-matrix (de)serialisation |
pin_types, backends, tower_light leaves
neopixel -> pin_types
controller -> backends, pin_types
behavior -> controller, pin_types
config_loaders -> neopixel, pin_types, tower_light
Each optional-dependency probe moved to sit with its only consumer:
get_gpio_settings / _GPIO_SETTINGS_AVAILABLE with the database loaders,
PixelStrip / NeopixelColor / _NEOPIXEL_LIB_AVAILABLE with the NeoPixel
controller, Device / OutputDevice / MockFactory with the backends. The
package __init__.py re-exports all 72 names the single-file module exposed.
Verification. 28 top-level definitions, 28 ast.dump() matches, zero
differences, plus the every-line-placed assertion. All six production importers
were imported to confirm they still resolve.
Two lessons this phase added.
Do not name a package module
types.py. It shadows the stdlibtypesfor any process whose working directory is the package directory, and the stdlib import chain (dataclasses→re→enum→types) then fails with a confusing partially-initialised-module error. Renamed topin_types.py.app_core/radio/demod/types.pyhas the same footgun — it is harmless in normal operation, so it was left alone rather than churning merged code, but it is a cleanup candidate.Find monkeypatch targets with an AST scan, not a grep. String matching found 24 of the 31 patch sites here; it missed multi-line
monkeypatch.setattr(\n gpio,\n "name",\n …)calls and a barereal_sleep = gpio.time.sleepattribute read. Walking the test files' ASTs forsetattrcalls whose first argument resolves to the package catches every form. The reusable check:# For each tests/*.py: find setattr calls targeting the package. for n in ast.walk(ast.parse(path.read_text())): if (isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and n.func.attr == 'setattr' and n.args): ... # report the target expression and attribute name
2d. app_core/gps/gps_manager.py — stateless timing math extracted ⚠️ partial
GPSManager is 2741 lines in one class, 54 methods. Profiling it by self
usage splits the class cleanly in two:
| Group | Methods | Lines | Extractable as motion? |
|---|---|---|---|
Stateless (zero self references) |
8 | 386 | ✅ yes — 6 of 8 moved here |
Stateful (touch self) |
46 | 2235 | ❌ no — needs collaborators |
Six of the eight stateless methods (359 lines) moved out verbatim. Two stayed, deliberately:
_sat_key(2 lines) — used only by_record_sat_seen/_record_sat_used, which remain on the manager. Moving a two-line helper would add a cross-module hop for no gain._scan_capture(25 lines) — UBX frame scanning. Its natural home is the existingapp_core/gps/ubx.py, not a timing-statistics module; folding it in here would have mixed two unrelated concerns in one commit.
The six that moved:
| New module | Lines | Contents |
|---|---|---|
timing_stats.py |
342 | compute_jitter_summary (adaptive-bucket PPS jitter histogram), compute_allan_deviation (overlapping ADEV/TDEV/MTIE at τ = 1/10/100/1000 s), holdover_seconds, derive_leap_state |
sysprobe.py |
49 | read_cpu_temp_c, safe_read — two sysfs reads that swallow failure and return None |
These were @staticmethod in all but name, so they were pure functions
trapped inside a class. Extracting them makes them directly importable and
testable — tests/test_gps_stability_metrics.py was already testing them
through GPSManager._compute_allan_deviation(...), reaching past the class to
get at a pure function.
Verification. All 6 moved functions are ast.dump()-identical to their
originals once the @staticmethod decorator and docstring indentation are
normalised (a module-level function indents its docstring 4, not 8); every
non-blank removed line was asserted present in the new modules; and both
implementations were run side by side over 5 interval datasets (including
empty, single-sample and constant edge cases) with zero output differences.
135 GPS tests pass.
What is left, and why it is not motion. The other 2235 lines are stateful:
_handle_sentence alone is 246 lines with 50 self references, accumulating
fix state, satellite tracking and publishing as it parses. The plan's original
"split manager / NMEA parsing / survey" wording assumed these were separable
files; they are not. Doing it properly means giving the NMEA parser explicit
state to return rather than mutating self, which changes behaviour-bearing
code on the GPS/timing path and cannot be verified by AST comparison. That
deserves its own design pass and reviewed commit — the same conclusion already
recorded for controller.py and behavior.py in 2c.
A lesson this phase added — do not let head truncate a reference search.
Grepping for the six method names before extracting appeared to show no test
references, so the first attempt moved them without retargeting any tests. The
search had been piped through head -20, and 20 unrelated _safe_read_text
matches in app_utils/system.py consumed the entire output budget before a
single real hit was printed. 17 tests then failed. When a search is being used
to prove a negative, either drop the limit or filter the known-irrelevant
matches out first — a truncated search cannot establish that something is
absent.
2e. GPSManager._handle_sentence → app_core/gps/nmea.py ✅
The first collaborator extraction in this effort, as opposed to pure
motion. _handle_sentence was 246 lines with 50 self references: four
sentence branches (GGA/RMC/GSV/GSA) that interleaved NMEA field-mapping with
manager state — satellite history, the holdover anchor, the system-clock sync
policy and a Redis publish.
The seam is what the sentence says vs. what the manager does about it.
app_core/gps/nmea.py (326 lines) owns the former and returns the latter:
| Piece | Role |
|---|---|
NMEAParseState |
The cross-sentence accumulators — per-talker GSV buckets, the GSA per-cycle PRN union, the cycle flag. Owned by the manager, passed in. |
SentenceEffects |
What a sentence implies beyond the fix dict: sats_seen, sats_used, saw_3d_fix, utc_datetime. |
apply_gga / apply_rmc / apply_gsv / apply_gsa |
One per sentence type. Each takes (fix, msg, state) plus config such as min_satellites, mutates the fix dict and parse state in place, and returns a SentenceEffects. Free of the manager, but not pure. |
apply_sentence |
Bumps the per-type counter and dispatches. |
_handle_sentence is now 30 lines: take the lock, call apply_sentence, apply
the effects, publish. The clock-sync policy moved to its own
_queue_time_sync. _FIX_QUALITY and _safe_int moved to nmea.py too — the
NMEA path was their only consumer — and are re-exported from gps_manager so
existing imports still resolve. gps_manager.py: 2893 → 2313 lines.
Deferring the effects is safe because none of _record_sat_seen,
_record_sat_used or _mark_3d_fix reads self._fix; that was checked by AST
before the seam was drawn, not assumed. Had any of them read the fix dict,
applying effects after the parse instead of mid-parse could have changed
behaviour.
Verification — this is where a characterization harness earns its keep.
ast.dump() comparison is useless here: the code is deliberately restructured.
So the safety net was built first, against the pre-refactor code:
- A 28-sentence multi-GNSS stream — full cycles, multi-constellation GSV groups, several GSAs per cycle, no-fix → 2D → 3D transitions, empty GLGSA, blank/malformed fields, out-of-order GSV group numbers.
- Snapshot all mutated state after every sentence: the fix dict, the GSV buckets, the GSA accumulator and cycle flag, the pending time sync, the per-PRN satellite history and the 3D-fix anchor.
- Confirm the baseline is discriminating — 28 frames, 28 distinct states. A harness that records constant state proves nothing.
- Refactor, re-run, diff with wall-clock timestamps scrubbed: 0 differing frames of 28.
tests/test_gps_nmea_sentences.py (19 tests) makes the harness permanent and
adds direct coverage the old shape could not have: each rule is now assertable
without constructing a GPSManager. Two of them were mutation-checked rather
than trusted — reverting the GSV per-talker bucketing and the GSA union each
failed exactly one test, and only that test.
Two traps this phase hit.
- Re-typing a constant instead of moving it.
_FIX_QUALITYand_safe_intwere re-written from memory into the new module. Both were wrong: the fix-quality labels came out"invalid"/"gps"/"dgps"instead of"no_fix"/"gps_fix"/"dgps_fix", and the re-typed_safe_intdropped theint(float(s))conversion so"12.0"would raise instead of yielding 12. Neither raises at import; both would have silently changed the dashboard. Move shared helpers by reference — grep for the real definition and cut it — and never re-type one from memory. - A truncated grep cannot prove a negative. See 2d:
| head -20hid every real hit behind unrelated matches, and the conclusion drawn from it ("no test references these") was wrong.
What is left in gps_manager.py
2313 lines, still over the guidance. The remaining bulk is the gpsd client
(_gpsd_reader_loop, _gpsd_connect, _handle_gpsd_tpv, _handle_gpsd_sky,
the watchdog and daemon restart), the serial reader loop, the PPS/kernel-PPS
handling and get_status. Each is a plausible next collaborator and each wants
the same treatment: characterize first, then extract. app_core/radio/drivers.py
(one 1822-line _SoapySDRReceiver) is untouched and needs the same approach.
Phase 3 — Web layer
Flask modules where route handlers and their helpers are interleaved. The
webapp/audio_archive/ package is the template to copy: helpers in topic
modules, routes.py holding only handlers.
| File | Lines | Planned split | Status |
|---|---|---|---|
webapp/admin/audio_ingest.py |
3180 | webapp/admin/audio_ingest/ package |
✅ landed — see 3a |
webapp/routes_public.py |
2849 | webapp/public/ package, split by surface |
✅ landed — see 3b; logs_data.py follow-up in 3b-ii |
webapp/routes_settings_radio.py |
2781 | webapp/radio_settings/ package, split by topic |
✅ landed — see 3c |
webapp/admin/api.py |
2105 | webapp/admin/api/ package, split by resource |
✅ landed — see 3d |
webapp/admin/certbot.py |
1946 | webapp/admin/certbot/ package |
✅ landed — see 3e |
app.py |
1869 | not the same kind of split — see 3g below before starting | ⏳ |
webapp/admin/maintenance.py |
1802 | webapp/admin/maintenance/ package |
✅ landed — see 3f |
webapp/routes/alert_verification.py |
1668 | webapp/routes/alert_verification/ package |
✅ landed — see 3h |
3a. webapp/admin/audio_ingest.py → webapp/admin/audio_ingest/ ✅
The largest Flask module in the tree, and the first web-layer split. Helpers and handlers were interleaved down its whole length — the stream-URL probe helpers sit at line 1575, between two route handlers.
| New module | Lines | Contents |
|---|---|---|
blueprint.py |
36 | audio_ingest_bp |
controller.py |
231 | Controller singleton, background startup, _try_acquire_lock, the Redis metrics bridge |
streaming.py |
262 | Auto-streaming (Icecast) service lifecycle, _get_icecast_stream_url |
sanitize.py |
173 | _sanitize_float/_bool/_metadata_value, _merge_metadata, _redact_device_params, _db_to_linear |
probe.py |
155 | _describe_stream_status, _probe_stream_url |
radio_sources.py |
385 | ensure_sdr_audio_monitor_source and the SDR naming/metadata helpers |
serialization.py |
359 | Adapter/DB row → API payload |
routes_sources.py |
88 | The two read endpoints (3a-ii) |
routes_sources_write.py |
389 | create / update / delete (3a-ii) |
listing.py |
226 | Reconciling DB, controller and Redis for the source list (3a-ii) |
source_payload.py |
268 | One audio source rendered to JSON (3a-ii) |
routes_source_control.py |
160 | start / stop / test-stream |
routes_rbds.py |
151 | RBDS history |
routes_metrics.py |
233 | /api/audio/metrics* |
routes_health.py |
255 | /api/audio/health* and the dashboard page |
routes_alerts.py |
204 | /api/audio/alerts* |
routes_devices.py |
159 | devices, waveform, spectrogram, live stream |
routes_icecast.py |
205 | /api/audio/icecast/* |
blueprint, sanitize, probe leaves
controller -> (nothing in-package)
streaming -> controller
serialization-> controller, sanitize, streaming
radio_sources-> controller, streaming
routes_* -> blueprint + the helpers each one needs
The entry point is unchanged: register_audio_ingest_routes(app, logger) stays
the package's only __all__ entry, so webapp/admin/__init__.py did not move.
Verification. 69 of the 73 top-level definitions are ast.dump()-identical
before and after, every non-blank line of the original lands in exactly one
module, and the full suite is green (1,997 passed). The four deliberate
differences are each pinned by a test in
tests/test_audio_ingest_package.py.
Three things this phase adds to the checklist, all of them consequences of one file becoming many namespaces.
- A Blueprint's
import_namechanges when it moves.Blueprint('audio_ingest', __name__)in ablueprint.pyresolves towebapp.admin.audio_ingest.blueprint, one level deeper than before — the same class of silent drift as the__file__bug in 2b, since Flask derives the blueprint's root path (and any template/static folder it later gains) from it.__package__is the pre-split value, so that is what the new module passes. Pinned by a test. - Never import a mutable module global across modules.
remove_radio_managed_audio_sourcereads_audio_controllerdirectly. A generatedfrom .controller import _audio_controllerbindsNoneat import time and never sees the singleton_get_audio_controllerlater installs, so the local-controller fallback would have silently stopped removing sources — no exception, sinceif controller and …simply skips. It goes through a new_peek_audio_controller()accessor instead, mirroring the_get_auto_streaming_service()that the sibling global already had. A test AST-scans the package for by-value imports of any of the six mutable globals. - A
loggerglobal that a registration hook rebinds has to be fanned out.register_audio_ingest_routesdidglobal logger; logger = logger_instance. With one module that was the whole story; with fifteen, rebinding the package'sloggerleaves every line that actually logs on its own. The hook now walks a_LOGGING_MODULEStuple, and a test asserts that tuple covers every module in the package that defines alogger.
The monkeypatch retarget was load-bearing, and loudly so. Four fixtures
across three test files reset _audio_controller, _auto_streaming_service,
_initialization_started, _streaming_lock_file,
_audio_initialization_lock_file, _start_audio_sources_background,
_reload_auto_streaming_from_env, _read_audio_metrics_from_redis and
_restore_audio_source_from_db_config on the module. Deliberately not
re-exporting the mutable globals from the package __init__ is what made this
safe: monkeypatch.setattr raises AttributeError on a missing attribute, so
all 9 patch sites failed immediately instead of turning into silent no-ops. Had
the shim re-exported them for completeness, the resets would have kept
"passing" while resetting nothing.
3a-ii. api_get_audio_sources → listing.py + source_payload.py ✅
The one module 3a left over the cap. routes_sources.py was 749 lines because
api_get_audio_sources alone was 327: a single handler that decoded the audio
service's Redis snapshot in four possible shapes, queried three tables,
reconciled the database against the local controller, and built two different
JSON bodies depending on which won.
Module-level splitting cannot shrink one function, so this is a collaborator extraction — the 2e technique, not the 2a–2d one — and it was verified the same way.
| Piece | Role |
|---|---|
RedisControllerState |
What the audio service is publishing: the source map, whether it is usable, whether the service is dead at all, and the streaming block. |
_read_redis_controller_state |
Decodes it. The audio_controller entry and its nested streaming entry may each be a dict or a JSON string, present or absent, well-formed or not. |
_latest_metrics_by_source |
One query for the newest persisted metric row per source. |
_collect_icecast_status |
Per-source stream stats, local service first, Redis only as a fallback. |
_serialize_from_redis / _serialize_db_only |
The two JSON bodies, in source_payload.py. The third — a live local adapter — was already serialization._serialize_audio_source. |
build_source_listing |
Orchestration and the envelope counters. |
The write endpoints moved to routes_sources_write.py in the same pass, split
along the permission boundary: all three carry
@require_permission('receivers.configure'), and neither read endpoint does.
Verification — the harness came first, as in 2e.
tests/test_audio_source_listing.py(12 tests) was written and run green against the pre-refactor handler, covering all three live-state paths, the envelope counters, the dead-service escalation rule, malformed Redis payloads and a failing streaming service.- Confirmed discriminating before trusting it: five deliberate mutations —
disabling the dead-service escalation, reversing Redis/database metric
precedence, inverting
db_only_count, letting Redis override the local streaming service, and dropping the reconstructed Icecast URL — each failed exactly the test that covers it, and only that test. - A separate dump script rendered the full response body for 8 scenarios (8 distinct bodies — a harness recording constant state proves nothing) against a git worktree at the pre-extraction commit and against the working tree: 0 differing bytes, with the script printing which module it patched so a silent fallback to the old shape could not masquerade as a match.
A trap worth recording: a mutation that does not apply looks exactly like a
mutation that survived. The first run of the five reported four caught and
one survived. The surviving one had been written with the wrong indentation in
its search string, so str.replace matched nothing and the "mutant" was the
original code. Assert that the mutation target was found before drawing any
conclusion from the result.
A second one, from tidying up afterwards: ruff check --select F401 --fix
over the package removed 124 genuinely unused imports — and would have removed
the shim's re-exports too, silently breaking every external importer, except
that ruff exempts __init__.py from F401 by default. That exemption is the
only thing that made the command safe. Verify the shim still resolves after any
automated import cleanup rather than relying on it.
3b. webapp/routes_public.py → webapp/public/ ✅
The second web-layer split, and structurally unlike 3a. audio_ingest.py had
73 top-level definitions sharing a file; routes_public.py had one. Its
entire body was a single 2,779-line register(app, logger) with all 21 route
handlers nested inside it, so no top-level definition could be moved at all.
That turned out to make the split easier, not harder. Every handler closed
over exactly two names — app (for the @app.route decorator) and
route_logger — which was checked by walking the AST for Name nodes
resolving to register's scope rather than assumed:
| Handler group | Closure variables used |
|---|---|
| all 21 handlers | app, route_logger |
terms_page, privacy_page |
+ _render_policy_page (same module) |
_render_policy_page |
policy_docs_root, route_logger |
So each surface keeps its own register(app, route_logger) and the handler
bodies move verbatim — still nested inside a register, just a much
smaller one. No reindentation, no rebinding, no signature changes.
| New module | Lines | Contents |
|---|---|---|
pages.py |
189 | /, /about, /help, /style-guide, /attribution, /support, /navigation, /terms, /privacy, /sms-compliance, /system_health, /audio-monitor, _render_policy_page |
sitemap.py |
115 | /sitemap.xml |
stats.py |
693 | /stats |
alerts.py |
648 | /alerts and /alerts/export.pdf |
logs_data.py |
1116 | _load_logs_data, via a build_logs_loader(route_logger) factory |
logs.py |
265 | /logs, /logs/export.csv, /logs/export.pdf |
_load_logs_data is a helper, not a route, and it is the only piece the three
/logs handlers share. Wrapping it in build_logs_loader(route_logger)
rather than re-signaturing it to take route_logger as a parameter keeps its
1,057-line body byte-identical — only the enclosing scope changed. logs.py
receives it as a parameter named _load_logs_data, so the three call sites
resolve it unchanged.
webapp/routes_public.py remains as a 31-line shim re-exporting register,
so the route-module registry in webapp/__init__.py is untouched.
Verification. Three independent checks, each confirmed discriminating before its result was trusted:
- AST equality — 21 of 21 handlers
ast.dump()-identical before and after, plus the every-non-blank-line-placed-exactly-once assertion (2,753 lines, 0 unplaced). - URL map — every rule, endpoint and method set, sorted and diffed
against a git worktree at the pre-split commit: 549 rules, 0
differences. Mutation-checked by deleting one
register()call, which the diff caught. - Response bodies — all 28 public surfaces fetched through the test client on both sides and hashed with volatile content scrubbed: 28/28 identical, 28 distinct digests.
A trap this phase hit — an "identical length, different hash" diff is a
scrubbing bug, not a regression. Nine pages first compared as differing with
byte-for-byte identical lengths, which is the signature of an unscrubbed
fixed-width random value rather than a content change. It was the per-session
CSRF token, which base.html emits in three shapes; the harness only knew the
<input value=…> one and missed <meta name="csrf-token"> and
window.CSRF_TOKEN. Diff the raw bodies before concluding anything from a
digest mismatch — the length equality was the tell.
Still over the cap — follow-up needed. logs_data.py (1116),
stats.py (693) and alerts.py (648) are each dominated by one enormous
function: _load_logs_data is 1,057 lines, stats is 645, alerts is 385.
Module-level splitting cannot shrink a single function, so these need
collaborator extraction with a characterization harness built first — the 2e /
3a-ii technique, not this one. Tracked as Phase 3b-ii.
3b-ii. _load_logs_data → webapp/public/logs_sources/ ✅
The first of the three modules 3b left over the cap. _load_logs_data was
1,057 lines in one function: a seventeen-way if/elif chain on log_type,
where each branch queried its own table (or the systemd journal, or the
compliance ledger, or an FCC report builder) and shaped rows into the generic
log dict the template renders.
The seam was already drawn by the branches, so the interesting design question
was not where to cut but what contract the pieces share. Every loader now
takes one LogQuery and returns one LogPage:
| Piece | Role |
|---|---|
LogQuery |
log_type, limit, service_filter, action_filter, logger. The last two are read by exactly one loader each, but travel with every query so the dispatch table can stay uniform. |
LogPage |
display_name, rows, report_meta. Only the report loader populates the third field — it is what the template keys off to choose the columnar layout. |
resolve_loader |
Exact-match lookup, falling back to the report_ prefix. Returns None for an unknown type, which is how the dispatcher reproduces the old chain "reaching the end without matching". |
| New module | Lines | Contents |
|---|---|---|
common.py |
83 | LogQuery, LogPage, MIN_LOGS_PER_CATEGORY, timestamp_sort_key |
database.py |
329 | system, polling, polling_debug, audio, audio_metrics, audio_health, gpio |
eas.py |
304 | eas_messages, decoded_audio, manual_activations, received_alerts |
audit.py |
130 | audit (with the SQL-level action filter), compliance |
reports.py |
146 | The six FCC report kinds and the report_meta envelope |
services.py |
90 | The systemd journal category |
aggregate.py |
112 | The "All Logs" merge, fault-tolerance and truncation |
aggregate_collectors.py |
346 | The eleven per-category collectors the merge runs |
__init__.py |
81 | LOADERS, resolve_loader |
webapp/public/logs_data.py: 1116 → 80 lines, now only a dispatch, with
MIN_LOGS_PER_CATEGORY re-exported so the old import path still resolves.
aggregate.py is deliberately not a loop over the focused loaders, however
much it looks like one. Three things differ, and each would be a visible
regression if "de-duplicated":
- The row shape. Merged rows carry a
categorylabel and a trimmeddetailspayload; the focused views carry the full payload and analert_identifier. - Fault tolerance. Each merged category is wrapped so one broken table cannot take the whole page down; in a focused view a failure should surface.
- The cap. Each category is limited separately before the merge, so a chatty table cannot crowd the others out.
The one thing that was de-duplicated is get_sort_key, which the all and
services branches each carried a private copy of — verified ast.dump()
-identical before collapsing them into common.timestamp_sort_key.
Verification — the harness came first, as in 2e and 3a-ii. The function had no test coverage at all, which ground rule 5 says to find out before moving code rather than after.
tests/test_public_logs_data.py(79 tests) was written and run green against the pre-refactor function, asserting the full returned triple — display name, every key of every row, and the report metadata — for everylog_type, plus the level-derivation rules, the fallback strings, and the limit arithmetic.- Confirmed discriminating before being trusted: 15 deliberate mutations, 15 caught, 0 survived, 0 misapplied, each failing only the tests that cover it.
- A dump script rendered the loader's full output for 115 scenarios (23 log types × 5 parameter combinations) against a git worktree at the pre-refactor commit and against the working tree: 0 differing scenarios, 26 distinct outputs. The script prints which module it patched, so a silent fallback could not masquerade as a match.
- The mutation run was repeated against the refactored structure — 16 mutations in their new homes, 16 caught — because passing on one shape says nothing about the other.
Three traps this phase hit.
cdpersisted between the two dump runs, so the "after" run executed in the pre-refactor worktree and reported a perfect match against itself. Thepatched:line is what caught it: it namedwebapp.public.logs_data, a module that no longer carries the systemd collaborators after the split. This is precisely the failure the 3a-ii note predicted — print what the harness bound to, and check it.- A source restore does not invalidate the bytecode cache. The mutation
that swapped two branches of the
audio_healthlevel rule reordered text of identical length, andshutil.moverestored the backup's mtime — so CPython saw a matching(mtime, size)and kept running the mutated.pycafter the file had been restored. The symptom was one test failing in every subsequent mutation, which reads exactly like a flaky test. Purge__pycache__on restore. (Note the family resemblance to the 3b lesson: equal length is a tell, not a coincidence.) - Seeding fires audit listeners. Inserting
EASMessage/ManualEASActivationrows writeseas.broadcast/eas.manual_activationaudit entries stamped with the real wall clock, which then showed up as four "differing" audit scenarios. They were genuinely different between runs and had nothing to do with the refactor — the fix is to seed the audit trail deterministically after everything else, not to scrub the diff.
All nine modules are under the 400-line guidance. aggregate.py came out
at 418 on the first pass and was split again along the collector boundary
rather than being recorded as a follow-up.
3b-ii (cont). stats() → webapp/public/stats_sections/ ✅
The second of the three. Where _load_logs_data was a dispatch — exactly one
branch runs per request — stats() is an accumulation: seventeen
try/except blocks in a row, each running a few queries, writing into a shared
stats_data dict, and declaring its own fallback so one failing query cannot
lose the whole dashboard. The repeated try/rollback/log/fallback shape is the
thing worth extracting, and it becomes the StatsSection contract:
| Piece | Role |
|---|---|
StatsSection |
collect, fallback, error_message. |
collect(stats_data) -> fragment |
Reads the dict built so far — three sections divide by total_alerts — and returns only its own keys. |
run_sections |
Runs each in order; on failure rolls back, logs, and substitutes the fallback. The rollback is not optional: a failed query leaves the session aborted, so without it every later section fails too. |
| New module | Lines | Contents |
|---|---|---|
common.py |
81 | The contract and the runner |
alerts_overview.py |
215 | Headline counts, boundary/status/severity/event breakdowns, urgency, certainty, message types |
timeline.py |
237 | Hour/weekday/month/year buckets, the recent-alert feed |
coverage.py |
143 | Most-affected boundaries, durations, coverage overlap |
broadcast.py |
173 | Forwarding rate, manual activations, received alerts, latency, relays |
polling.py |
183 | Poller success rate, timings, trend |
__init__.py |
100 | The ordered pipeline and build_stats_data |
webapp/public/stats.py: 693 → 50 lines. Every module is under the
guidance.
The pipeline order is declared explicitly in __init__.py, not implied by
module grouping. It is load-bearing — EAS_FORWARDING, MESSAGE_TYPES and
COVERAGE_OVERLAP each divide by total_alerts — and keeping the original
sequence also keeps the error-log order unchanged on a broken database.
Verification. Harness first, as always: 32 characterization tests written
and green against the pre-refactor handler, reaching the payload by replacing
render_template with a capture and calling the view directly (the app's
global login redirect otherwise gets in the way). Mutation-checked at 17/17
before the split and 17/17 after. The rendered payload was then compared
key-by-key against a worktree at the pre-refactor commit across an empty and a
populated database: 70 keys, 0 differences, with the dump printing the line
count of register so the two runs could be proven to be different code.
Two findings worth recording.
- 29 of the 31 trailing
setdefaultcalls were dead. Each section already set its keys on both its success and its failure path, so the defaults could never fire. Onlyavg_durationsandlifecycle_timelinehad no producer at all. This was established with a static check, not by eye, after a mutation deleting one of the redundant defaults survived — the right outcome for a semantically null change, and the signal that sent me looking. The section contract now guarantees the property structurally, so only the two real defaults remain. - Retargeting mutations is part of the job. Seven of the seventeen stopped
applying after the split, because the code they matched had legitimately
been reworded —
- 5became- MAX_YEAR_LOOKBACK, the success test became_is_success. "Not applied" proves nothing, so each was rewritten against its new home before the run counted.
3b-ii (cont). alerts() → webapp/public/alerts_page/ ✅
The last of the three, and a third distinct shape. _load_logs_data was a
dispatch (one branch runs per request); stats() was an accumulation
(all sections run, contributing to one dict); alerts() is a pipeline —
each stage consumes what the last produced, so the modules are stages and the
seams are sequential.
parse_filters request args → AlertFilters (clamped, allow-listed)
load_filter_options dropdown values + headline counts
build_alert_query AlertFilters → a filtered, sorted query
paginate_alerts one page of rows, with a fallback
build_audio_map generated EAS audio for those rows
load_manual_messages recent operator-originated activations
backfill_ipaws_audio lazy extraction for pre-extractor alerts
| New module | Lines | Contents |
|---|---|---|
filters.py |
210 | AlertFilters, the sortable-column allow-list, parsing and clamping |
query.py |
133 | Search, exact filters, date range, VTEC, visibility, sorting |
pagination.py |
101 | MockPagination, paginate-with-fallback |
options.py |
88 | Filter dropdown values and headline counts |
enrichment.py |
160 | Audio map, manual activations, lazy IPAWS backfill |
pdf_export.py |
150 | The export's query, blocks and filter summary |
__init__.py |
94 | build_alerts_page |
webapp/public/alerts.py: 648 → 112 lines. Phase 3b-ii is complete —
every module in webapp/public/ is now within the guidance.
One duplication left in place, deliberately. The page and the PDF export
have separate query builders, and the export's is a strict subset: it has no
date-range, VTEC or superseded handling, so a PDF exported from a filtered page
can contain rows the page was hiding. Unifying them would have been the
obvious tidy-up and would have silently changed what operators get in a
compliance export. It is documented at the top of pdf_export.py and pinned by
test_pdf_export_ignores_filters_the_page_supports — recorded as current
behaviour, not endorsed.
Verification. 73 characterization tests written and green against the
pre-refactor handlers, reaching the boundary by replacing render_template and
generate_pdf_document with captures. Mutation-checked 26/26 before and 26/26
after — with all 26 needing retargeting in between, since almost every
mutated line had moved or been reworded. Template kwargs and PDF arguments were
then compared across 34 query strings on both routes against a worktree at the
pre-refactor commit: 68 scenarios, 37 distinct outputs, 0 differences.
A dead variable removed. The PDF export captured per_page and never used
it — a standing ruff F841. ruff check --select F,E9 is now clean across the
whole of webapp/public/.
The operational lesson from this phase is about the harness, not the code. The mutation runner rewrites files in place and restores them afterwards. It was started in the background while new modules were still being written into the same directory, and it duly picked the half-written files up as mutation targets — backing up, mutating and "restoring" them, which left one committed file altered and one new file silently carrying a mutant's arithmetic. Nothing was lost (git had the committed file; the new one was caught by reading the diff), but the rule is simple: never run a file-rewriting harness in the background against a tree you are still editing. Run it in the foreground, or against a worktree copy.
3c. webapp/routes_settings_radio.py → webapp/radio_settings/ ✅
The largest remaining Flask module, and a hybrid of the two shapes seen so far:
eight module-level helpers (like 3a) plus a 2,114-line register() with 26
handlers nested inside it (like 3b). The 3b closure analysis is what made it
tractable — walking the AST for Name nodes resolving to register's scope
showed every handler captures only app and route_logger:
| Handlers | Closure variables captured |
|---|---|
| 22 | app, route_logger |
| 2 | app |
| 1 | (none) |
| 1 | app, route_logger, _decode_soapysdr_error (moved alongside it) |
So the bodies move verbatim into topic modules that each keep their own
small register(app, route_logger) — no reindentation anywhere.
| New module | Lines | Contents |
|---|---|---|
deps.py |
129 | The injectable seams and the capture constants |
sdr_client.py |
64 | _send_sdr_command |
serialization.py |
162 | _receiver_to_dict, _make_offline_status |
payload.py |
291 | _parse_receiver_payload |
sync.py |
174 | _sync_radio_manager_state, _sync_audio_monitors |
routes_pages.py |
66 | The two rendered pages |
routes_receivers.py |
259 | Receiver CRUD |
routes_receiver_control.py |
241 | Restart, audio-monitor wiring |
routes_devices.py |
265 | Discovery, capabilities, frequency validation |
routes_presets.py |
49 | Built-in tuning presets |
routes_signal.py |
355 | Waveform, spectrum |
routes_monitoring.py |
64 | Dashboard status, diagnostics summary |
routes_diagnostics_status.py |
306 | Status, SoapySDR error decoding |
routes_diagnostics_capture.py |
275 | IQ capture and download |
routes_diagnostics_waterfall.py |
315 | The waterfall view |
routes_diagnostics_analyze.py |
386 | Capture analysis, auto-gain sweep |
__init__.py |
105 | register, fanning out in the original order |
webapp/routes_settings_radio.py remains as a 52-line shim. All 17 modules
are within the guidance.
The one deliberate deviation from verbatim motion is deps.py. The radio
tests inject fakes for get_redis_client, get_radio_manager,
_log_radio_event, RADIO_CAPTURE_DIR and the other capture constants — names
used by up to 19 definitions each, which after the split are spread across ten
modules. A from .deps import get_redis_client in each would snapshot the real
object at import time, so a stub set in one place would be silently ignored
everywhere else. Route modules therefore go through the module
(deps.get_redis_client()), giving exactly one patch point, and a module added
later honours the stub automatically. 51 references were routed this way; every
other line is untouched.
The shim deliberately does not re-export those names, so a
monkeypatch.setattr aimed at the old location raises AttributeError instead
of quietly patching something nothing reads. That is what made the retarget
safe: all 11 patch sites failed immediately and pointed at the real seam.
Verification.
- AST equality — 34 of 34 moved definitions
ast.dump()-identical, plus the every-non-blank-line-placed-exactly-once assertion (2,394 lines placed; the 54 unplaced are the licence header, the import block, theregistersignature and__all__). - URL map — every rule, endpoint and method set diffed against a worktree at the pre-split commit: 549 rules, 0 differences. Mutation-checked by dropping one module from the registration tuple, which the diff caught (3 missing rules).
- Logger identity —
_module_loggerwaslogging.getLogger(__name__), which would have silently becomewebapp.radio_settings.deps. It is pinned to the pre-split"webapp.routes_settings_radio", andregister()derives the samegetChild("routes_settings_radio")once and passes it down, so every log line keeps its original name.
Three traps this phase hit, all in the tooling rather than the code.
partition(")\n")found the licence header, not the import block. The copyright line ends(KR8MER), so a naive split to separate imports from body landed on line 3 and the "body" rewrite corrupted every module's imports. Split on a sentinel you actually control.- A regex cannot rewrite a name safely; it needs scope. Three functions
import
get_redis_clientfromapp_core.redis_clientlocally, shadowing the module-level import fromapp_core.extensions— a different object. A regex rewrote both the import aliases (from x import deps.get_redis_client, a syntax error ruff caught) and those shadowed call sites, which would have been a silent behaviour change. The rewrite became AST-driven, skipping any function that rebinds the name. ast.walk()does not respect scope boundaries. The first scope-aware version collected local bindings withast.walk(fn), which descends into nested functions — soregister()inherited every handler's local import and appeared to shadow names it never touches. Since shadowing is inherited downward, that silently skipped the rewrite for every handler. The symptom was a plausible-looking count (46 rewrites instead of 51). Cut nestedFunctionDef/Lambda/ClassDefoff explicitly when computing a scope.
3d. webapp/admin/api.py → webapp/admin/api/ ✅
The easiest of the four web-layer splits so far, and worth recording why:
3b and 3c were each one enormous register() with the handlers nested inside
it, so the unit of motion was a closure. This file was 21 ordinary top-level
definitions decorated with @api_bp.route, which is the 2a–2c shape. Every
definition moved verbatim.
| New module | Lines | Contents |
|---|---|---|
blueprint.py |
45 | The shared api_bp |
hostinfo.py |
81 | Host CPU sample cache, primary-IP detection |
motion.py |
98 | The NWS storm-motion parameter parser |
county.py |
164 | The county-wide heuristic and location terms |
display_data.py |
322 | One alert flattened for the detail views |
routes_geometry.py |
176 | /api/alerts/<id>/geometry |
routes_alert_detail.py |
337 | /alerts/<id> |
routes_alert_export.py |
265 | PDF, social-share image, IPAWS audio |
routes_alerts_list.py |
321 | /api/alerts, /api/alerts/historical |
routes_boundaries.py |
141 | /api/boundaries |
routes_system.py |
281 | /api/system_status, /api/system_health |
routes_system_history.py |
136 | /api/system_health/history |
routes_smart.py |
165 | /api/smart_diag |
__init__.py |
95 | register_api_routes, side-effect imports |
blueprint, county, hostinfo, motion leaves
display_data -> motion
routes_geometry -> blueprint, county
routes_alert_detail -> blueprint, county, display_data
routes_alert_export -> blueprint, display_data
routes_alerts_list -> blueprint, county
routes_boundaries -> blueprint
routes_system -> blueprint, hostinfo
routes_system_history -> blueprint
routes_smart -> blueprint
All 14 modules are within the guidance. register_api_routes(app, logger) is
unchanged and is the package's only __all__ entry.
Verification. 21 of 21 top-level definitions are ast.dump()-identical,
every non-blank line of the original lands in exactly one module, and the URL
map is unchanged at 549 rules, 0 differences against a worktree at the
pre-split commit. The 11 original lines that appear nowhere in the package are
the re-rendered import statements, the module docstring and the
Blueprint(...) line.
Derive import blocks; do not write them. Hand-writing them produced 127 ruff errors on the first attempt — a mix of F401 (imports the module does not need) and F821 (names it does need and did not get). The generator now narrows each of the original's import statements to the names a module actually uses, which keeps the original grouping and gets the answer right.
Compute free variables with symtable, not by counting ast.Name nodes.
The first derived version still emitted two errors, because
_extract_alert_display_data has a local variable named desc and name
counting read that as a use of from sqlalchemy import desc (F401 + F811).
This is the third appearance of the same bug — Phase 4a hit it with a parameter
named text, and 3c with ast.walk() ignoring scope boundaries. symtable
answers the question directly: at module scope keep names that are referenced
and never bound, and inside a function keep the symbols is_global() reports.
Three dead imports fell out — flask.current_app,
app_utils.vtec.extract_vtec_identity and optimized_parsing.json_dumps were
imported by the single-file module and used by none of it. Deriving the import
blocks removes this class of cruft for free.
A blueprint's root_path changes even when import_name does not. The
checklist already says to pass the package name rather than the module's
__name__ (Phase 3a), and blueprint.py does — __package__ is exactly
webapp.admin.api. But import_name now names a package rather than a
module, so Flask derives root_path as webapp/admin/api where it used to be
webapp/admin. It is inert here (no template_folder, no static_folder, no
open_resource), and a test pins that so adding one later is deliberate. Any
split that converts a module into a package of the same name inherits this.
Tests that read the source as text break on the move, and the failure mode
is asymmetric. tests/test_api_field_fixes.py and
tests/test_detect_county_wide_false_positive.py both open() the API source
and assert against the text. Pointed at a deleted file they fail loudly, which
is fine — but pointed at a shim that no longer holds the code they would pass
vacuously, which is worse than deleting them. Both now scan the package
directory. Add this to the pre-split checklist alongside the
spec_from_file_location item: grep the tests for the module's path, not
just its import name.
A new test that passes alone can still fail in the suite — pytest imports
every test module during collection. The new putnam_ohio fixture did
import app_core.location and then reached through app_core.location. That
works in isolation but raises AttributeError: module 'app_core' has no attribute 'location' under a full run: the submodule attribute is only set on
the parent package by the import that first executes the submodule, and by
collection time something else has already put it in sys.modules. Use
importlib.import_module('app_core.location'), which returns
sys.modules[name] and never touches the parent. tests/conftest.py documents
the identical trap for app_core.auth. Run a new test file inside the whole
suite, not just on its own — the failure only appears there, and the first
full run of this phase reported 11 errors that a targeted run could not
reproduce.
A test that mirrors the logic cannot catch a change to the original.
test_detect_county_wide_false_positive.py reimplemented both heuristics
locally and then grepped the source to confirm the original still matched —
which is what the two brittle source checks were doing there. _detect_county_wide
only reads alert.area_desc and alert.raw_json, so
tests/test_api_package.py now exercises the real function against a stub
alert and a monkeypatched get_location_settings. Both heuristics are
mutation-checked (2 failures each). Writing those cases also surfaced that
state_code holds the two-letter postal code (OH), not the spelled-out
state — the _multi_county_list guard counts ", <state_code>" occurrences,
so a fixture using "ohio" reinstates the false positive it was written to
prevent.
3e. webapp/admin/certbot.py → webapp/admin/certbot/ ✅
The same shape as 3d — 22 ordinary top-level definitions — so the motion was easy. What made this one interesting is that it carried both hazards the checklist warns about, and it had no test coverage at all to catch either.
| New module | Lines | Contents |
|---|---|---|
blueprint.py |
40 | certbot_bp, the domain/email patterns |
log.py |
43 | The one logger, named webapp.admin.certbot |
failures.py |
50 | certbot exit → operator-readable explanation |
routes_pages.py |
50 | The rendered settings page |
nginx.py |
93 | Is nginx up, and bring it up |
routes_status.py |
119 | Certificate status and the log tail |
routes_settings.py |
134 | Reading and writing stored settings |
staging.py |
149 | Detect and clear staging certificates |
paths.py |
157 | The writable certbot_data tree |
routes_actions.py |
228 | Auto-renewal, download, install |
routes_obtain.py |
273 | Obtain dry-run, domain test |
routes_renew.py |
286 | Renew dry-run, real run |
install.py |
293 | Install a certificate into nginx |
routes_obtain_execute.py |
449 | The real certificate run — over, see below |
__init__.py |
105 | register_certbot_routes, side-effect imports |
log, blueprint leaves
paths -> log
failures -> paths
nginx -> log
staging -> log, paths
install -> log, nginx, paths
routes_pages -> blueprint, log
routes_settings -> blueprint, log
routes_status -> blueprint, log, paths
routes_obtain -> blueprint, log, paths
routes_obtain_execute -> blueprint, failures, install, log, nginx, paths, staging
routes_renew -> blueprint, failures, log, paths, staging
routes_actions -> blueprint, install, log, paths
The __file__ hazard fired, exactly as the checklist predicted.
CERTBOT_BASE_DIR = Path(__file__).parent.parent.parent / 'certbot_data' —
three hops from webapp/admin/certbot.py to the repository root, but only two
thirds of the way from webapp/admin/certbot/paths.py. Unfixed it resolves to
<repo>/webapp/certbot_data, and nothing raises: certbot builds a fresh
empty tree there and every existing certificate appears to have vanished. This
is the same failure shape as Phase 2b's brand logo, and the same reason
AST-equality cannot see it — the code really is identical, it just means
something else. Fixed to four hops, pinned by a test asserting the resolved
value, and a second test rejects any new
Path(__file__).parent.parent.parent added at this depth.
A module-level logger is a __name__ hazard too, not just Blueprint and
getLogger in the checklist sense. One logging.getLogger(__name__) served
all 92 call sites, so per-module loggers would have renamed every record from
webapp.admin.certbot to webapp.admin.certbot.<submodule> — invisible in
tests, but it breaks a journald grep or a log filter keyed on the old name.
There is one log.py holding getLogger(__package__) and every module
imports logger from it.
Whether a by-value logger import is safe depends on the registration
hook. 3a needed a fan-out list because register_audio_ingest_routes
rebinds the module's logger; here register_certbot_routes only writes one
line through the logger it is handed and never rebinds, so from .log import logger is safe. Check which kind you have before copying either pattern —
the fan-out is unnecessary machinery when nothing rebinds, and a by-value
import is silently wrong when something does.
routes_obtain_execute.py was 449 lines — over, at the time.
obtain_certificate_execute is a single 387-line try block. Module-level
splitting cannot shrink one function; that needs collaborators extracted from
the body, which is behavioural and needs the behaviour pinned first. Doing
that on a module with zero existing coverage is its own piece of work.
Tracked as Phase 3e-ii, landed 2026-09-18 — see below.
Zero coverage is worth stating before the move, not after. Ground rule 5
says to run the tests that cover the file and name them in the commit message;
here there were none, on the code that drives certificate issuance. The split
added tests/test_certbot_package.py — 11 tests, all three structural guards
mutation-checked.
Verification. 22 of 23 definitions ast.dump()-identical; the one
difference is register_certbot_routes, retyped into the package __init__,
whose docstring lost the trailing whitespace on one blank line. URL map
unchanged at 549 rules, 0 differences, 14 certbot endpoints intact, and all
four CERTBOT_* paths resolve to their pre-split values.
3e-ii. obtain_certificate_execute → obtain_validation.py + obtain_methods.py ✅
The last known-exception module in the whole 400-line audit. Unlike the
module-level splits above, obtain_certificate_execute was one 387-line
try block — a dispatch, in the 3b-ii sense: pre-flight validation and
prerequisite checks shared by every request, then exactly one of three
near-identical certbot invocations (standalone, nginx, webroot) runs
per request, each shelling out to certbot and (for two of the three) nginx.
| New module | Lines | Contents |
|---|---|---|
obtain_validation.py |
108 | _validate_obtain_request (enabled/domain/email/pattern/method checks), _check_certbot_installed |
obtain_methods.py |
341 | _obtain_standalone, _obtain_nginx, _obtain_webroot |
routes_obtain_execute.py: 454 → 99 lines — parse the request, validate,
clear stale locks, check certbot is installed, handle a staging→production
cert switch, then dispatch through a {'standalone': ..., 'nginx': ..., 'webroot': ...} lookup table. No known exceptions remain in the certbot
package's size guard.
Zero coverage, same as 3e itself. tests/test_certbot_obtain_execute.py
(28 tests) is the module's first-ever test, written and run green against
the pre-refactor handler before anything moved. The permission decorator is
bypassed via __wrapped__ (Phase 3h's technique) rather than standing up an
authenticated Flask test client, since this file is about the handler's own
control flow, not the auth layer.
The retargeting trap, avoided by not needing it. subprocess.run and
time.sleep are the true I/O boundary and are patched globally
(patch("subprocess.run", ...)), the same reasoning as psutil in 4a-ii:
both are shared singleton modules, so a global patch reaches every module
that calls them regardless of which one ends up owning the call after a
split. That sidesteps the retarget entirely for those two — but the
higher-level collaborators (_check_nginx_status,
_install_certificate_internal, _explain_certbot_failure,
_ensure_webroot_directory) are ordinary functions imported by value,
so those patches still had to move from routes_obtain_execute to
obtain_methods once the method bodies did.
A mutation sweep (20 mutations) caught 18 on the first pass; the other two were the same shape of isolation gap 4a-ii hit, not missing coverage.
- The standalone port-in-use test asserted
"already in use" in body["error"]against a mocked raw error message that already contained "already in use" verbatim — so the assertion passed whether or not the augmentation branch that adds "Another process may be using it" actually ran. Fixed by asserting on text only the augmented wrapper adds. - The webroot method's own "Permission denied" augmentation branch (as
opposed to its sibling "No such file or directory" branch, which was
tested) had no test at all. Added
test_webroot_method_augments_permission_error.
No __file__ hazard — neither new module resolves a path relative to
its own depth; both new modules sit at the same directory depth as their
siblings, so paths.py's existing CERTBOT_* constants are unaffected.
Verification. All 28 new tests and the existing 10-test
test_certbot_package.py suite pass unchanged (the latter's size-guard test
was updated to drop routes_obtain_execute.py from known_exceptions, per
its own comment: "if the exception ever gets fixed, this test should stop
excusing it"). grep -n "__file__" across both new modules is empty.
3f. webapp/admin/maintenance.py → webapp/admin/maintenance/ ✅
31 top-level definitions, none over 250 lines, no module-level calls — the
3d/3e shape, and every one of the 15 modules landed under the guidance.
31/31 ast.dump()-identical, URL map unchanged at 549 rules / 0
differences.
| New module | Lines | Contents |
|---|---|---|
blueprint.py |
28 | maintenance_bp |
paths.py |
41 | repo_root |
routes_poll.py |
53 | Out-of-band feed poll |
serialization.py |
65 | A CAP alert row for the admin views |
eas_settings.py |
85 | The singleton EAS settings row |
routes_env.py |
122 | The .env editor |
routes_operations.py |
140 | Operation status, backup, upgrade |
routes_database.py |
153 | DB health and optimize |
routes_expiry.py |
162 | Mark and clear expired alerts |
operations.py |
165 | Backup/upgrade progress state and its lock |
routes_location.py |
178 | Location settings, filtering, FIPS lookup |
routes_import.py |
249 | Manual single-alert import |
routes_eas_settings.py |
259 | The EAS settings page |
routes_alerts.py |
264 | Admin alert list and detail |
noaa.py |
271 | The api.weather.gov client |
__init__.py |
118 | register_maintenance_routes, __all__ |
The __file__ hazard fired again — second phase running. repo_root
resolves tools/create_backup.py and tools/inplace_upgrade.py, is passed as
their cwd, and locates the .env the environment editor writes. One hop
short and all three point into webapp/. Two consecutive files have now
carried this; assume the next one does too and grep for it first.
__all__ is not the public surface — the tree is. get_operation_status
is a route handler, absent from this module's ten-name __all__, and imported
by app_core/websocket_push.py inside a function whose caller swallows
failures. Listing exports by hand is what let AudioSourceConfigDB break; the
test AST-walks the repository for from webapp.admin.maintenance import … and
asserts each name resolves.
Two adjacent blueprints, two opposite prefix conventions. maintenance_bp
is registered with no url_prefix and writes /admin into every route
decorator. certbot_bp, in the sibling package, is registered with
url_prefix='/admin' and its decorators must not repeat it. Both are correct;
either one "corrected" to match the other silently doubles or drops the
prefix. The package tests now pin the resulting rules on both sides.
Check a retyped function against the original, not against memory.
register_maintenance_routes is the one definition that does not move as a
slice — it goes into the package __init__. Writing it from memory produced
app.register_blueprint(maintenance_bp, url_prefix='/admin'), which is the
certbot convention and would have moved all 17 routes to /admin/admin/….
The AST check would have caught it, but only after the fact; reading the six
original lines first is cheaper.
3h. webapp/routes/alert_verification.py → package ✅
The second-hardest shape in Phase 3, and the one that best shows why the
closure has to be measured. 687 of 1,668 lines were one
register(app, logger) holding eight helpers and seven handlers.
| New module | Lines | Contents |
|---|---|---|
errors.py |
26 | The self-test error type |
samples.py |
31 | The bundled sample recordings |
routes_export.py |
77 | The CSV export |
temp_audio.py |
77 | Decode an upload, then persist |
helpers.py |
89 | The capture-free helpers from register |
decode_serialization.py |
100 | A decode result across the thread boundary |
composite_audio.py |
124 | Stitching segments into one file |
routes_api.py |
141 | Progress, header decode, decode audio |
routes_self_test.py |
170 | The end-to-end self test |
audio_buffer.py |
174 | PCM extraction and caching |
routes_operations.py |
203 | Starting an async run |
routes_page.py |
269 | The verification dashboard |
progress.py |
289 | On-disk progress and result stores |
eas_detection.py |
316 | Locating every EAS burst in a file |
__init__.py |
123 | register, fanning out to the route modules |
Two kinds of motion, chosen by symtable rather than by eye. Four nested
helpers capture nothing from register, so they were dedented to module scope
— semantically identical, and ast.dump is unchanged because it ignores
col_offset. The other four capture route_logger, repo_root or app, so
each topic module keeps its own register(app, logger) rebuilding exactly
those locals. Emitting both locals unconditionally left an unused repo_root
in three modules (F841); emit only what that module's handlers actually
capture.
The silent-no-op hazard, demonstrated rather than asserted. The checklist
has said since 3a not to re-export mutable globals a test patches. This split
is where that was actually measured: with _progress_dir and friends
re-exported and the async tests patching the package, all six tests still
pass — writing to the real temp directory instead of tmp_path. The patch
rebinds a copy; OperationResultStore keeps reading the original. Nothing
fails, and the test quietly stops testing isolation. Left off the package, the
same call raises AttributeError naming the module. If you are ever unsure
whether a re-export matters, re-export it and run the tests: passing is the
bad outcome.
Group by the call graph, not by topic name. _process_temp_audio_file →
_detect_comprehensive_eas_segments → _build_composite_audio_segment is one
chain crossing three layers. Putting the two ends in one composite_audio
module — which their names invite — sandwiched eas_detection into an import
cycle. Python reports that as a partially-initialised module at startup, which
names the symptom, not the mistake. The generator now walks the emitted
imports and fails with the cycle spelled out; the package test does the same
so a later edit cannot reintroduce one.
Inspect handlers through __wrapped__. app.view_functions[name] is the
outermost decorator (require_auth), whose only free variable is the function
it wraps — the first closure assertions passed nothing but {'f'}. Unwrap
before reading co_freevars.
Do not assert co_freevars == () on a module-level function. It is always
empty; the test is tautological. The invariant that can actually break is
textual — a future edit inside a dedented helper reaching for route_logger
compiles fine and raises NameError only on the branch that runs it. Assert
on the source instead.
No __file__ hazard here: repo_root comes from app.root_path, so it
does not move with the module's depth. Worth checking for explicitly rather
than assuming, given 3e and 3f both had one.
Verification. 27 of 28 definitions — top-level and nested —
ast.dump()-identical; the one difference is register, deliberately
restructured to fan out. URL map unchanged at 549 rules, 0 differences,
all 7 endpoints intact.
3g. app.py (1869) — assessed, not started ⚠️
Do not plan this one as another package split. It is a different problem from every file in Phase 3 so far, and the difference is not visible from the line count.
app.py does not contain an application factory. It builds the app at
import time — app = Flask(__name__) at line 324 — and then configures it
with 103 module-level statements (52 Assign, 24 bare calls, 20 If, 6
Try, 1 AnnAssign) interleaved with the decorated handlers. create_app(),
at line 1839, is fifteen lines that update app.config, optionally call
initialize_database(), and hand back the module-level singleton. Its shape:
- 103 module-level configuration statements, against only 28 definitions
- 4 error handlers, 2 context processors, 2 template filters
before_request(200 lines),after_request(72),_record_traffic(120)initialize_database(192),_load_db_settings_into_config(71)- 4 Click CLI commands
- an
if __name__ == '__main__'block
The behaviour here is the statement order, and ast.dump() cannot verify
order. Every check this plan leans on — definitions compare equal, every
line lands once, the URL map is unchanged — would pass on a split that
reorders configuration and silently changes what the app does. Secret-key
resolution, SETUP_MODE, the database URL, session-cookie flags and the
background-service guards all read and write app.config in sequence, and
several are conditional on values set a few lines earlier.
What it actually needs, roughly in order:
- Turn the module-level singleton into a real factory first, as its own commit with no file movement. Until construction is a function, "where a statement lives" and "when it runs" are the same thing, and nothing can move safely.
- Characterize the resulting config: build the app under several
environment combinations (setup mode on/off, sqlite vs. postgres,
SKIP_BACKGROUND_SERVICES, secret key present/absent/placeholder) and snapshot the fullapp.configplus the registered extension list. That snapshot is the baseline a reorder is checked against — the URL map alone is not enough here. - Only then extract the handler groups, which are ordinary motion.
Step 1 is the whole risk. Budget it as its own phase, and do not start it in the same session as a routine split.
Phase 4 — Long-running services
Highest risk: these are the alert path. The plan's original assumption was
that each file is dominated by one very large class, needing mixins or
extracted collaborators rather than free functions, with behaviour pinned by
tests before anything moves. That assumption held for system.py (not a
god-class, landed as 4a) and did not hold for eas.py (also not a
god-class by the time it was split — see 4b). Check which shape a file
actually is, the way the Phase 3 table note already says to, before
assuming a characterization harness is the only way in. poller/cap_poller.py
is the one file here confirmed to still be a genuine god-class.
| File | Lines | Note |
|---|---|---|
poller/cap_poller.py |
4933 (was 3996) | CAPPoller is ~3,900 lines by itself, 59 methods — a genuine god-class. fetch / parse / persist / relevance / cleanup are the seams. Not started. |
app_utils/eas.py |
4246 (was 3848) | ✅ landed — see 4b. Turned out to be 48 mostly-independent functions plus two god-classes, not one large class as originally scoped. |
app_core/eas_storage.py |
2824 | Not started — not yet profiled for shape (god-class vs. free functions). |
sdr_hardware_service.py |
2275 | Not started. |
eas_monitoring_service.py |
2246 | Not started. |
app_utils/system.py |
2580 | ✅ landed — see 4a |
4a. app_utils/system.py → app_utils/system/ ✅
The one Phase 4 file that is not a god-class, and the plan's own note —
"mostly independent helpers — easier than it looks" — held up. 47 top-level
definitions shared the file, the internal call graph is strictly acyclic, and
grep -n "__file__" came back empty, so this is the 2a–2c technique with no
new hazards.
| New module | Lines | Contents |
|---|---|---|
common.py |
102 | SystemHealth, _safe_read_text, _safe_int, _coerce_int, _to_bool, _is_valid_temperature |
dependencies.py |
96 | _collect_dependency_versions |
services.py |
146 | _collect_systemd_services |
badges.py |
189 | get_distro_logo_url, _escape_shields_io_text, get_shields_io_badges |
osinfo.py |
131 | _collect_operating_system_details, _detect_virtualization_environment |
network.py |
111 | _collect_network_traffic, _select_primary_interface |
device_tree.py |
110 | DEVICE_TREE_CANDIDATES, _collect_device_tree_details and the three device-tree readers |
block_devices.py |
163 | _collect_block_devices, _simplify_block_devices |
hardware.py |
276 | _collect_hardware_inventory, _collect_usb_devices, _collect_cpu_details, _collect_platform_details |
disks.py |
99 | _iter_disk_devices, _detect_device_type, _nvme_controller_path |
smart.py |
429 | _collect_smart_health |
smart_fields.py |
202 | NVME_DATA_UNIT_BYTES and the smartctl/NVMe field extractors |
temperature.py |
177 | _collect_temperature_readings, _add_temperature_entry, _parse_temperature_value |
rtc.py |
127 | _collect_rtc_status |
subsystems.py |
147 | _HARDWARE_SUBSYSTEMS, _collect_hardware_subsystems, _collect_gps_status |
snapshot.py |
478 | _AUDIO_PROCESS_KEYWORDS, _is_audio_processing_process, build_system_health_snapshot |
common, dependencies, services, badges, network, device_tree,
disks, smart_fields, subsystems leaves
block_devices -> common
osinfo -> common
temperature -> common
rtc -> common
hardware -> common, block_devices, device_tree
smart -> common, disks, smart_fields
snapshot -> badges, dependencies, hardware, network, rtc,
services, smart, subsystems, temperature, osinfo
Verification. 48 top-level definitions, 48 ast.dump() matches, zero
differences, plus the every-non-blank-line-placed-exactly-once assertion.
ruff check --select F,E9 is clean on the package apart from one pre-existing
F841. All four production consumers were imported to confirm the shim resolves
(app_utils/__init__.py, app_core/system_health.py, webapp/admin/api/,
scripts/diagnose_smart.sh). 43 system-health tests pass.
The mutable-global rule paid for itself again. DEVICE_TREE_CANDIDATES is
a list that two tests replace wholesale. It is deliberately absent from the
package __init__, so both patches raised AttributeError instead of silently
patching a name nothing reads — the same loud-failure property that made the
3a retarget safe. They now patch app_utils.system.device_tree. The CPU test's
Path and psutil patches moved to app_utils.system.hardware for the same
reason.
One generator bug worth recording: a parameter name is not a use of an
import. The script that derived each module's import block from the names its
definitions reference treated the parameter of
_escape_shields_io_text(text: str) as a use of from sqlalchemy import text,
and gave badges.py (and device_tree.py, via a local) an import they do not
need. ruff --select F caught both as F811/F401. Only snapshot.py actually
issues SQL. Lint the generated package before trusting an inferred import
block — name-level inference cannot see scope.
Still over the cap at the time — follow-up needed. snapshot.py (478) and
smart.py (429) were each one function: build_system_health_snapshot was
406 lines and _collect_smart_health is 396. Module-level splitting cannot
shrink them, so they need collaborator extraction with a characterization
harness built first — the 2e / 3a-ii technique. Tracked as Phase 4a-ii.
snapshot.py landed 2026-09-18 (below); smart.py is still open.
4a-ii. build_system_health_snapshot → app_utils/system/{cpu,memory,disk_usage,processes,loadavg,db_health,status}.py ✅
The easier of the two Phase 4a-ii files, as predicted: the function's try
block was already a sequence of independent figures assembled into one dict
— CPU, memory, disk, network, process table, load average and a database
probe — followed by a status computation reading several of them. No single
piece touched another's state, so the seam was cutting each block out to its
own _collect_* function and reassembling the calls in snapshot.py.
| New module | Lines | Contents |
|---|---|---|
cpu.py |
51 | _collect_cpu_info |
memory.py |
42 | _collect_memory_info |
disk_usage.py |
74 | _collect_disk_info — distinct from disks.py's block-device enumeration for SMART |
processes.py |
135 | _AUDIO_PROCESS_KEYWORDS, _is_audio_processing_process, _collect_process_info |
loadavg.py |
34 | _collect_load_averages |
db_health.py |
85 | _collect_database_health |
status.py |
79 | _compute_overall_status |
network.py (extended) |
184 | added _collect_network_info, composing the existing _collect_network_traffic / _select_primary_interface with interface enumeration that used to live inline in snapshot.py |
snapshot.py: 480 → 158 lines, now pure orchestration. Every module in
app_utils/system/ is within the 400-line guidance for the first time since
this plan started.
Verified by characterization, not ast.dump() — dedenting an inline
block into a function is restructuring, not motion, in the same sense as 2e.
tests/test_system_health_snapshot_package.py (18 tests) was written and run
green against the pre-refactor function first, mocking only the true I/O
boundary (psutil, socket, os, the database session, the logger) plus
the twelve already-extracted sibling collectors this phase does not touch.
Re-run green after the split with no changes to the assertions.
The harness caught a real assertion bug in itself before the refactor even
started. running_processes reads proc.info["status"] before the
per-process try/except (NoSuchProcess, AccessDenied) — so a process that
disappears mid-scan still counts toward "running." A first-draft test
expected it excluded; running the harness against the untouched original
function failed immediately and pointed at the wrong assumption, not a bug.
A mutation sweep (14 mutations, one per collector) caught 12 on the first pass and found two of the test's own isolation gaps. Both are the same shape as the audit trail lesson in 3b-ii: a test that only checks a correct outcome, not the specific mechanism that produced it, cannot catch a mutation to a different mechanism that happens to produce the same outcome.
- The original critical-status test set CPU to 95% and forced a database
failure in the same call, then asserted
status == "critical"andany("CPU usage" in reason for reason in status_reasons). Both survive even with the CPU-critical threshold mutated to 190%, because the DB failure alone forcescritical, and the warning-branch reason text also contains the substring "CPU usage". Split intotest_status_critical_cpu_alone/test_status_critical_memory_alone(DB healthy, assertingstatus_reasons == ["CPU usage is 95.0%"]exactly) andtest_status_critical_db_alone_overrides_healthy_cpu. - The disk permission-error test put the denied partition at a fake mount
and the surviving one at
/— so ifexcept PermissionErroris mutated toexcept KeyError, the exception escapes to the outer handler, which falls back to querying/itself, and the assertion (mountpoint == "/") passes anyway. Moved the surviving partition to/dataand made the fakedisk_usageraiseAssertionErroron any query for/, so the fallback path is unreachable in a correct implementation and loudly wrong in a broken one.
No __file__ hazard, checked rather than assumed — grep -n "__file__"
across every touched file came back empty, and none of the collectors resolve
a path relative to their own module depth.
4a-ii (cont). _collect_smart_health → app_utils/system/{smart_command,smart_query,smart_status,smart_attributes}.py ✅
The harder of the two Phase 4a-ii files, also as predicted. Unlike
snapshot.py's independent figures, _collect_smart_health is one
425-line pipeline per device — locate smartctl once, then for each
device: build a query command, run it, validate/parse its JSON output, infer
a health status when smartctl's own verdict is absent, and populate ~35
result fields from the report. Each stage consumes what the last produced,
so — like 3b-ii's alerts() split — the modules are pipeline stages, not
independent collectors.
| New module | Lines | Contents |
|---|---|---|
smart_command.py |
85 | _find_smartctl_path, _build_smartctl_command |
smart_query.py |
115 | _query_smartctl (subprocess + its three exception branches), _validate_smartctl_output (exit-code/empty-output/JSON-parse validation) |
smart_status.py |
108 | _derive_overall_status — the exit-code-bitmask health inference fallback |
smart_attributes.py |
161 | _populate_identity_fields, _populate_smart_attributes (wraps the existing smart_fields.py extractors), _populate_nvme_extended_fields |
smart.py: 425 → 191 lines — _device_result_skeleton (the per-device
default dict) and _collect_one_device (the per-device pipeline, calling the
four new modules in sequence) plus the top-level _collect_smart_health
loop. Every module in app_utils/system/ is now within the 400-line
guidance — Phase 4a-ii is complete.
Existing coverage was narrower than it looked. tests/test_smart_health.py
(8 tests) covers the exit-code health-inference fallback in real depth, but
nothing else: smartctl discovery, every subprocess failure mode, output
validation, or field-extraction wiring. tests/test_smart_health_package.py
(24 tests) was written and run green against the pre-refactor function first
to cover the rest — deliberately not re-testing what smart_fields.py's
own extractors already have dedicated tests for elsewhere, only that
_collect_smart_health wires their results into the right keys.
A mutation sweep (18 mutations) caught 16 on the first pass; the other two
were structural, not assertion gaps, and both point at the same underlying
fact: _detect_device_type() (disks.py) currently only ever returns
"nvme" or "auto".
- The bit0 ("Invalid command line arguments") exit-code branch had no test
at all — bit1 and bit2 were covered, bit0 was an oversight. Added
test_nonzero_exit_with_empty_output_invalid_command_line. command.extend(["-n", "standby"])only fires whendevice_type_flag in ("ata", "sat")— a code path_detect_device_typecannot currently produce, so no route through_collect_smart_health()can reach it end to end. Since_build_smartctl_command()is now its own importable unit (it wasn't, before this split), it gets a direct unit test instead:test_standby_flag_added_for_ata_and_sat_device_types/_omitted_for_other_device_types. First attempt asserted"-n" not in command, which is ambiguous —sudo -n(don't prompt) is also"-n"— and passed for the wrong reason; fixed to check for the literal"standby"argument instead.
The retargeting trap fired exactly as predicted, immediately. Both
tests/test_smart_health.py and the new package test patched
"app_utils.system.smart.subprocess.run" and
"app_utils.system.smart.os.path.exists" — those attributes moved to
smart_query.py and smart_command.py respectively, so every one of those
23 patch sites would have become a silent no-op (real subprocess.run /
os.path.exists executing against the test host) had they not been
retargeted before the split landed. Caught before merging, not after, by
retargeting on the same pass as the extraction rather than treating it as a
follow-up.
No __file__ hazard — grep -n "__file__" across all four new modules
came back empty, matching snapshot.py.
Phase 4a-ii is complete. Every module in app_utils/system/ is now
within the 400-line guidance.
4b. app_utils/eas.py → app_utils/eas/ ✅
The single largest file in the tree at the time of the split: 4,246 lines,
grown from the 3,848 the plan was originally scoped against. The plan's own
table called this "config loading · SAME header build+describe · TTS
normalisation · audio generation · EASBroadcaster" and grouped it with the
god-class files. Profiling it first showed that description was stale.
48 top-level definitions shared the file — mostly independent functions,
the 2a/2b pure-motion shape — plus two large classes (EASAudioGenerator,
791 lines/5 methods; EASBroadcaster, 445 lines/5 methods) that are
god-classes in their own right but are a small fraction of the file. This
is the same lesson 4a already taught, generalized: profile a Phase 4 file
before assuming it needs a characterization harness. system.py wasn't a
god-class either; cap_poller.py, still unstarted, is confirmed to be one.
| New module | Lines | Contents |
|---|---|---|
indicators.py |
302 | Redis-backed broadcast/incoming-alert indicator state: set_broadcast_active, clear_broadcast_active, get_broadcast_state, set_incoming_alert, clear_incoming_alert, get_incoming_alert_state, the pub/sub nudge, BROADCAST_LEAD_IN/OUT_SECONDS |
config.py |
408 | load_eas_config — one 369-line function, the module's whole reason for being over the cap |
same_header_constants.py |
228 | Static SAME/NRSC-4-B lookup tables: originator descriptions, county abbreviations, purge-time/field tables, P-digit meanings |
same_header_decode.py |
367 | decode_county_originator, describe_same_header, score_decode_confidence |
same_header_build.py |
283 | build_same_header, build_eom_header, _collect_event_code_candidates, _duration_code, _julian_time, _normalise_same_codes |
tts_normalize.py |
360 | _normalize_text_for_tts (ALL-CAPS CAP text → sentence case), _load_pronunciation_rules |
tts_compose.py |
288 | _compose_message_text, _strip_awips_identifier, manual_default_same_codes |
tone_generation.py |
209 | _generate_tone, _generate_silence, _generate_station_terminator_samples, _normalize_audio_amplitude, the MDC1200 op-code helpers |
chime.py |
263 | _generate_chime (bell/beep/three-tone/QC-II/DTMF/MDC1200) and its DTMF frequency table — split out from tone_generation.py separately, see below |
wav_io.py |
118 | samples_to_wav_bytes, _wav_duration_seconds, truncate_wav_to_max_seconds, _write_wave_file |
broadcast_pid.py |
213 | The in-flight playback subprocess's PID/EOM Redis markers, _run_command, play_broadcast_audio |
audio_conversion.py |
349 | _fetch_embedded_audio, _convert_audio_to_samples, _resample_audio |
generator.py |
848 | EASAudioGenerator — known exception, see below |
broadcaster.py |
489 | EASBroadcaster — known exception, see below |
app_utils/eas.py is gone; app_utils/eas/__init__.py is the compatibility
shim, re-exporting every one of the 37 distinct names anything in the tree
(production or tests) ever imported from app_utils.eas — enumerated by an
AST scan across app_core/, app_utils/, webapp/, scripts/,
services/, tools/, poller/ and tests/ first, rather than trusted from
memory or from the pre-existing __all__ (which, as every prior phase has
found, is not the same thing as the real public surface).
Verification. 48/48 top-level functions/classes and 32/32 module-level
constants ast.dump()-identical before and after. Every non-blank original
line placed in exactly one new module, checked programmatically (the only
"missing" lines were the __all__ block, deliberately relocated to
__init__.py). Full test suite green: 3,362 passed, 282 skipped, 62
xfailed, 9 xpassed, 0 failures. Every production consumer
(app_core.audio.*, app_core.eas_processing, app_core.gpio_input_listener,
app_core.websocket_push, eas_monitoring_service, poller.cap_poller,
scripts.manual_eas_event, scripts.resend_eas_broadcast,
scripts.run_eas_broadcaster, services.gpio.alert_indicators,
tools.generate_sample_audio, webapp.admin, webapp.eas,
webapp.routes.broadcast_control, webapp.routes_monitoring,
webapp.routes_rwt_schedule) imported directly to confirm the shim
resolves. grep -n "__file__" across all 14 new modules is empty.
A confirmed internal-cross-call hazard — found by scoping, not by
accident. EASBroadcaster.handle_alert() calls build_same_header() and
clear_broadcast_active() as same-module bare names today, resolved through
the file's own global namespace. tests/test_gpio_centralized_keying.py
monkeypatches both at the app_utils.eas module level and then calls
broadcaster.handle_alert() directly, expecting the patch to intercept that
internal call — this is exactly the shape the checklist's "map the closure
before splitting a module that is one big function" item warns about, except
here it is two module-level names a class method calls bare, not a
closure. An AST scan across all 36 test files that reference
app_utils.eas in any form (import ... as, from ... import, or bare
import app_utils.eas — not just the subset a naive grep for the literal
substring "app_utils.eas." would catch) found this as the only two sites
where the patched name is also called from inside a class that is itself
moving. Retargeted to eas.broadcaster.build_same_header /
eas.broadcaster.clear_broadcast_active, and verified load-bearing by
temporarily reverting the retarget: the test fails with KeyError: 'present', because the recording wrapper that patch installs never gets
called — the real, unpatched function runs instead, silently.
subprocess and time are re-exported from the shim as modules, not
just the functions that use them. tests/test_gpio_dump_broadcast.py
patches eas_module.subprocess.Popen and tests/test_airchain_fringe_cases.py
patches 'app_utils.eas.time.sleep' via unittest.mock.patch's string-path
resolution — both require app_utils.eas.subprocess / app_utils.eas.time
to exist as attributes, which a package __init__.py doesn't get for free
the way a single-file module's own import subprocess did. Both are shared
singleton stdlib modules (the same reasoning as psutil in 4a-ii and
subprocess in 3e-ii): patching an attribute on the module object affects
every importer of it, so import subprocess / import time in the shim is
enough — no further retargeting needed for these two, unlike the two
same-module bare-name calls above.
Two rounds of missing-import bugs, both caught by actually importing the
result rather than trusting a script. The generator script derived each
module's free-variable set with a hand-rolled AST scope walker (symtable's
own get_identifiers() turned out to conflate "referenced in this scope"
with "bound in this scope" for module-level names, so it silently swallowed
genuine free references — P_DIGIT_LABELS in same_header_constants.py
came back with zero free names when it plainly needed one). The walker
also didn't attribute type-annotation expressions on bare module-level
AnnAssign constants (PRIMARY_ORIGINATORS: Tuple[str, ...] = (...)) to
the same free-variable pass as everything else, so several modules' derived
typing imports were incomplete (missing Dict, Optional, Sequence, or
Tuple depending on the module). Neither was caught by py_compile — a
missing name inside a function body only raises at call time, and a missing
name in a bare module-level annotation only raises at import time, which
py_compile doesn't execute. The fix in both cases was the same: actually
import app_utils.eas and iterate on the NameErrors it raised, rather
than trusting the derivation. This is the fourth appearance of the
symtable/free-variable lesson in this plan (4a, 3c, 3d, now 4b) — it
remains the single most repeated mistake in this effort.
chime.py split out from tone_generation.py after the fact, once line
counts included the license header and import block. The generator
script's own line-count estimates were computed on the bare function bodies
before headers/imports were added, which put tone_generation.py at an
estimated 395 lines — under the cap on paper, 446 once assembled. Rather
than accept the overage, _generate_chime (231 lines, self-contained
except for the DTMF frequency table it already owned) moved to its own
module, bringing both tone_generation.py (209) and the new chime.py
(263) comfortably under. Lesson for the next phase: budget for header +
import overhead (~35-40 lines per module) when estimating module sizes
up front, not just the bare body content.
generator.py (848) and broadcaster.py (489) are known exceptions.
EASAudioGenerator and EASBroadcaster are each one class; module-level
splitting cannot shrink a single class, the same conclusion 2a reached for
RBDSWorker/RBDSDecoder and 3e reached for obtain_certificate_execute
before its own 3e-ii follow-up. Bringing them under the cap needs
collaborators extracted from the class bodies — a behavioural change
needing a characterization harness built first, the 2e technique. Not
started; tracked as a follow-up. config.py (408) is the same shape at a
smaller scale: one 369-line function (load_eas_config) dominates a module
whose only other content is a 5-line helper and a 3-line constant list.
4c. poller/cap_poller.py — stateless methods extracted, god-class remains ⚠️ partial
CAPPoller is confirmed to be a genuine god-class, exactly as the plan
originally assumed for Phase 4 (unlike system.py and eas.py, which
turned out not to be): 4,933 lines total, CAPPoller itself ~3,978 of
them, 59 methods. Profiling by self usage — the same technique 2d used on
GPSManager — splits it cleanly:
| Group | Methods | Lines | Extractable as motion? |
|---|---|---|---|
Stateless (zero self references) |
9 | 143 | ✅ yes — all 9 moved here |
Stateful (touches self) |
50 | 3,711 | ❌ no — needs collaborators, the 2e technique |
The nine that moved, to poller/cap_alert_parsing.py (187 lines):
_select_cap_info, _extract_cap_event_codes, _extract_cap_parameters,
_summarise_geometry, _apply_cancellation_status, _validate_ugc_code,
_normalize_same_code (already @staticmethod), _coords_equal,
_safe_json_copy. cap_poller.py: 4933 → 4800 lines — still far over
the guidance, because module-level splitting only ever had 143 of the
4,933 lines to work with here. The other 3,711 lines are the real Phase 4c
work, not started.
Verified the same way 2d verified GPSManager's stateless methods: all
9 are ast.dump()-identical to their originals once self is stripped
from the signature and docstring indentation is normalized (one dedent
level, 8→4 spaces) — the exact same two normalizations 2d needed, for the
exact same reason (a class method's docstring and a module function's
docstring are conventionally indented one level apart, and that's a real
textual difference ast.dump() correctly reports, not a false mismatch).
19 internal call sites rewritten from self._method(...) to
_method(...) across cap_poller.py — found by grep -c "self\.$method(" per method rather than assumed, matching the "a truncated
search cannot prove a negative" lesson from 2d/2e.
One test file called a moved method through a live instance and had to
be retargeted. tests/test_ipaws_event_code_extraction.py built a
CAPPoller via object.__new__(CAPPoller) specifically to reach
poller._extract_cap_event_codes(...) — the only one of the 36 usages of
these 9 names anywhere in the tree (production or tests, AST-scanned) that
called through an instance rather than importing load_eas_config-style or
not touching them at all. Retargeted to import _extract_cap_event_codes
directly from poller.cap_alert_parsing and call it bare, dropping the now-
pointless _make_test_poller() instance entirely for that test class.
The ET/CAPAlert type hints needed real imports, not the
from __future__ import annotations shortcut. Three of the nine methods
type-hint parameters as ET.Element or CAPAlert without using either as a
runtime value (pure duck-typing via .findall()/getattr() in the
bodies). The first draft relied on from __future__ import annotations to
defer hint evaluation and skip importing either — py_compile and even a
real import poller.cap_alert_parsing both stayed silent about this, since
neither actually evaluates annotation expressions. ruff check (not
available in this sandbox by default — installed into a scratch venv to
get a real lint pass rather than trusting py_compile alone) caught both
as F821 immediately. Fixed by importing ET the same way cap_poller.py
itself resolves it (get_element_tree_module() from
app_utils.optimized_parsing) and CAPAlert directly from
app_core.models — both harmless from poller.cap_alert_parsing, which
already sits downstream of both in the dependency graph.
A CodeQL false-positive class worth naming for the next phase. The PR
was flagged with 3 "new" alerts: a polynomial-regex pattern in
tts_normalize.py, a log-injection pattern in audio_conversion.py
(both landed in 4b, not 4c), and a stack-trace-exposure pattern in
webapp/admin/pending_alerts.py — a file this session never touched at
all. All three were confirmed pre-existing: the first two are
ast.dump()-identical to code already on main before the split, and the
third has a byte-identical diff (none) against main. GitHub's PR-scoped
CodeQL analysis identifies an alert partly by file path, so moving a file
makes its pre-existing findings reappear as "new," and a data-flow source
passing through a moved file (here, load_eas_config) can do the same to
an untouched file downstream of it. main already carries ~100 open
alerts of these same rule categories elsewhere in the tree (including in
this plan's own already-merged certbot/obtain_methods.py split), and
main has no branch protection requiring CodeQL to pass — confirmed via
gh api repos/.../branches/main/protection (404) and gh pr view --json mergeable,mergeStateStatus (MERGEABLE/UNSTABLE, not blocked) rather
than assumed. Fixing a security-flagged regex or log call is a behaviour
change and does not belong in a pure-motion commit; documented here rather
than silently fixed or silently ignored, matching the plan's standing rule
for pre-existing issues found mid-phase.
What was left after 4c, before 4c-ii. The remaining 50 stateful methods,
3,711 lines, were the actual Phase 4c work — dominated by poll_and_process
(464 lines, 153 self references), __init__ (285 lines),
_insert_new_alert (225), fetch_cap_alerts (208), process_intersections
(170), and 45 more. These need the 2e technique: a characterization harness
built before any restructuring, the same way _handle_sentence was pinned
before its nmea.py extraction. Highest-risk work remaining in this entire
plan, now that system.py and eas.py are done and turned out not to need
it.
4c-ii. poller/cap_poller.py — CAP-geometry collaborator, 2e technique ✅
The first slice of the 50 stateful methods above to actually land, using the 2e technique this section called for: characterization tests written and run green against the pre-extraction bound methods first, extraction second, same tests retargeted at the extracted free functions third.
Profiling by self-attribute usage (not just reference count) found 12
methods touching only self.logger — set once in __init__, never
reassigned — or each other, never self.db_session or the poller's
zone/SAME-code configuration: _parse_ipaws_xml_feed, _convert_cap_alert,
_extract_cap_resources, _extract_area_details, _parse_cap_polygon,
_parse_cap_circle, _approximate_circle_polygon, _message_type_priority,
_alert_sort_key, _should_replace_alert, parse_cap_alert,
_count_vertices — a CAP-XML-to-GeoJSON-feature parsing concern, cleanly
separable from the DB/config-coupled methods (fetch_cap_alerts,
get_alert_relevance_details, _has_geometry_changed, ...) that stay
behind. Moved to poller/cap_geometry.py along with the module-level
_serialize_alert_for_sig helper (_convert_cap_alert's only caller) and
the MESSAGE_TYPE_PRIORITIES class attribute (actually a shared constant,
not instance state).
self.logger became an explicit logger parameter, not a new
logging.getLogger(__name__). Phase 3e's lesson applies here unchanged: a
fresh per-module logger would silently rename every record these 12
functions emit from poller.cap_poller to poller.cap_geometry, breaking
any journald filter or log search keyed on the old name. cap_poller.py's 4
remaining call sites (_parse_feed_payload, fetch_cap_alerts,
_set_alert_geometry, poll_and_process) now pass self.logger explicitly
to the module functions instead of calling self.method(...).
Not pure motion, so no bare ast.dump() equality this time — every
signature changed (dropped self, added logger), so verification had to
be behavioral: 59 characterization tests (tests/test_cap_geometry.py)
written against the pre-extraction bound methods, confirmed 2 of them
load-bearing via targeted mutation spot-checks (a _should_replace_alert
CANCEL-priority flip and a _parse_cap_polygon minimum-vertex-count change,
both caught immediately), then the same 59 assertions retargeted at the
extracted free functions and re-run green.
Three existing test files needed retargeting off the removed bound
methods — test_ipaws_event_code_extraction.py, test_cap_poller_batching.py,
test_cap_poller_per_item_isolation.py. The last one needed a real fix, not
just an import change: test_parse_ipaws_xml_feed_one_malformed_alert_does_not_drop_the_others
patched fake_self._convert_cap_alert (an instance attribute) expecting to
intercept _parse_ipaws_xml_feed's internal call — but that call is now a
same-module bare name inside cap_geometry.py, so an instance-attribute
patch has nothing left to intercept. Retargeted to
monkeypatch.setattr(cap_geometry, '_convert_cap_alert', ...), the same
same-module-bare-call hazard this plan has hit repeatedly (eas.py's
build_same_header, eas_storage.py's collect_compliance_log_entries).
cap_poller.py: 4933 → 4800 (4c) → 4244 lines (4c-ii). CAPPoller
itself is ~3,183 lines across the remaining 38 methods — still the actual
Phase 4c work, not started, and still the highest-risk item left in this
plan (poll_and_process alone is 464 lines / 153 self references).
4d. app_core/eas_storage.py — pure motion, package of 14 modules ✅
Profiled first rather than assumed, per the "check shape before planning"
note this plan added after 4c: 2,825 lines, 57 top-level functions, zero
classes — the same pure-motion shape as system.py (4a) and eas.py (4b),
not a god-class. Grouped into 14 modules by topic — audio-decode logging,
disk file caching/purging, schema migrations, one-time backfills, delivery
records/trends, the FCC compliance log (parsing + collection + CSV/PDF
export), weekly/monthly summary reports (common window helpers + the
received/initiated builders + the summary builders + CSV/PDF export), and
precedence — plus a __init__.py shim re-exporting all 40 public names
(and format_local_datetime/utc_now, two pass-through names some callers
import from this module rather than app_utils.time directly).
All 69 top-level definitions verified ast.dump()-identical with zero
normalization needed — a first for this plan. Every prior split needed at
least the self-stripping/docstring-dedent pair (2d, 4a-ii, 4c) because
code moved out of a class or a closure; nothing here did, so the AST
comparison caught real textual differences with no false positives to
filter out.
Built the call graph before laying out modules, not after. A free-
variable scan across all 57 functions found collect_compliance_dashboard_data
calls collect_compliance_log_entries as a same-module bare name — the
exact eas.py build_same_header/clear_broadcast_active hazard shape.
Since tests/test_public_logs_data.py monkeypatches
eas_storage.collect_compliance_log_entries expecting to intercept calls
made through collect_compliance_dashboard_data, the two had to land in the
same module (compliance_log.py) rather than being split further by
sub-topic — confirmed by grepping the whole tree for the call graph first,
not discovered by a test failure after the fact.
The free-variable scanner needed to cover constants, not just functions.
The first pass only walked FunctionDef/AsyncFunctionDef bodies for
import dependencies, so _CAP_SOURCE_ORIGINATORS — a module-level dict
literal referencing ALERT_SOURCE_NOAA and friends — silently lost its
import and failed at collection time (NameError, not a lint-catchable
issue, since nothing evaluates a dict literal's values at parse time).
Re-ran the scan over Assign/AnnAssign nodes too and caught two more
constants missing an import the same way (Tuple in compliance_export.py,
Dict in reports_common.py) before they became runtime failures.
Comments attached to a constant, not a function, are invisible to
ast. The mechanical per-node extraction (slicing exact source text by
node.lineno/end_lineno) silently dropped four standalone comment blocks
that preceded a constant rather than sitting inside a docstring —
including a six-line FCC Part-11 citation above _CAP_SOURCE_ORIGINATORS
and a four-line rationale above REPORT_MAX_ROWS. Caught by the plan's own
"every non-blank line lands in exactly one module" check (a line-set diff
against the original file), not by any AST-based verification, since a
Comment node doesn't exist in Python's AST at all.
One confirmed-dead import dropped: ORIGINATOR_DESCRIPTIONS from
app_utils.eas, imported in the original file but referenced nowhere in
it. The free-variable scan simply never listed it as a dependency of
anything; confirmed dead (not a false negative) by grepping the whole
original file for the name before dropping it.
compliance_log.py lands at 405 lines — negligible, deliberate overage to
keep the internal-call pair above together. Full suite green: 3367 passed,
identical to the pre-split count.
Phase 5 — Frontend
| File | Lines | Planned split |
|---|---|---|
static/css/styles.css |
9390 | static/css/ partials by concern, concatenated or @imported |
templates/admin/gps_dashboard.html |
8493 | extract JS to static/js/pages/, panels to templates/admin/gps/ partials |
templates/system_health.html |
5749 | same treatment |
templates/led_control.html |
4363 | same treatment |
templates/admin/radio.html |
3006 | same treatment |
templates/alert_detail.html |
2826 | same treatment |
templates/audio_monitoring.html |
2711 | JS already partly in static/js/audio_monitoring.js (1982) — finish the move and split that too |
The pattern for templates is the one used on the Audio Archives page in 2.133.1:
inline <script> moves to static/js/pages/<page>.js, repeated markup moves to
templates/components/, and the page keeps only its structure.
Progress log
| Date | Version | What landed |
|---|---|---|
| 2026-08-06 | 2.134.0 | Plan written. Phase 1 (fips_codes.py, 3887 → 673) and Phase 2a (demodulation.py, 5355 → 9 modules + a 95-line shim). ~7,500 lines of oversized module retired. Follow-up Phase 2a-ii opened for RBDSWorker/RBDSDecoder. |
| 2026-08-06 | 2.135.0 | Phase 2b (image_export.py, 3391 → 13 modules + a re-exporting __init__). demod/ converted to relative imports to match the repo convention. Largest remaining Python module is now poller/cap_poller.py at 3996. |
| 2026-08-06 | 2.136.0 | Phase 2c (gpio.py, 3149 → 7 modules + a re-exporting __init__). Phase 2 complete: the four biggest library modules — 15,043 lines between them — are now 42 focused modules. A pre-split checklist was added, distilled from the three bugs the earlier phases hit. |
| 2026-08-06 | 2.138.0 | Phase 2d/2e (gps_manager.py, 2893 → 2313). The stateless timing math moved as motion; _handle_sentence was restructured into app_core/gps/nmea.py and verified by characterization rather than AST comparison. |
| 2026-08-06 | 2.140.0 | Phase 3a-ii (api_get_audio_sources, 327 → 15 lines + listing.py/source_payload.py; write endpoints to routes_sources_write.py). Every module in webapp/admin/audio_ingest/ is now under the 400-line guidance. |
| 2026-08-06 | 2.139.0 | Phase 3a (webapp/admin/audio_ingest.py, 3180 → 15 modules + a re-exporting __init__). First web-layer split; register_audio_ingest_routes(app, logger) preserved as the entry point. Three new checklist items: Blueprint import_name, mutable-global imports, logger fan-out. |
| 2026-08-07 | 2.141.0 | Phase 3b (webapp/routes_public.py, 2849 → 6 surface modules + a package __init__ + a 31-line shim). First split of a module that was a single function — all 21 handlers were nested inside one 2,779-line register(). Verified by AST equality, a 549-rule URL-map diff, and 28 response-body digests. |
| 2026-08-07 | 2.142.0 | Phase 4a (app_utils/system.py, 2580 → 16 modules + a re-exporting __init__). The one Phase 4 file that is not a god-class; pure motion, 48/48 AST matches. 14 of the 16 modules are under the guidance. |
| 2026-08-08 | 2.143.0 | Phase 3b-ii (_load_logs_data, 1,057 lines in one function → the webapp/public/logs_sources/ package; logs_data.py 1116 → 80). A LogQuery/LogPage contract replaced the seventeen-way if/elif. The function had no test coverage, so 79 characterization tests were written against the pre-refactor code first; verified by a 115-scenario output diff (0 differences) and two mutation runs (15/15 before, 16/16 after). All nine modules under the guidance. |
| 2026-08-08 | 2.144.0 | Phase 3b-ii cont. (stats(), 645 lines in one handler → the webapp/public/stats_sections/ package; stats.py 693 → 50). Seventeen inline try/except blocks became a StatsSection contract. 32 characterization tests, mutation-checked 17/17 before and after; payload compared key-by-key against the pre-refactor handler (70 keys, 0 differences). Found 29 of 31 trailing setdefault calls to be dead. |
| 2026-08-08 | 2.145.0 | Phase 3b-ii cont. (alerts() + its PDF export → the webapp/public/alerts_page/ package; alerts.py 648 → 112). A pipeline rather than a dispatch or an accumulation, so the modules are stages. 73 characterization tests, mutation-checked 26/26 before and after (all 26 needed retargeting); 68-scenario output diff, 0 differences. Phase 3b-ii complete — every webapp/public/ module is within the guidance. |
| 2026-08-08 | 2.146.0 | Phase 3c (webapp/routes_settings_radio.py, 2781 → 17 modules + a 52-line shim). All 26 handlers captured only app and route_logger, so they moved verbatim. 34/34 AST matches; URL map diffed at 549 rules, 0 differences. The one deliberate change is a deps.py of injectable seams, reached through the module so a test has a single patch point. |
| 2026-08-08 | 2.146.1 | Not a split — a regression the Phase 3a split caused, found while baselining the next one. webapp/admin/audio_ingest.py had imported AudioSourceConfigDB at its top, so the model was incidentally importable from it; the package __init__ re-exports only what it means to. app_core/websocket_push.py imported it from there twice and both broke silently, taking the audio-source WebSocket push with them. The package's guard test listed its exports by hand and nobody had added this one — it now AST-walks the tree for every from webapp.admin.audio_ingest import … and asserts each name resolves. |
| 2026-08-08 | 2.147.0 | Phase 3d (webapp/admin/api.py, 2105 → 13 modules + a 95-line __init__). 21 ordinary top-level definitions rather than one giant register(), so pure motion: 21/21 AST matches, URL map 549 rules / 0 differences. Import blocks are derived with symtable after hand-writing them produced 127 ruff errors. Two source-text test files were retargeted, and _detect_county_wide is now tested through the real function instead of a local mirror. |
| 2026-08-08 | 2.150.0 | Phase 3h (webapp/routes/alert_verification.py, 1668 → 14 modules + a 123-line __init__). 27/28 AST matches including nested definitions; URL map 549 rules / 0 differences. The closure was reproduced from symtable: four capture-free helpers dedented to module scope, four capturing ones kept inside per-module registers. The silent-no-op hazard was measured — with the mutable globals re-exported, all six async tests pass while writing to the real temp dir. An import cycle from grouping by topic name instead of the call graph was caught and is now checked. Phase 3 complete except app.py. |
| 2026-08-08 | 2.149.0 | Phase 3f (webapp/admin/maintenance.py, 1802 → 15 modules + a 118-line __init__). 31/31 AST matches, URL map 549 rules / 0 differences, every module under the cap. The __file__ hazard fired for the second phase running — repo_root drives backup, upgrade and the .env editor. get_operation_status is imported by websocket_push but absent from __all__, so the export test derives its list from the tree. app.py was assessed and deliberately deferred — see 3g. |
| 2026-08-08 | 2.148.0 | Phase 3e (webapp/admin/certbot.py, 1946 → 14 modules + a 105-line __init__). 22/23 AST matches, URL map 549 rules / 0 differences. Carried both __file__ hazards at once: CERTBOT_BASE_DIR would have silently moved the whole certbot tree to webapp/certbot_data, and per-module loggers would have renamed every log record. The module had zero test coverage beforehand; the split added 11 tests, three guards mutation-checked. routes_obtain_execute.py (449) is left over the cap as Phase 3e-ii — it is one 387-line try block. |
| 2026-09-18 | 3.11.0 | Phase 4a-ii (app_utils/system/snapshot.py, 480 → 158 lines + 7 new collector modules + an extended network.py). build_system_health_snapshot was already a sequence of independent CPU/memory/disk/network/process/load-average/database figures, so the seam was cutting each to its own _collect_* function. Verified by 18 characterization tests written and run green against the pre-refactor function first, then a 14-mutation sweep — which caught 12 mutations immediately and found two of the test's own isolation gaps (a conflated CPU+DB critical-status assertion, and a disk-permission-error test that couldn't distinguish "correctly skipped" from "silently fell back to /"). smart.py (429) is the one Phase 4a-ii file left. |
| 2026-09-18 | 3.12.0 | Phase 4a-ii cont. (app_utils/system/smart.py, 425 → 191 lines + 4 new modules: smart_command.py, smart_query.py, smart_status.py, smart_attributes.py). Unlike snapshot.py, this is one per-device pipeline (build command → run → validate/parse → infer status → populate fields), so the modules are stages, not independent collectors — the 3b-ii alerts() shape. Existing coverage (tests/test_smart_health.py) only exercised the status-inference fallback in depth; added tests/test_smart_health_package.py (24 tests) for the rest. An 18-mutation sweep caught 16 immediately; the other two were an untested bit0 exit-code branch and an -n standby command flag that _detect_device_type() currently never actually triggers, so it got a direct unit test against the newly-extracted _build_smartctl_command() instead. 23 subprocess.run/os.path patch sites across both test files would have silently degraded to no-ops had the retarget (to smart_query.py/smart_command.py) been missed — caught on the same pass as the extraction. Phase 4a-ii complete — every module in app_utils/system/ is within the 400-line guidance. |
| 2026-09-18 | 3.13.0 | Phase 3e-ii (webapp/admin/certbot/routes_obtain_execute.py, 454 → 99 lines + obtain_validation.py + obtain_methods.py). The last known-exception module in the size audit, and — like 3e itself — had zero test coverage beforehand; added tests/test_certbot_obtain_execute.py (28 tests), the module's first ever. subprocess.run/time.sleep are patched globally (both shared singleton modules, the 4a-ii psutil reasoning), sidestepping the retarget trap for those two; the higher-level collaborators still had to move from routes_obtain_execute to obtain_methods. A 20-mutation sweep caught 18 immediately; the other two were the same isolation-gap shape 4a-ii hit — an assertion that matched raw pre-augmentation text as readily as the augmented message, and a missing test for webroot's own permission-denied augmentation branch (its sibling "No such file or directory" branch was tested; this one wasn't). No known exceptions remain in webapp/admin/certbot/. |
| 2026-09-18 | 3.14.0 | Phase 4b (app_utils/eas.py, 4246 → package of 14 modules under app_utils/eas/). The single largest file in the tree, and — contrary to the plan's original "dominated by one very large class" note — actually 48 mostly-independent top-level functions plus two god-classes (EASAudioGenerator, EASBroadcaster) that are a small fraction of the file: the 2a/2b pure-motion shape, not the characterization-harness shape Phase 4 was scoped for. 48/48 definitions and 32/32 constants ast.dump()-identical; full suite green (3,362 passed, 0 failures). Found and fixed a confirmed internal-cross-call hazard: EASBroadcaster.handle_alert() calls build_same_header()/clear_broadcast_active() as same-module bare names, and test_gpio_centralized_keying.py patches both at the module level expecting to intercept that internal call — verified load-bearing by reverting the retarget and watching it fail with KeyError: 'present'. subprocess/time re-exported as modules (not just functions) from the shim so two more tests' patches keep resolving. Hit the symtable free-variable bug for the fourth time in this plan (4a, 3c, 3d, now 4b) — this time it also missed type annotations on bare module-level constants. 12/14 modules land under the guidance; generator.py (848) and broadcaster.py (489) are known-exception god-classes, config.py (408) is one 369-line function, all tracked as follow-ups. poller/cap_poller.py, still unstarted, is confirmed to be a genuine god-class (59-method CAPPoller) — the next Phase 4 file needs the characterization-harness technique this one didn't. |
| 2026-09-18 | 3.16.0 | Phase 4c partial (poller/cap_poller.py's 9 stateless methods, 143 lines, → poller/cap_alert_parsing.py). Confirmed by profiling that CAPPoller really is the god-class the plan originally assumed (3,978/4,933 lines, 59 methods) — unlike system.py and eas.py. The 2d technique applied cleanly to the stateless slice: 9/9 ast.dump()-identical after normalizing self and docstring indentation, 19 internal call sites rewritten, one test retargeted off a live-instance call. Caught two more lessons: ruff (not available by default in this sandbox — installed into a scratch venv rather than trusting py_compile alone) flagged F821s that from __future__ import annotations had silently let slide past both py_compile and a real import; and GitHub's PR-scoped CodeQL analysis re-flagged 3 confirmed-pre-existing findings as "new" purely because the file paths moved or a data flow passed through a moved file — verified via ast.dump()/byte-identical diffs against main, and merged anyway since main has no branch protection requiring CodeQL and already carries ~100 open alerts of the same categories. The remaining 50 stateful methods (3,711 lines) — the actual Phase 4c work — are not started and are now the highest-risk item left in this plan. |
| 2026-09-18 | 3.16.1 | Fix (not a split): app_core/minimal_app.py, a lightweight create_minimal_app() bootstrap for CLI/timer scripts that only need db.session (GitHub issue #2581). security-perimeter-ingest.timer ran ingest_security_perimeter_log.py every 2 minutes forever through app.py's full create_app() — ~260 routes, every subsystem, 7.2s per run — to tail a log and insert a few rows. Measured 7.2s → 1.8s (639 routes → 1) after retargeting it and two other by-hand admin scripts (fix_admin_roles.py, create_example_screens.py) at the new bootstrap instead. |
| 2026-09-18 | 3.17.0 | Phase 4d (app_core/eas_storage.py, 2825 → package of 14 modules). Profiled first and confirmed pure-motion shape (57 functions, zero classes) — like system.py/eas.py, not a god-class. 69/69 definitions ast.dump()-identical with zero normalization needed, a first for this plan (nothing moved out of a class this time). Caught the eas.py-shaped internal-call hazard again (collect_compliance_dashboard_data → collect_compliance_log_entries, monkeypatched in test_public_logs_data.py) by building the call graph before laying out modules, not after. Two new lessons: the free-variable scanner needed to walk Assign/AnnAssign nodes too, not just function bodies, after a module-level dict constant silently lost its import; and ast-based extraction is blind to comments, which don't exist as AST nodes — four standalone comment blocks preceding constants were dropped on the first pass and only caught by the plan's line-coverage diff, not by any AST check. Dropped one confirmed-dead import (ORIGINATOR_DESCRIPTIONS). sdr_hardware_service.py and eas_monitoring_service.py — the other two files this plan flagged unprofiled — turned out to be main()-shaped instead (891 and 1079-line dominant functions) and need the 2e technique, not this one. |
| 2026-09-18 | 3.18.0 | Phase 4c-ii (poller/cap_poller.py, 12 of the 50 remaining stateful methods → poller/cap_geometry.py). First slice of the highest-risk item in this plan to actually land, using the 2e technique: 59 characterization tests written and run green against the pre-extraction bound methods, 2 confirmed load-bearing by mutation spot-check, extraction, then the same tests retargeted at the extracted free functions. Not pure motion (every signature dropped self, most gained an explicit logger parameter to avoid Phase 3e's module-level-logger hazard), so no ast.dump() equality this time — verification was entirely behavioral. Found and fixed a real retarget gap in an existing test: test_parse_ipaws_xml_feed_one_malformed_alert_does_not_drop_the_others patched an instance attribute expecting to intercept an internal call that's now a same-module bare name in cap_geometry.py — the same hazard shape as eas.py's build_same_header and eas_storage.py's collect_compliance_log_entries, retargeted to monkeypatch.setattr(cap_geometry, ...). cap_poller.py: 4800 → 4244 lines; CAPPoller itself now 3,183 lines across the remaining 38 methods. Full suite: 3426 passed (was 3367). |
Next up
Phase 3b-ii is complete. All three webapp/public/ modules that Phase 3b
left over the cap — logs_data.py, stats.py and alerts.py — have landed,
and every module in the package is now within the guidance.
Phase 3 is complete except for app.py. api.py landed as 3d,
certbot.py as 3e, maintenance.py as 3f and alert_verification.py as 3h.
Every web-layer file the plan listed is now a package of modules within the
guidance, bar two known exceptions (routes_obtain_execute.py at 449, tracked
as 3e-ii) and app.py itself.
app.py (1869) is assessed in 3g above and deliberately deferred. It is
not a package split at all: it needs the module-level singleton turned into a
real factory first, as its own commit, because until then statement order is
the behaviour and none of this plan's verification catches a reorder. Budget
it as its own phase.
Check which shape a file is before planning it. 3d/3e/3f were ordinary
top-level definitions and went quickly; 3b, 3c and 3h were single enormous
register() functions and needed the closure mapped first.
Phase 3e-ii is complete, landed 2026-09-18: routes_obtain_execute.py
(454 → 99 + obtain_validation.py + obtain_methods.py). No known
exceptions remain in webapp/admin/certbot/.
Phase 4a-ii is complete, both landed 2026-09-18: snapshot.py (480 → 158
- 7 new modules) and
smart.py(425 → 191 + 4 new modules). Every module inapp_utils/system/is now within the 400-line guidance.
Phase 3 and Phase 4a-ii are now both fully complete (bar app.py, 3g,
deliberately deferred). app_utils/eas.py landed as 4b, 2026-09-18.
poller/cap_poller.py (4933, was 3996 when the plan was written) is
confirmed a genuine god-class, unlike system.py (4a) and eas.py (4b).
Its 9 stateless methods (143 lines) landed as 4c, 2026-09-18 — the same
2d technique used on GPSManager. A further 12 methods (the CAP-geometry
collaborator: only touched self.logger and each other, never
self.db_session or the poller's zone/SAME-code config) landed as 4c-ii,
2026-09-18, using the 2e technique in earnest for the first time —
characterization tests against the pre-extraction bound methods first,
extraction second. 38 methods (3,183 lines) remain, still not started,
and this is still the highest-risk item left in the whole plan:
poll_and_process (464 lines, 153 self references) alone is bigger than
most files this plan has split in their entirety. Needs the same 2e
technique 4c-ii proved out, just at a much larger scale and against methods
that are genuinely DB/config-coupled rather than "only needs a logger."
Budget it as its own dedicated multi-session effort, not a routine split.
app_core/eas_storage.py (2825, 57 top-level functions) was profiled and
confirmed pure-motion shape — like system.py and eas.py, not a
god-class — and split into a 14-module package, 2026-09-18. main()-shaped
files remain unstarted: sdr_hardware_service.py (2924 lines) is dominated
by one 891-line process_commands function, and eas_monitoring_service.py
(2546 lines) by one 1079-line main() — both need the 2e
characterization-harness technique cap_poller's remaining 50 methods need,
not the pure-motion technique that worked for eas_storage.py. Check shape
before planning either one; do not assume either is safe to split the same
way.
Pre-split checklist
Run all of this before touching a file. Each item exists because skipping it cost a debugging cycle in an earlier phase.
grep -n "__file__". Every__file__-relative path shifts meaning when a module moves one directory deeper. AST-equality cannot catch it. Assert the resolved values against the pre-split module afterwards. (Phase 2b: silently dropped the brand logo from every share image.)- AST-scan the tests for
monkeypatch.setattrtargets, not grep — grep misses multi-line calls and bare attribute reads. Retarget each to the module that calls the name. (Phase 2c: grep found 24 of 31 sites.) - Check for tests loading the module by file path
(
spec_from_file_location). A package needssubmodule_search_locationson the spec. (Phase 2b.) - Lay the modules out by the call graph, not by topic name. A chain
that crosses three layers (
_process_temp_audio_file→_detect_comprehensive_eas_segments→_build_composite_audio_segment) looks like one topic and is three. Putting its two ends in one module sandwiches the middle into an import cycle. Walk the emitted imports and fail on a cycle by name — Python's own error names the symptom, not the layering. (Phase 3h.) - Unwrap
__wrapped__before inspecting a handler's closure.app.view_functions[name]is the outermost decorator; its only free variable is the function it wraps. (Phase 3h.) - Never assert
co_freevars == ()on a module-level function — it is always empty, so the test cannot fail. Assert on the source that the function does not reference names its old enclosing scope provided. (Phase 3h.) - Read the original of any definition you retype rather than move.
Exactly one function usually does not travel as a source slice — the
register_*that goes into the package__init__. Writing it from memory gavemaintenancethe certbot blueprint convention (url_prefix='/admin'on a blueprint whose decorators already carry/admin), which would have moved all 17 routes to/admin/admin/…. (Phase 3f.) - Check the blueprint's prefix convention against its own decorators.
Sibling packages here use opposite ones:
certbot_bptakesurl_prefix='/admin'and its routes omit it;maintenance_bptakes no prefix and its routes include it. Neither is wrong; making one match the other silently doubles or drops the prefix. (Phase 3f.) - Grep the tests for the module's path, not just its import name —
open('webapp/admin/api.py'),py_compile.compile(...),Path(...). Tests that assert against source text break on the move, and the failure mode is asymmetric: pointed at a deleted file they fail loudly, but pointed at a shim that no longer holds the code they pass vacuously. Retarget them at the package directory. (Phase 3d: five such tests.) - Run any new test file inside the full suite, not just on its own.
pytest imports every test module during collection, so a module you
never look at can change what yours sees. Reaching a submodule as
parent.childafterimport parent.childis the usual casualty — useimportlib.import_module('parent.child'). (Phase 3d: 11 errors that no targeted run reproduced.) - Prefer replacing a source-text assertion with a call to the real function. A test that mirrors the logic locally and then greps the original to confirm they still match cannot catch a change to the original — that is what the grep was standing in for. If the function only needs a stub object and one monkeypatched lookup, test it directly and mutation-check it. (Phase 3d.)
- Do not name a module
types.py— it shadows the stdlib. (Phase 2c.) - Assert every non-blank line of the original lands in exactly one module, so nothing is silently dropped by an off-by-one slice.
- Compare
ast.dump()of every top-level definition old vs new. - Verify any test retarget is load-bearing by confirming the tests fail without it. A retarget that changes nothing means the test was passing vacuously and you have learned something either way.
- Import every production consumer to confirm the shim resolves.
- Check what
__name__is passed to. ABlueprint(name, __name__)orlogging.getLogger(__name__)means something different one directory deeper. Pass__package__where the pre-split value is what matters. (Phase 3a.) A module-level logger counts: onegetLogger(__name__)serving the whole file becomes one logger per module, renaming every record fromwebapp.admin.certbottowebapp.admin.certbot.<submodule>. No test sees it; a journald grep or log filter keyed on the old name does. Put the logger in its own module and import it. (Phase 3e.) - Decide by-value vs. fan-out for the logger by asking whether the
registration hook rebinds it. If
register_*reassigns the module'slogger, a by-valuefrom .log import loggeris silently wrong and you need 3a's fan-out list. If it only writes through the logger it is handed — asregister_certbot_routesdoes — the fan-out is unnecessary machinery and a by-value import is correct. (Phase 3a vs. 3e.) - Never let a module import a mutable global from a sibling.
from .x import _some_globalsnapshots the value at import time. Add an accessor function instead, and AST-scan the package to prove none crept in. (Phase 3a: would have silently disabled a cleanup path.) - Do not re-export mutable globals from the shim. Tests reset them with
monkeypatch.setattr, which raises on a missing attribute but silently no-ops on a re-exported one. The loud failure is what tells you where the patch has to point. (Phase 3a.) Phase 3h measured the cost: with_progress_dirand friends re-exported and the patch aimed at the package, all six async tests still passed — writing to the real temp directory instead oftmp_path. If you are unsure whether a re-export matters, add it and run the tests: passing is the bad outcome. - Fan out any global a registration hook rebinds —
loggeris the usual one. One module meant one global; a package means one per module. (Phase 3a.) - Assert a mutation actually applied before concluding it survived. A search string with the wrong indentation replaces nothing, and the "surviving mutant" is the untouched original. (Phase 3a-ii.)
- Re-check the shim after any automated import cleanup.
ruff --fixwould strip a re-export shim bare; it only spares__init__.pybecause F401 exempts it by default. A shim that is not an__init__.py— likewebapp/routes_public.py— has no such exemption and survives only because its__all__marks the re-export as used. Verify, don't assume. (Phase 3a-ii, 3b.) - Map the closure before splitting a module that is one big function.
Nested handlers can be moved verbatim into per-topic
register()functions only if you know exactly which enclosing names each one captures. Walk the AST forNamenodes resolving to the outer scope rather than eyeballing it. (Phase 3b: all 21 handlers captured justappandroute_logger, which is what made the split pure motion.) - Derive per-module import blocks from the original's own import statements; do not hand-write them. Narrowing each original statement to the names a module uses keeps the grouping and gets the answer right. Hand-writing them cost 127 ruff errors in one go. (Phase 3d.)
- Compute the free-variable set with
symtable, notast.Namecounting — name counting cannot see scope. A parameter calledtextreads as a use offrom sqlalchemy import text, and so does a local variable calleddesc.symtableanswers directly: at module scope keep names referenced but never bound; inside a function keep the symbolsis_global()reports. This bug has now appeared three times (4a, 3c, 3d) in three different disguises — it is the single most repeated mistake in this plan. - Lint the generated package anyway.
ruff check --select F,E9is the gate, whatever produced the imports: F401 catches an import nothing needs, F821 an import a module needed and did not get. (Phase 4a.) - Rewrite names with the AST, never a regex — a regex has no scope.
It will rewrite the alias inside
from x import name(a syntax error, if you are lucky enough for the linter to catch it) and, worse, call sites inside functions that shadow the name with their own local import. Phase 3c had three functions importingget_redis_clientfrom a different module locally; rewriting those would have been a silent behaviour change, not a move. ast.walk()does not stop at scope boundaries. Computing "names bound in this function" withast.walkdescends into nested functions, so an outer function inherits every inner one's local imports. In Phase 3c that maderegister()appear to shadow names it never touches, and because shadowing inherits downward it silently skipped the rewrite for every nested handler — leaving a plausible-looking count rather than an error. Cut nestedFunctionDef/Lambda/ClassDefoff explicitly.- Split generated files on a sentinel you control, not on punctuation.
A
partition(")\n")intended to find the end of an import block matched the(KR8MER)in the copyright header instead, and the "body" rewrite then corrupted every module's imports. (Phase 3c.) - Never run a file-rewriting harness in the background against a tree you are still editing. A mutation runner backs up, rewrites and restores files in place; started in the background while new modules were being written into the same package, it picked the half-written files up as targets, leaving one committed file altered and one new file carrying a mutant's arithmetic. Run it in the foreground, or point it at a worktree copy. (Phase 3b-ii.)
- Retarget every mutation after the split, and count "not applied" as a
failure. A refactor moves and rewords the lines the mutations matched —
after the
alerts()split, all 26 stopped applying. A mutation that matched nothing proves nothing, so the run is only meaningful once every one has been pointed at its new home. (Phase 3b-ii, and 4 of 17 in thestats()split.) - Purge
__pycache__when a harness restores a mutated source. A mutation that swaps two same-length branches leaves the byte count unchanged, and a restore that preserves the backup's mtime lets CPython reuse the mutant's.pyc. The tree then keeps running code you believe you reverted, and the symptom looks exactly like one flaky test. (Phase 3b-ii.) - Make the harness print what it bound to, and read it. A comparison
script that silently falls back to the pre-split shape reports a perfect
match against itself. Naming the patched module is what catches a
cdthat leaked between runs, an import that resolved to the wrong tree, or a patch target that no longer exists. (Phase 3a-ii, 3b-ii.) - Seed fixtures deterministically, including what the ORM writes for
you. Insert-triggered listeners (audit events,
onupdatestamps) carry real wall-clock values, and they will show up as diffs that have nothing to do with the refactor. Re-seed those tables last rather than scrubbing the comparison until it goes quiet. (Phase 3b-ii.) - Treat an equal-length, unequal-hash diff as a scrubbing bug first.
It is the signature of an unscrubbed fixed-width random value, not a
content change. Diff the raw bodies before concluding a regression.
(Phase 3b: the per-session CSRF token, emitted in three shapes by
base.html, of which the harness knew only one.)
This document is served from docs/development/LARGE_FILE_REFACTOR_PLAN.md.md in the EAS Station™ installation.