Software craft The unwritten rules

Keeping optional Python imports optional

A test that something didn't happen has to prove it got far enough to see it.

On this page · 6 sections

If all I want from a library is to clean up a string, I don’t expect to load the package behind its optional formatting feature. Keeping that boundary intact seems simple: put the extra import inside the function that needs it.

Then someone moves the import to the top of the file. The formatting feature still works. The string cleanup still works on a machine with everything installed. But importing the library now pulls in a dependency that basic callers were supposed to avoid.

I’d want a test that catches that change. There is a catch in writing one, though: “the dependency wasn’t loaded” can also mean “the code broke before it got that far.” We need evidence of two things together: our library finished importing, and the optional dependency stayed unloaded.

The interesting part is how Python’s memory of earlier imports affects that test. Let’s start there, then build something we can run.

The short version

  • An import inside a function body waits until the function runs. Moved to the top of the file, it runs whenever the library is imported.
  • sys.modules records every module an interpreter has loaded, and the tests in one run share it, so an earlier test can decide the result.
  • The test therefore imports the library in a fresh child process and checks that the optional package’s whole namespace is absent before and after.
  • The child prints a completion marker, because a crash or an early sys.exit(0) would otherwise read as a pass.
  • It is a regression test for cooperating code, not a security boundary, and it doesn’t replace running the basic feature without the dependency installed.

What Python remembers after an import#

It helps to separate a package being available to import from a module being loaded into a running interpreter. The package’s files can be sitting on disk while the program has never imported them.

For an ordinary Python source module, the first import executes the code at module level. That creates its functions and variables, and runs any imports written there. Importing one function with from module import function still loads the module containing it. Python’s introduction to modules walks through this distinction.

Defining a function doesn’t execute its body. An import inside that body waits until execution reaches it during a call. That’s the small fact we’re relying on when we make an optional import lazy.

Our test runner is a Python program running in an operating-system process. Inside it, an interpreter executes the tests and holds their module objects in memory.

The interpreter keeps a dictionary called sys.modules. Module names are the keys, and module objects are the values. Test functions running in that interpreter share this state. A new test function doesn’t get a new import history.

An ordinary repeat import finds the existing module in that cache and reuses it. It doesn’t start the module’s initialization over again. The module-cache reference also explains what happens if code deliberately removes or replaces an entry; here we’re concerned with normal imports.

That gives us somewhere to look for the optional dependency. It also explains why where we look will matter.

A small library with one optional feature#

Let’s make up a tiny library, runtime_helpers. Its basic job is trimming whitespace. Its optional feature adds some formatting through another made-up module, optional_toolkit.

Here’s runtime_helpers.py:

python
"""A basic string operation and an optional formatting feature."""
def normalize(value):
return value.strip()
def decorate(value):
import optional_toolkit
return optional_toolkit.decorate(normalize(value))

Notice where the import sits. Loading this file creates normalize and decorate; the optional import waits inside decorate.

For optional_toolkit.py, all we need is something recognizable:

python
"""A tiny optional formatter for the example."""
def decorate(value):
return f"*** {value} ***"

Now we can follow the same interpreter as it does a little more work each time:

Action, in this orderResultoptional_toolkit in sys.modules?
import runtime_helpersThe library’s functions become availableNo
runtime_helpers.normalize(" hello ")"hello"No
runtime_helpers.decorate(" hello ")"*** hello ***"Yes

These are the observed results in a fresh interpreter with both files available. The third row is allowed: the caller has chosen the optional feature. We’re trying to keep that import out of the first row.

This is also why the test should have the optional dependency available. We want an accidental eager import to succeed so we can detect what it loaded. Otherwise a missing-package error could obscure the particular behavior we’re checking.

Give the import a fresh process#

Suppose another test has already called decorate. Checking the main test runner’s sys.modules would now find optional_toolkit, even if our library’s import boundary is correct. Conversely, if the library is already cached, importing it again doesn’t exercise its initialization under the conditions we meant to test.

I find a new Python process easier to reason about here. The parent stays in charge of the test; the child starts a separate interpreter and reports what happened during one import. Python’s subprocess module gives us that boundary and the pipes to collect the result.

A fresh interpreter still has startup modules loaded. So the child first checks that the optional namespace is absent. It then imports our library and checks again. We’re testing the whole namespace: optional_toolkit and names starting with optional_toolkit.. A name such as optional_toolkit_extra doesn’t belong to it.

Here’s the successful path:

Fresh Python processTest runnerFresh Python processTest runnerEarlier tests may havechanged this interpreter's cacheOwn module cacheStartup modules are already presentStart Python with -ICheck optional namespace is absentImport runtime_helpersCheck optional namespace againIMPORT_CONTRACT_OK on stdoutExit status 0Require both marker and exit 0

