Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Modules, Environments & Packaging  ›  Lesson

Modules, Packages & What import Really Does

Modules and Packages 26 minAn import finds a file, runs it once, caches the module and binds a name
You're building a piece ofQuarry — the shipment
This piece — resolve_import(): Says which module an import line really loads, every file it runs on the way, and why it fails when it does.
Scenario Quarry is a folder of fourteen files that only runs from one directory with one command; before it can ship, every import in it has to mean exactly one thing.
Your task
Build resolve_import(files, current, statement). files lists the project's .py paths; current is the path of the file holding the statement; statement is "import X" or "from X import Y", where X may start with dots. Return {"module": <deepest dotted module loaded>, "chain": [<files run, parents first>]} — a folder's __init__.py wins over a same-named .py, and for from-imports X.Y is loaded too when it is a module. Return {"error": "ImportError"} for a relative import with no parent package or too many dots, and {"error": "ModuleNotFoundError", "name": <first missing dotted name>} when a file is missing. Example: resolve_import(FILES, "quarry/stages/parse.py", "from ..store import vault") → {"module": "quarry.store.vault", "chain": ["quarry/__init__.py", "quarry/store/__init__.py", "quarry/store/vault.py"]}.

Modules, Packages & What import Really Does

In short: import is not a declaration — it's a small program. Python finds a file on sys.path, creates an empty module object and stores it in sys.modules before running anything, runs the file's code with that object's dict as its namespace, then binds a name to it in your namespace. The cache in sys.modules means a module's body runs exactly once per program, however many times it's imported — and a package is a folder with an __init__.py, so importing something deep inside one runs every __init__.py above it first, outermost first.

import greeter three times: the first finds the file and runs its body once, the second and third find the cached module and just bind the name again — the body never reruns

Cache a slow module so it only runs once

Steps 2 to 4 of import need nothing but the standard library — done here by hand, with types.ModuleType, the very type every imported module has:

python

The body printed once, though load ran twice. With a real file the same thing happens — the browser has an in-memory filesystem, so this really imports:

What does this print?
import importlib
import pathlib
import sys
import tempfile

sys.modules.pop("greeter", None)
root = tempfile.mkdtemp()
pathlib.Path(root, "greeter.py").write_text('print("loading", end=" ")\n')
sys.path.insert(0, root)
importlib.invalidate_caches()

import greeter
import greeter
import greeter
print(sys.modules["greeter"] is greeter)
Drawing diagram…

Guard a script so importing it doesn't rerun main()

Every module has a __name__, set before its first line runs. A file started with python report.py gets the special name "__main__"; imported, it gets its dotted name. That's the entire trick behind if __name__ == "__main__"::

python

Run as a script, the guarded block calls main(). Imported, the file only defines main and leaves calling it to whoever imported it. Without the guard, merely importing the module would build a report as a side effect, every time:

What does this print?
import types

report = types.ModuleType("quarry.report")
exec("where = __name__.rpartition('.')[2]", report.__dict__)
print(report.where)

Reach the vault from a sibling package

Fourteen modules in one flat folder is hard to navigate. A package groups them — a folder with an __init__.py — and the folder's name becomes part of every file's dotted name:

quarry/
├── __init__.py          → quarry
├── cli.py               → quarry.cli
├── stages/
│   ├── __init__.py      → quarry.stages
│   └── parse.py         → quarry.stages.parse
└── store/
    ├── __init__.py      → quarry.store
    └── vault.py         → quarry.store.vault
Drawing diagram…

A package is a module — quarry has a __dict__ and a body, which is its __init__.py — and importing anything inside it runs every __init__.py above it first, because quarry.store.vault cannot exist until quarry and quarry.store do:

python

quarry/stages/parse.py can reach the vault two ways: from quarry.store import vault (absolute) or from ..store import vault (relative — each dot climbs one package level). Step through the dot arithmetic:

step through it
1package = "quarry.stages"
2target = "..store"
3level = len(target) - len(target.lstrip("."))
4parts = package.split(".")
5base = parts[:len(parts) - (level - 1)]
6absolute = ".".join(base + [target[level:]])
7print(level, absolute)

Run cli.py directly and a relative import fails with ImportError: attempted relative import with no known parent package: the file is __main__, belonging to no package, so the dot has nothing to be relative to. python -m quarry.cli imports it by its dotted name instead, and the same line works — never make a module work only by running it as a script.

Traps worth knowing

Circular imports. Module a imports b, and b imports a. A module is cached before its body runs, so when b asks for a, Python hands back the half-built module, stopped on the line that imported b — anything below that line doesn't exist yet:

broken — fix itImportError

cli imports report, and report imports a name from cli that cli hasn't defined yet. Change report.py's source so it no longer needs VERSION at import time, and the program prints "quarry 0.3".

Shadowing. Name your own file random.py and import random in that folder finds yours first, silently. Import-time cost is startup cost: a CLI that eagerly imports 30 stage modules to print --help pays for all of them — keep module bodies cheap, and import heavy things inside the function that needs them.

