You opened the laptop on a plane. You only wanted a list of tests.
pytest --collect-only -q should print names and stop. It is a table of contents, not a plot. Then the cursor sat there. DNS was gone. The suite had not even started.
That stall is not a flaky assertion. Collection imports your test modules, and those modules import your package. If __init__.py phones home, the table of contents becomes a network client. A later cleanup pull request can hide the call behind a version check or a banner fetch. The tests still pass on office Wi-Fi. They fail in CI images with no egress. They fail in the sky.
Think of import as opening a book. The spine should not ring a cash register.
Collection is an import storm
Pytest collection is mostly import. It loads conftest.py, then every test module that matches your arguments. Names at module scope run immediately. A “constant” assigned from urlopen is not a constant. It is a request wearing a pytest badge.
python -c "import mypkg" is the smallest reproduction you can give a teammate. python -X importtime -c "import mypkg" shows which submodule ate the wall clock. Neither command should need a VPN. If they do, your public import surface is lying about being a library.
The usual excuse is convenience. A CLI wants a live banner. A client wants a cached token. Someone moved that work to import so the first function call looks clean. The bill arrives later, in places that are not supposed to have side effects: editors running discovery, pre-commit hooks, container builds that collect tests before secrets exist.
Import-time I/O also poisons tools that never intended to be clients. A language server that loads the package to read __version__ now needs network policy. A docs build that imports the package to steal a docstring now needs DNS. You did not write those tools. Your module body volunteered them as dependents.
The trap: an audit hook that only lives during collection
Python already has a probe for this. sys.addaudithook sees socket.connect, socket.getaddrinfo, and subprocess.Popen. You do not need to patch urllib. You do not need a fake resolver. You fail the run when collection tries to become a client.
Treat the listing as a proposed local harness. It is an unexecuted example. Adapt the blocked events to your package.
# import_trap.py — proposed collection guard
from __future__ import annotations
import sys
from typing import Any
_ARMED = False
_HOOK_INSTALLED = False
_BLOCKED = frozenset(
{
"socket.connect",
"socket.getaddrinfo",
"subprocess.Popen",
"os.system",
"os.posix_spawn",
}
)
class ImportTimeIO(RuntimeError):
"""Collection tried to leave the process."""
def _hook(event: str, args: tuple[Any, ...]) -> None:
if not _ARMED:
return
if event in _BLOCKED:
raise ImportTimeIO(f"{event} {args!r} during import/collection")
def arm() -> None:
global _ARMED, _HOOK_INSTALLED
if not _HOOK_INSTALLED:
sys.addaudithook(_hook)
_HOOK_INSTALLED = True
_ARMED = True
def disarm() -> None:
global _ARMED
_ARMED = False
Audit hooks cannot be removed once added. That is a language constraint, not a style choice. You arm a flag instead. Collection turns the flag on. After collection, you turn it off so real tests may open sockets under mocks.
The hook is deliberately deaf to open. Import has to read bytecode. If you block file reads, you will test the interpreter, not your package. Stay focused on events that leave the process or spawn a child.
Hang it on pytest, not on faith
A root conftest.py can own the flag. Keep the package itself free of pytest imports. Discovery policy does not belong in library code.
# conftest.py — proposed
from import_trap import arm, disarm
def pytest_configure(config):
# Arm early if conftest or plugins import the package under test.
arm()
def pytest_collection(session):
arm()
def pytest_collection_finish(session):
disarm()
pytest_configure matters because some repos import the product package from conftest.py before collection officially starts. If you only arm later, the phone call already happened. You will watch a green collect and still hang on a plane.
Then run the command that used to freeze at thirty thousand feet:
pytest --collect-only -q
If a test module still imports mypkg at the top, and mypkg still dials out, collection dies with ImportTimeIO. That is the point. The failure happens before fixtures, before VPN folklore, before someone says it works on their machine.
A second command keeps you honest outside pytest:
python -c "from import_trap import arm; arm(); import mypkg"
If that one-liner needs the network, your wheel is not a wheel. It is a client that forgot to declare itself.
When you want a breadcrumb without pytest, importtime is enough. The lines below are illustrative noise, not a measured run from this article:
# illustrative importtime output — unexecuted
import time: self [us] | cumulative | imported package
import time: 412 | 412 | mypkg._meta
import time: 8801 | 9213 | mypkg
A quiet package spends its import budget on local modules. A noisy one spends it waiting on getaddrinfo while pytest is still building node ids.
A package that looks quiet
Here is the kind of file that survives a tired review. It has no async. It has no thread. It still needs the internet to exist as a module.
# mypkg/__init__.py — bad pattern, unexecuted example
from __future__ import annotations
import json
import urllib.request
__all__ = ["VERSION", "banner", "Client"]
with urllib.request.urlopen("https://example.invalid/v1/meta", timeout=2) as resp:
_META = json.load(resp)
VERSION = str(_META.get("version", "0"))
def banner() -> str:
return f"mypkg {VERSION}"
class Client:
def __init__(self, endpoint: str) -> None:
self.endpoint = endpoint
The with block runs while pytest is still assembling a graph of names. Editors that discover tests pay for it. So do tools that import the package just to read a version string.
A lazy split keeps the public names and moves the wire call behind a function. Collection can import banner without paying DNS. Runtime can still fetch metadata when a human actually asked.
# mypkg/__init__.py — proposed repair, still unexecuted
from __future__ import annotations
from typing import Any
__all__ = ["VERSION", "banner", "Client", "load_meta"]
VERSION = "0" # packaging version, not a live banner
def load_meta() -> dict[str, Any]:
import json
import urllib.request
with urllib.request.urlopen("https://example.invalid/v1/meta", timeout=2) as resp:
return json.load(resp)
def banner() -> str:
meta = load_meta()
return f"mypkg {meta.get('version', VERSION)}"
class Client:
def __init__(self, endpoint: str) -> None:
self.endpoint = endpoint
That repair is not more fashionable. It is a boundary. Import becomes a dictionary of names. Network becomes a verb you choose. You will still want tests for load_meta. Those tests run after collection, with the trap disarmed, and with a fake HTTP layer. Collection no longer cares.
Git tags as version probes fail the same way. subprocess from __init__.py to run git describe looks clever in a local clone. It looks like a hang in a shallow CI checkout with git missing from PATH. Bake the version at build time. Do not shell out because import felt lonely.
What the hook should kill
Not every audit event is a crime. Opening your own .py files is how import works. Compiling bytecode is noise. The interesting events are the ones that leave the process.
| Event during collection | Typical cause | What you do |
|---|---|---|
socket.getaddrinfo |
hostname lookup in __init__
|
Fail. Move the call. |
socket.connect |
HTTP, Redis, SMTP at import | Fail. Same move. |
subprocess.Popen |
shelling out for a git tag | Fail. Bake the version at build. |
os.system |
leftover debug ping | Fail. Delete it. |
open on your own sources |
normal import | Allow. |
in-memory sqlite3
|
some plugins | Allow unless it hits a disk path you do not own. |
If a plugin you do not control connects during collection, pin that plugin or isolate it. Do not widen the allowlist to make the log pretty. An allowlist that grows every week is a mute button wearing a spreadsheet.
localhost is not an exemption. If your package talks to a sidecar at import, the hook should still scream. Sidecars are not present during editor discovery. A map that assumes a running daemon is not a map.
After the trap is red, a draft is cheap
Once collection fails for a real reason, the repair is usually mechanical. Pull I/O out of module scope. Keep constants local. Lazy-import heavy clients inside functions that users actually call.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode is relevant only after the hook exists on your machine. Free model access can draft the lazy split from the messy __init__.py and the trap traceback. A free server option can run the same pytest --collect-only -q you run locally, so you are not grading a diff by eye. The hook remains the judge. Do not paste secrets into that session. Do not ask a model to make collection faster without the trap; it will delete a call or cache it in a global, and you will not see the socket until the next flight.
Give the failing traceback and the one module. Ask for a change that keeps public names and removes import-time I/O. Reject patches that swallow ImportTimeIO. Reject patches that mock DNS to green the hook. The hook is not a test to be stubbed. It is a fence.
Where the fence goes blind
Audit hooks do not see every C extension. A native wheel can open a socket and never fire socket.connect in Python. Gevent and some import-time monkeypatches reorder reality. Threads started from a module body can connect after you disarm. The hook also cannot tell a harmless metadata ping from an exfiltration. It only knows the process tried to leave.
Collection is not runtime. A green collect-only run does not prove request handling is safe. It does not prove you closed files. It does not prove a worker will avoid DNS later, when a function finally runs. Pair this with explicit network tests if your product is a client.
DNS caches lie in the other direction too. A laptop that resolved a host an hour ago may collect “fine” on a plane because the stub resolver still has a TTL. The hook still helps, because getaddrinfo is the event, not the round trip. Trust the event. Do not trust the vibe that it felt fast.
Who should leave this off
Skip the trap if your suite is a protocol lab and every test module is supposed to open a socket at import. You already chose that pain. Skip it if you import hardware drivers that probe USB at module load and you cannot change that vendor package this quarter. Skip it if you do not own conftest.py.
Teams that treat collection failures as noise will disable the hook on the first red CI. Then you are back to airplane DNS. If you cannot keep the flag armed, do not add it. A fence that everyone props open is just a comment.
Keep discovery boring
A library import should be as dull as reading a map. Maps do not place phone calls. When collection needs a VPN, the package has already chosen a side effect you did not put in the README.
Leave the hook on. Run pytest --collect-only -q after every import-shape change. Collection should stay silent on your laptop, on CI, and on a plane.
Top comments (0)