sys.modules as the import cache, and why circular imports fail the way they do
import doesn't necessarily re-run a module's code -- the first successful import stores the module object
in sys.modules keyed by name, and every subsequent import of the same name just returns that cached
object. This is why module-level state persists across "re-imports," and it's the exact mechanism behind
circular-import failures: a module gets a partially-initialized entry in sys.modules the moment it
starts running, before it finishes -- so a circular import doesn't get a missing module, it gets an
incomplete one.
import sys, tempfile, pathlib
tmpdir = tempfile.mkdtemp()
(pathlib.Path(tmpdir) / "counter_mod.py").write_text(
"count = 0\n"
"def increment():\n"
" global count\n"
" count += 1\n"
" return count\n"
)
sys.path.insert(0, tmpdir)
import counter_mod
print("first import, increment():", counter_mod.increment())
print("increment() again:", counter_mod.increment())
import counter_mod as cm2 # NOT re-executed -- returned straight from the cache
print("re-imported (as cm2) is the SAME object:", cm2 is counter_mod)
print("cm2.count reflects the prior increments:", cm2.count)
print("'counter_mod' in sys.modules:", "counter_mod" in sys.modules)
Here's a real circular import, reproduced with two actual files written to disk at runtime (mod_a.py
imports mod_b, mod_b imports mod_a back and tries to use something from it):
import subprocess
circdir = tempfile.mkdtemp()
(pathlib.Path(circdir) / "mod_a.py").write_text(
"print(' mod_a: starting')\n"
"import mod_b\n"
"print(' mod_a: finished')\n"
"X = 1\n"
)
(pathlib.Path(circdir) / "mod_b.py").write_text(
"print(' mod_b: starting')\n"
"import mod_a\n"
"print(' mod_b: using mod_a.X:', mod_a.X)\n"
)
result = subprocess.run(
["python3", "-c", "import sys; sys.path.insert(0, '.'); import mod_a"],
cwd=circdir, capture_output=True, text=True
)
print(result.stdout, end="")
print("error:", result.stderr.strip().splitlines()[-1])