Quick practice

What does this print?
import sys, importlib, pathlib, tempfile

sys.modules.pop("widget", None)
root = tempfile.mkdtemp()
pathlib.Path(root, "widget.py").write_text("COUNT = 0\n")
sys.path.insert(0, root)
importlib.invalidate_caches()

import widget
import widget as w2
w2.COUNT = 99
print(widget.COUNT)
broken — fix it

Importing this module prints "building report..." as a side effect every time. Move that print under a main guard so importing it stays silent and only 42 prints.

What does this print?
import sys, importlib, pathlib, tempfile

root = tempfile.mkdtemp()
pathlib.Path(root, "random.py").write_text("def choice(seq):\n    return 'rigged'\n")
sys.path.insert(0, root)
importlib.invalidate_caches()
sys.modules.pop("random", None)

import random
print(random.choice(["a", "b", "c"]))

🎯 Your turn

Build resolve_import(files, current, statement) — the first piece of Quarry's shipment. It answers the question every confusing ImportError hides: what does this line actually load?

  • files lists the project's files, relative to the folder on sys.path.
  • current is the path of the file containing the statement.
  • statement is import X or from X import Y, where X may be relative.

Return the dotted name of the deepest module loaded and the chain of files Python runs for it, parents first. A folder's __init__.py wins over a same-named .py file. For from X import Y, if X.Y is itself a module, it is loaded too; otherwise Y is just a name inside X.

resolve_import(FILES, "quarry/stages/parse.py", "from ..store import vault")
# → {"module": "quarry.store.vault",
#    "chain": ["quarry/__init__.py", "quarry/store/__init__.py", "quarry/store/vault.py"]}

resolve_import(FILES, "cli.py", "from . import quarry")
# → {"error": "ImportError"}               # no parent package
resolve_import(FILES, "quarry/cli.py", "import quarry.yard.walk")
# → {"error": "ModuleNotFoundError", "name": "quarry.yard"}

Press ▶ Run and hit ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upFrom a path to a dotted name

Every .py file under a sys.path folder is importable by a dotted name. Write module_name(path) that turns a slash-separated path into that name: "quarry/stages/parse.py" → "quarry.stages.parse", and a package's __init__.py names the package itself: "quarry/stages/__init__.py" → "quarry.stages". Anything that isn't a .py file gives "".

modules-and-importsstrings
DrillWhat does the module define?

Importing a module runs its source top to bottom in a fresh namespace whose __name__ is the module's name — or "__main__" when the file is run as a script. Write run_as(source, name): run source with exec in a new dict whose __name__ is name, and return the sorted list of names it defined, leaving out any that start with _. Example: a file with a main() function and an if __name__ == "__main__": code = main() guard gives ["code", "main"] as "__main__" but only ["main"] as "quarry.cli".

modules-and-importsdictionaries
BuildWhat can be imported?

In a project that uses regular packages, a .py file is importable only if every folder above it holds an __init__.py. Write package_tree(files) returning a dict from each importable dotted name to "package" or "module". A folder with __init__.py is a package (named for the folder); other .py files are modules. Files under a folder chain with a missing __init__.py are left out, and so is anything that isn't .py. Example: ["quarry/__init__.py", "quarry/cli.py"] → {"quarry": "package", "quarry.cli": "module"}.

packagesdictionaries
BossFind the circular import

A circular import is a cycle in the graph of who imports whom at module level. Write find_cycle(imports): imports maps each project module to the list of modules it imports at the top of its file. Imports of names that aren't keys (like json) are outside the project — ignore them. Search depth-first, starting from the module names in sorted order and following each list in the order given, and return the first cycle you close as a list that starts and ends with the same module, e.g. ["quarry.cli", "quarry.report", "quarry.cli"]. Return [] when there is none.

modules-and-importspackagescycle-detection
CapstoneImport a package you just wrote

Now do it for real. files maps paths to source text. Write run_package(files, entry, attr): write the files into a fresh temporary folder, put that folder at the front of sys.path, import the dotted module entry with importlib.import_module, and return {"value": <its attribute attr>, "loaded": <sorted names in sys.modules that belong to entry's top-level package>}. If the import raises, return {"error": <the exception's type name>}. Leave no trace: before and after, drop every sys.modules entry for that top-level package, and take your folder back off sys.path. Call importlib.invalidate_caches() after writing files — Python caches folder listings and may not see files made after it looked.

modules-and-importspackagesexceptions
ExplainWhy the same file fails as a script

quarry/cli.py starts with from .store import vault. Running python -m quarry.cli works, but python quarry/cli.py stops with ImportError: attempted relative import with no known parent package. Explain what is different about the two ways of starting the same file, and why Quarry's installed quarry command will never hit this error.

resolve_import.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
resolve_import(files, current, statement) → dictSay which module an import statement loads, and every file it runs on the way.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.