testsAndMisc/docs/python-split-recipes.md
Krzysztof kuhy Rudnicki 5029d4685b
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Split the handoff doc and refresh its stale counts
The handoff had grown to 264 lines -- a violation of the very cap it exists to
enforce. The Python recipes move to docs/python-split-recipes.md; the handoff
keeps a pointer.

Also refreshed what had gone stale: the directory distribution said python_pkg
50 and '48 near-misses' from before this session's 9 splits. A future session
reading a stale count would not know which other claims in the file to trust,
so the numbers are now explicitly labelled a snapshot with the script as the
authority, and the per-package breakdown points at brother_printer (15) and
code_tutor (13) as where the bulk now sits.

Corrected the test recipe too: shared builders go in tests/conftest.py, not a
_helpers.py -- name-tests-test rejects the latter, which the earlier wording
would have walked the next session straight into.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 16:29:45 +02:00

4.1 KiB
Raw Blame History

Python split recipes for the 250-line cap

Settled empirically while clearing python_pkg/wsg_grabber. Every rule here cost at least one failed gate run or one revert to learn — do not re-derive them. Referenced from refactor_claude_todo_resume.md.

The re-export form

When a split moves a public name out of a module that callers/tests still reach through the old path, the re-export must be marked with __all__:

from python_pkg.pkg._new_module import show_logs, summarise

__all__ = ["build_parser", "main", ..., "show_logs", "summarise"]

The PEP 484 alias form from x import y as y does not work here: ruff raises PLC0414 (useless-import-alias), auto-fixes it away, and then removes the now-"unused" import under F401 — silently breaking cli.summarise. Verified on wsg_grabber/cli.py; __all__ passes ruff + mypy + pylint clean.

Splits that only move private helpers (leading _, no external caller) need no shim at all — check with grep first, as in catalog.py.

__all__ only needs the re-exported names, not every public name in the module — names defined locally stay reachable either way. Verified against the full gate on store.py.

__all__ does not save you from ruff's TC001. It suppresses F401, but a re-exported class used only in annotations gets rewritten into the TYPE_CHECKING block, which lints perfectly and raises AttributeError at import time. This nearly shipped a broken ui.Callbacks that cli.py constructs. A class that must be reachable at runtime from module X stays defined in module X. Always finish a re-export split with

PYTHONPATH=. python3 -c "from python_pkg.pkg import mod; mod.TheName"

Two other things the gate will not catch for you:

  • Check what the tests patch before choosing a seam. test_ui.py does patch.object(ui, "tk", fake), so every line touching tkinter had to stay in ui.py; moving widget construction out errored 22 tests. Grep the test file for patch.object(<module> first and treat those names as pinned.
  • Tests that call private methods pin them too — test_player.py calls mpv._note_errors(...), so that method stayed as a one-line delegate to the moved implementation.

Budget the new import block and __all__ before deciding a two-way split is enough. Five splits so far landed 2–16 lines over the cap after the first cut and needed a further seam; the header costs 10–20 lines that the arithmetic on function sizes does not show.

Splitting a class: if tests call private methods as bound attributes (worker._fetch_thread(...) — grep the tests first), module-level functions will not do; use mixins. The shape that passes every gate with no suppression (settled on downloader.py, do not re-derive):

# _worker_base.py
class WorkerBase(ABC):
    _deps: WorkerDeps          # bare annotations for read-only attributes
    _downloaded: int = 0       # real default for any attribute the mixin ASSIGNS

    @abstractmethod
    def _publish(self, event: DownloadEvent) -> None:
        """Supplied by the concrete Worker."""

Both mixins inherit WorkerBase; Worker(ScanMixin, DownloadMixin). Why not the obvious alternatives:

  • Naming a class *Mixin does not get you pylint's no-member exemption here — ignored-checks-for-mixins is not configured for it. Don't rely on it.
  • raise NotImplementedError stub bodies are uncovered lines under the 100% branch gate.
  • if TYPE_CHECKING: def _publish(...) -> None: ... satisfies mypy but leaves pylint reporting no-member.
  • A bare annotation (_downloaded: int) is not enough for an attribute the mixin assigns to (+= 1); pylint wants a real default.

Watch the MRO: Worker(ScanMixin, DownloadMixin) means ScanMixin wins any name collision.

Coverage: meta/pyproject.toml sets source = ["python_pkg"], so a new sibling module is measured automatically. Confirmed: new modules show up in the report at 100% rather than being silently skipped. Add no new branches (no if TYPE_CHECKING guards beyond what moved, no defensive try/except).