The result comes from a completed check in the child’s interpreter; earlier imports in the parent don’t determine it.

The last two messages are both necessary. An exception can stop the child before it reaches the check. Less obviously, a module can call sys.exit(0). The process can exit “successfully” while skipping the rest of our probe. That’s why the parent requires an exact completion marker as well as exit status zero.

A cache entry isn’t proof that initialization finished. Python registers a module before executing its body, so recursive imports can find the object being initialized. We wait for the import call to return and the dependency check to finish before printing our marker.

Turning that sequence into a test#

With that model in place, the helper mostly follows the picture. The string named child is the little program we ask the new interpreter to run. importlib.import_module lets it import the target by name.

Two process boxes side by side. On the left, the test runner, the parent process: test_import_contract.py's test_runtime_import() calls assert_no_import.py's assert_import_does_not_load("runtime_helpers", "optional_toolkit"). A note says the parent's own sys.modules may already hold optional_toolkit from an earlier test, so the parent does not look there. An arrow labelled "starts Python -I" leads to the right box, a fresh Python process with its own sys.modules. Inside it, four steps in a column: 1, optional_toolkit absent, or the setup is invalid; 2, import runtime_helpers with importlib.import_module, its print() calls going to stderr; 3, optional_toolkit still absent, or an AssertionError naming the modules; then print("IMPORT_CONTRACT_OK"), reached only if steps 1 to 3 passed. Step 2 runs the top level of runtime_helpers.py, whose code shows normalize and decorate, with "import optional_toolkit" highlighted inside decorate. A dashed line leads from that import to optional_toolkit.py, labelled "only when decorate() is called". A red box shows the edit the test catches: the import moved to the top of runtime_helpers.py, so step 2 loads it and step 3 fails with "AssertionError: Import loaded forbidden modules: optional_toolkit". A dashed arrow carries stdout and the exit status back to the parent's check, which passes only if stdout is "IMPORT_CONTRACT_OK\n" and the exit status is 0; an exception, an early sys.exit(0) or the 10 second timeout fails the test.
The four files, and which process each one runs in. Only the marker and the exit status cross back to the parent.

Everything on the blue side runs in the child. Nothing an earlier test imported can reach the check there, and the parent judges the result only by what comes back along the dashed arrow.

Here’s the complete assert_no_import.py:

