skip to content

Under a flat layout, how can a nightly rebuilder's tests pass while its installed wheel raises ModuleNotFoundError?

level: seniorimportance: should knowfreq 30%

answer

  1. Two different copies of one package
  2. What CI actually imported
  3. The wheel is not a copy of the repository
  4. The root is first on sys.path
  5. Install the built artefact, then test it

basics

~20 s

Because the tests never imported the wheel. With the repository root first on sys.path, a flat-layout test run imports the working copy, which contains every file; the built distribution can be missing a module and nothing in the run would notice.

solid answer

~50 s

The test job and production import two different things. In a flat layout the package sits at the repository root, which CPython puts at the front of `sys.path`, so the test run resolves `import indexer` to the working copy — where the new module obviously exists, because you just wrote it. The wheel contains only what the build backend's rules included, and a new module drops out for mundane reasons: a subdirectory that never got an `__init__.py`, an include pattern that does not match. Production installs that artefact, imports it from site-packages, and raises `ModuleNotFoundError` when control first reaches the new code path. The structural fix is a src layout, so the only importable copy is the installed one; the belt-and-braces fix is a CI stage that installs a built wheel into a clean environment and runs the suite there.

code

python · 3 lines
python
import importlib.util

print(importlib.util.find_spec("email.mime.text"))

go deeper

for a junior

Understand that the code you run locally and the code inside an installed wheel can differ, and that ModuleNotFoundError in production with a green test suite is a packaging symptom rather than a code bug.

for a middle

Explain the mechanism end to end: the repository root sits first on sys.path, the flat-layout package shadows site-packages, and the build backend never included the new module. Know how to list a wheel's contents to confirm it.

for a senior

Show the diagnosis and the two-layer fix — src layout so development cannot import the wrong copy, plus a release stage that installs a built wheel into a clean environment and tests there. Note how a lazy import delayed the failure into the middle of a batch.

for a principal

Own the release gate rather than the incident: what every repository must prove before deploying, who owns that stage, and where the boundary sits between the packaging fix and the separate question of making a long batch job restartable.

## The scenario A nightly search-index rebuilder processes a batch of roughly 6,800 rows. A change adds a small module that normalises dates written in a locale-dependent format, imported lazily from the row-normalising code path. The suite is green in CI. That night the rebuild dies partway through the batch with `ModuleNotFoundError` naming that module, and the index is left half-written. Nothing about the code is wrong. The test run and the deployment imported two different copies of the package. ## Why the test run proved nothing The repository uses a flat layout: `indexer/` sits at the top level beside `tests/` and `pyproject.toml`. CI checks out the repository, installs the project, and runs the suite from the repository root. That last detail is the whole failure: the working directory is first on `sys.path`, and `indexer/` is right there, so every `import indexer.*` in the test run resolves to the checkout. The installed copy in site-packages was never touched. The suite verified the source tree — which is complete, because the file is sitting in it — and told you nothing at all about the artefact you were about to deploy. The wheel, meanwhile, is not a copy of the repository. It contains exactly what the build backend decided to include. A new module drops out for mundane reasons: it lives in a new subdirectory that has no `__init__.py`, so discovery does not see it as package content; an explicit package or include list was never updated; the file was added to the working tree in a way the build's rules do not match. None of those produce an error at build time. You get a smaller wheel and no warning. Production installs that wheel into a clean environment and runs the entry point from a working directory that is not your repository, so the import resolves through site-packages — correctly, and to an incomplete package. Because the import is on a lazy path reached only when a row carries the locale-dependent format, the process even starts up fine and fails partway through the batch, which is the worst version of this bug: a partially-written index and a stack trace that points at an import rather than at the packaging mistake that caused it. ## Diagnosing it quickly The question to answer first is *which copy is this?*, and the answer is one line: ```python import importlib.util print(importlib.util.find_spec("email.mime.text")) ``` Substituting your own module, the spec names the file the import machinery would use. From there: - print the module's `__file__` in the failing environment — a path under site-packages confirms the installed copy is the one in play; - compare `importlib.metadata.version("indexer")` with what you believe you shipped; - list the built wheel's contents (it is a zip archive) and look for the missing file — this is the definitive check and it takes seconds; - re-run the suite from a directory that is not the repository root and watch it fail the same way CI should have. ## Fixing it so it cannot recur **Move to a src layout.** With the package under `src/`, the repository root contains nothing importable, so the prepended path entry matches nothing and every command — the test run included — resolves `indexer` through the installed distribution. The missing module then fails on the developer's machine, minutes after the mistake, instead of in a nightly batch. This is the guarantee the layout exists to provide, and it is why libraries adopt it. **Test the artefact, not the tree.** Add a release stage that builds the wheel, installs it into a fresh environment with no source checkout on the path, and runs the suite there. A src layout makes the everyday case safe; this makes the shipped case verified, and it also catches things layout alone does not, such as missing metadata or a data file that was never declared. **Do not import lazily to hide the problem.** Lazy imports are legitimate for start-up cost, but here they turned a start-up crash into a half-finished batch. If a module is required for the main code path, importing it at module import time turns a silent packaging defect into an immediate, loud one. **Be careful what you conclude about editable installs.** An editable install deliberately points imports back at the working tree, so it does not by itself prove the wheel is complete; that is exactly why the verification stage installs a built wheel rather than the project in editable mode. How editable installs achieve that redirection is a separate subject. ## The interview point The candidate to hire says the words *the tests never imported the artefact*. Everything else — discovery rules, `__init__.py`, wheel contents, the lazy import that delayed the crash — is detail hanging off that one sentence, and it is also the sentence that explains why anyone bothers with a src layout in the first place.

  • CI runs the suite from the repository root. What is the smallest change that would have caught this?
    Run the suite from a directory that is not the repository root, against an installed build. Even without changing the layout, that removes the working copy from the front of `sys.path` and forces the import to resolve through site-packages. Setting `PYTHONSAFEPATH` in the job achieves the same for every interpreter it starts; a src layout makes it structural rather than a job setting someone can drop.
  • Would running with an editable install have caught the missing module?
    No. An editable install deliberately makes imports resolve to your working tree, so a module that exists in the checkout but never made it into the build imports perfectly. It is the right tool for the development loop and the wrong tool for verifying a release; for that, install a built wheel into a clean environment.
  • The batch failed partway through, leaving a half-written index. Does the layout discussion address that?
    No, and you should say so. Layout explains why the defect escaped, not why its effect was so expensive. That the rebuild is not restartable or transactional is a separate design problem, and an interviewer will respect the distinction between the packaging fix and the resilience fix rather than a single answer that conflates them.

saying these in an interview costs you the question

  • Blaming the test runner instead of the import path
  • Assuming the wheel mirrors the repository contents
  • Claiming an editable install proves the wheel is complete
  • Suggesting a try/except around the import as the fix
  • Believing a green suite guarantees a deployable artefact

context