The sequence of steps you’re looking for are:
-
import test- creates module object
test - sets
test.__path__(among other things) - sets
sys.modules['test']to the new module - executes
test/__init__.py, which encounters …
- creates module object
-
from test.foo import Foo(swapping to fully quilified import here)- finds
sys.modules['test'] - searches
test.__path__for search paths - finds
test/foo.py - creates module object
foo - sets
sys.modules['test.foo']to the new module - executes
test/foo.py, which encounters …
- finds
-
class Foo- executes the body
pass - creates the class object
type("Foo", (), {}) - assigns the class object to
Foo - returns back to executing
test/__init__.py
- executes the body
-
... import Foo- gets
Fooattribute oftest.foo(or would try another import if it isn’t there) - assigns it to
Fooin the current module (test)
- gets
-
__all__ = ["Foo"]- just an assignment of a literal
- returns back to whoever imported
testin the first place
So the cycles are mainly avoided by looking in sys.modules first when importing a module, and storing new modules in sys.modules before executing their code. So there’s absolutely a partial module in place when you are importing, but as long as anything you need has already been assigned (and __path__ is most common) then you can use it just fine.
I believe the error message you get warning about cyclic imports is actually a heuristic. The actual error will be AttributeError (or potentially NameError? I might be wrong on that one), but the importlib module will see the exception first. It knows that it’s in a recursive import situation, so it replaces the error with one saying that it’s probably due to a cycle, but sometimes it isn’t due to the cycle.
(e.g. pyzmq has/had a situation where a dynamic import would fail, and because it fails at a point where a submodule is importing a different submodule via the top-level module, the importer assumes it’s because the partial module doesn’t have the name yet. But it’s actually totally unrelated!)