Skip to content
C.W.K.
Stream
Lesson 04 of 05 · published

No Module Imports Another

~14 min · imports, layout, discipline, exception

Level 0Loose Parts
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

Where the Copy Lands Is Not the Kit's Choice

Consumers lay out their code differently. One puts shared modules flat inside its top-level package. Another nests them under an engine directory two levels down. A third vendors only some of them. All three are legitimate, because the layout follows each app's own history and none of them is going to restructure to satisfy a distribution mechanism.

An import between two shared modules has to survive every way a consumer might load it. A relative import resolves whenever there is a parent package — which covers both layouts above — but not when a file is put directly on the import path, which is exactly what the kit's own test suite does. So a shared module that imports a sibling needs a dual-import shim, written into every file that needs it, and the split it covers is package-import versus path-import rather than flat versus nested. All three answers are worse than not asking the question, which is what the rule does: no shared module imports another shared module. Wiring is constructor injection, always. The app assembles the pieces because the app is the only party that knows where they are.

The Second Reason, Which Matters More Over Time

Scope. A consumer that wants one shared module gets exactly that module. If shared modules imported each other, adopting the queue plumbing would silently drag in the log store, which drags in the identity minter, and a consumer that wanted one file now vendors four and has opinions imposed on it that it never chose.

This is the property that makes partial adoption real rather than theoretical. One workshop takes the whole kernel; another takes exactly two tables' worth of schema and nothing else; a third takes the client layer and none of the queue. That would be impossible if the modules were a graph.

In a vendored library, every import is a decision imposed on every consumer. Inside a normal package, an internal import is invisible and free. Inside a set of files that will be copied piecemeal into repositories with different shapes, the same import is a constraint on layout and a widening of scope, applied to everyone downstream. That is why a rule that would be over-engineering in a package is exactly right here.

The One Exception, and Why It Is Written Down

Exactly one shared module imports another: a registry backed by database rows imports the plain registry. The reason is definitional — a rows registry is a registry over a provider — so injecting the class would only make every consumer pass the same argument at every call site, which is ceremony, not decoupling. Note what the exception does not extend to: the module composes the registry rather than subclassing it, so the import is the exception and the relationship is still the ordinary one.

Two details make the exception honest rather than merely permitted. It carries a dual-import shim so both layouts still resolve. And it defines its own timestamp helper rather than importing one from a sibling — resisting the small convenience that would have turned one exception into two.

The exception also collected a real bill. The bare relative import failed under the import style the kernel's own test file uses, which is why that module sat untested beside eight tested siblings until somebody noticed the asymmetry. The rule is not arbitrary; the first violation found the exact failure the rule predicts.

Code

Injection everywhere, and the single documented exception·python
# THE RULE: wiring is constructor injection. No shared module reaches
# for a sibling, so the copies work in flat and nested layouts alike.

class QueueEngine:
    def __init__(self, db, logstore, registry, briefgit):
        # Four collaborators, all handed in. This module has no idea
        # where any of them live, which is exactly the point: the APP
        # knows its own layout, so the APP does the assembling.
        self._db = db
        self._log = logstore
        self._registry = registry
        self._git = briefgit


# In the app's shim - the one file that knows the layout:
#
#   from . import kit_queue_db, kit_queue_logstore        # flat app
#   engine = QueueEngine(db=..., logstore=..., ...)
#
#   from .engine import kit_queue_db, kit_queue_logstore  # nested app
#   engine = QueueEngine(db=..., logstore=..., ...)


# THE EXCEPTION, with its reason attached in the module itself:
#
#   A rows-backed registry is a registry over a provider. Injecting
#   the registry CLASS would make every consumer pass the same
#   argument at every call site - ceremony, not decoupling. Note that
#   the module still COMPOSES rather than inherits: the import is the
#   exception, the relationship is not. A SECOND exception needs its
#   own reason here; the rule is otherwise unchanged.
try:
    # Vendored consumers import this as <app>.kit_queue_pipelines, and
    # the kit's own NAMESPACE-PACKAGE tests as kit.python.… - both have
    # a parent package, so the relative import resolves in flat and
    # nested layouts alike.
    from .kit_queue_registry import PipelineRegistry
except ImportError:                                    # pragma: no cover
    # The kernel's own test suite puts kit/python directly on sys.path,
    # so there is no parent package to be relative to. THAT is the split
    # this shim covers - package-import vs sys.path-import, never flat
    # vs nested.
    from kit_queue_registry import PipelineRegistry


class PipelineRows:
    """Pipelines as database ROWS rather than as code data.

    COMPOSES the registry rather than subclassing it. The import above
    is the whole exception; the relationship is still 'has a', so a
    consumer that wants rows gets rows, not an inheritance chain.
    """

    def __init__(self, db, prog: str):
        self._db, self._prog = db, prog
        # Re-read per lookup so row edits apply without a restart.
        self.registry = PipelineRegistry(self._provider)

    def _provider(self) -> dict:
        """The rows, read fresh. Passed as a CALLABLE, not a dict, so
        an edited row applies without restarting anything."""
        return load_pipeline_rows(self._db, self._prog)

    @staticmethod
    def _utcnow() -> str:
        """Defined here rather than imported from a sibling. The
        convenience of reusing one is exactly how a single exception
        becomes two."""
        from datetime import datetime, timezone
        return datetime.now(timezone.utc).isoformat()


# What the exception cost, recorded because it is evidence FOR the
# rule: the bare relative import failed under the sys.path import
# style the kernel's own tests use, so this module sat untested
# beside eight tested siblings until the asymmetry was noticed.

External links

Exercise

Take a shared library you maintain and draw its internal import graph. For each edge, ask what a consumer wanting only the tail module is forced to accept. Then pick the edge with the widest fan-out and write what would have to be injected instead — and honestly assess whether removing it would be decoupling or just ceremony.
Hint
The honest test for 'ceremony' is whether every consumer would pass the same value. If they all would, the import is expressing a real fact about the module rather than hiding a choice, and it is a candidate for a documented exception rather than a refactor.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.