python
"""Check optional import boundaries in a fresh Python interpreter."""
from pathlib import Path
import subprocess
import sys
def assert_import_does_not_load(module, forbidden, *, source_root=None):
"""Check one import in a child using this interpreter and installed environment."""
child = r"""
import contextlib
import importlib
import sys
module, forbidden, source_root = sys.argv[1:]
if source_root:
sys.path.insert(0, source_root)
def loaded():
return sorted(name for name in tuple(sys.modules)
if name == forbidden or name.startswith(forbidden + "."))
if loaded():
raise RuntimeError("Invalid test setup: forbidden module already loaded")
with contextlib.redirect_stdout(sys.stderr):
importlib.import_module(module)
if loaded():
raise AssertionError("Import loaded forbidden modules: " + ", ".join(loaded()))
print("IMPORT_CONTRACT_OK")
"""
root = str(Path(source_root).resolve()) if source_root is not None else ""
result = subprocess.run(
[sys.executable, "-I", "-c", child, module, forbidden, root],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0 or result.stdout != "IMPORT_CONTRACT_OK\n":
raise AssertionError(
f"Import contract failed for {module!r} (exit {result.returncode})\n"
f"{result.stdout}{result.stderr}"
)

There are a few choices here I’d keep even in a small test.

The tuple(sys.modules) gives us a snapshot of the names to iterate, following the dictionary’s documented guidance. The namespace comparison catches a dependency loaded indirectly through another helper, too.

The output redirection keeps ordinary import-time print() calls out of our completion signal. They go to standard error instead, where we can still read them if the test fails. Without that separation, a harmless startup message would make the exact-output check fail.

Finally, subprocess.run waits for the child and captures its output. A nonzero exit becomes an assertion with the diagnostics attached. If waiting times out, TimeoutExpired fails the test. Silence never becomes a pass.

Now we can express the actual boundary we care about in test_import_contract.py:

python
from pathlib import Path
from assert_no_import import assert_import_does_not_load
def test_runtime_import():
assert_import_does_not_load(
"runtime_helpers",
"optional_toolkit",
source_root=Path(__file__).resolve().parent,
)
if __name__ == "__main__":
test_runtime_import()
print("PASS: runtime_helpers leaves optional_toolkit unloaded")

To try it, the four files go side by side: runtime_helpers.py, optional_toolkit.py, assert_no_import.py, and test_import_contract.py. From that directory:

Shell
Terminal window
python3 test_import_contract.py

The result is:

text
PASS: runtime_helpers leaves optional_toolkit unloaded

That’s the test script speaking after the helper succeeds. The child’s IMPORT_CONTRACT_OK stays inside the captured output.

Make sure it fails when it should#

The passing result is only half the demonstration. Let’s make the edit that worried us: move import optional_toolkit out of decorate and up to module level.

Run the same command and the failure includes:

text
AssertionError: Import loaded forbidden modules: optional_toolkit

Move it back, and the test passes again. Now we’ve seen it distinguish the two versions of our library, rather than merely accept the one we started with.

The other cases are useful for checking the test itself. These outcomes were exercised with small example modules:

What the target doesResultWhy
Leaves the import inside an uncalled functionPassImport finishes with the optional namespace absent.
Imports the dependency at module levelFailThe dependency is in the cache.
Imports a helper that imports the dependencyFailThe indirect import is visible in the same cache.
Raises an unrelated exceptionFailA broken import isn’t a successful probe.
Doesn’t existFailModuleNotFoundError reaches the failure report.
Inserts only optional_toolkit.child into the cacheFailThe dotted-name check covers descendants.
Prints a message, then finishes without the dependencyPassOrdinary output doesn’t interfere with the marker.
Prints a message, then eagerly imports the dependencyFailOutput handling doesn’t conceal the import.
Exits early with status zeroFailIt never prints the completion marker.

The submodule-only case deliberately inserts a cache entry to check name matching. A normal package import isn’t being modeled there. The example and these controls were run on Python 3.12.12 and 3.9.6.

The unrelated-exception and early-exit rows are the ones I’d pay particular attention to. A test for something not happening needs to establish that it reached the point where it could have observed that thing.

What I’d take into a real library#

The helper’s two import names are the part you’ll change first: your target module, perhaps your_library.runtime_helpers, and the optional dependency’s top-level name. Use absolute import names.

Then choose what code the child should see. sys.executable selects the parent’s Python executable. The -I option ignores PYTHON* environment variables and leaves the current directory and user site-packages out of the child’s search path. Our explicit source_root adds the example directory back. In a source-tree test, that might instead be the project’s src directory.

If the question is about an installed distribution, omit source_root and use an interpreter where that distribution is available under -I. A package reachable only through PYTHONPATH or user site-packages won’t be found that way. Starting fresh and choosing an installation are separate decisions.

I’d keep this assertion’s promise narrow. It checks which module names remain after one import completes. It can’t show every import attempt that was caught, or a module that was loaded and later removed from the cache. It’s a regression test for cooperating code, not a security boundary.

There is also one more test I’d want before promising callers that the dependency is optional: run the basic operation in an environment where that dependency is absent. That environment test hasn’t been run here. Our probe checks whether importing the library leaves the extra module unloaded; the separate test checks whether callers can actually do their basic work without it.

For the original one-line edit, though, we now have a precise guard: the import must finish, and it must leave the optional feature’s dependency alone.

Share this article

Related posts

3 items
  • Sep 15, 2026 11 min read Software craft

    Green on my laptop, red in CI

    A pytest plugin passed every test from the source checkout and failed twice in CI, because only the installed wheel told pytest the plugin existed.

    The unwritten rulespythontesting
  • Oct 13, 2026 26 min read Software craft

    Code review economics

    On three busy repositories the wait for a first review grows much more slowly than a PR's size, so a change split into eight PRs reviewed one after another waits three to eight times as long. Opening them side by side helps less than it looks, and only one reviewer taking them together is no worse than one PR. The measurement, the rule that falls out of it, and a tool to run it on your own PRs.

    The unwritten rulescraftteamsstatistics
  • Oct 2, 2026 16 min read Software craft

    How many concepts is too many

    Framework debates trade concept counts that measure different things. Count what the smallest real program makes its user write, in separate columns and per reader, find the rules the count can't see by misspelling them, and keep an extra concept only when it moves a check earlier or gives users a seam they can see.

    The unwritten rulespythonapi-designframeworks
  • Software craft

    Green on my laptop, red in CI

    A pytest plugin passed every test from the source checkout and failed twice in CI, because only the installed wheel told pytest the plugin existed.

    pythontesting
    Sep 2026
  • Software craft

    Code review economics

    On three busy repositories the wait for a first review grows much more slowly than a PR's size, so a change split into eight PRs reviewed one after another waits three to eight times as long. Opening them side by side helps less than it looks, and only one reviewer taking them together is no worse than one PR. The measurement, the rule that falls out of it, and a tool to run it on your own PRs.

    craftteams
    Oct 2026
  • Software craft

    How many concepts is too many

    Framework debates trade concept counts that measure different things. Count what the smallest real program makes its user write, in separate columns and per reader, find the rules the count can't see by misspelling them, and keep an extra concept only when it moves a check earlier or gives users a seam they can see.

    pythonapi-design
    Oct 2026

Comments