21Packages & program structure
In Chapter 20 we cracked the import machine open. We met sys.path, the chain of finders, the loaders, and the .pyc cache, then watched a single module get found, compiled, and cached. In this one we point that same machine at a whole directory. Your playlist has outgrown a single file. Shuffle logic, storage, and the data classes now sprawl across a dozen .py files, and import shuffle sits next to import config with no hint of which belongs to whom. So we fold the pile under one name, import playlist, the way import json hands you a whole folder of code through a single handle. Here's the plan. We'll see exactly what runs when a directory imports, and meet __init__.py as runnable module code rather than ceremony. Then we'll sort absolute imports from relative ones, learn what python -m quietly repairs, and watch two innocent imports poison each other. The whole way through we keep asking the one question that decides all of it: when a folder becomes a name, what code runs, and in what order? By the end we've restructured the whole playlist into a real package. There's one clean front door, the private plumbing hidden behind it, and python -m playlist starting the program from a single command.
01A package is a directory that imports as one name
Let's start with the mess. You've been writing one file per idea, like engine.py, storage.py, and model.py, and reaching across them with import storage. It worked, but the folder is now a junk drawer. Nothing says these files are one thing. Meanwhile import json hands you a whole directory of standard-library code through a single word. That's exactly what you want here: import playlist, one name for the whole project. So here's the question, and it's sharper than it looks. How does a folder become a single importable thing, and what code, if any, runs the moment it does?
Watch what import actually does, because it isn't what most people picture. import playlist does not go hunting for a file called playlist. It walks a list of finders, the objects sitting in sys.meta_path, the same crew you met last chapter. It asks each one the same question: do you know how to load the name 'playlist'? The finder that handles the filesystem, FileFinder, scans the directories on sys.path. The instant it spots a directory named playlist holding an __init__.py, it says yes. It hands back a module spec, a little record whose loader is a SourceFileLoader aimed straight at playlist/__init__.py. That's the handle the rest of the import is built on.
Now the import machinery runs a fixed four-step ritual, and every step matters later:
# 1. create an empty module object — a namespace, nothing in it yet
mod = new_module("playlist")
# 2. mark it as a PACKAGE by giving it __path__
mod.__path__ = [".../playlist"] # where future sub-imports will search
# 3. register it BEFORE running any of its code
sys.modules["playlist"] = mod # remember this line — §5 turns on it
# 4. exec __init__.py's bytecode, using mod.__dict__ as its globals
exec(compile(init_source), mod.__dict__)Read step 2 twice. The single thing that separates a package from an ordinary module is the attribute __path__, a list holding the package's own directory. An ordinary module like math has no __path__. A package has one, and that list is the map future sub-imports consult when they need to find playlist's children. Step 4 is the reveal. __init__.py is not configuration, not a manifest, not boilerplate the interpreter reads for settings. It is the package's module body: ordinary Python that runs top to bottom, exactly like a script. The fresh module's __dict__ serves as its global namespace. Whatever names it binds become the package's attributes. Print something at the top of __init__.py and it prints the instant someone imports the package.
print("init running")
print("__name__ =", __name__) # playlist
print("__package__ =", __package__) # playlist
print("__path__ =", __path__) # ['/abs/path/to/playlist']And here is the surprise that trips everyone: importing the package does not import its submodules. import playlist runs __init__.py and stops, so there is no playlist.engine yet — touch it and you get AttributeError. A submodule springs into existence only when something explicitly imports it, and at that moment the machinery does something quietly elegant. After loading engine.py, it binds the submodule onto its parent, so playlist.engine becomes a real attribute of the playlist module object. The package is a namespace that fills in on demand.
__init__.py, one namethere is no build step and no registry: the folder is importable the moment it holds that filejukebox holding an __init__.py is a package. Nothing to declare, nothing to install.__init__.pyNot a marker file. It is the package's module body, executed once with the package's own namespace as its globals.__path__The single attribute that separates a package from a module: a list holding the package's directory, and the map every sub-import consults.sys.path must contain the package's parent, never the package itself. Standing in myproject is what makes index 0 correct.import jukeboxRuns __init__.py and stops. It does not walk the folder, and it imports zero submodules.import jukebox.tracksImports the parent first, always. Then it binds tracks as a real attribute of the live jukebox module object.sys.modules hit. Nothing re-runs, and both names point at the same module object in this process.you type
# ---------- jukebox/__init__.py ----------
print("jukebox/__init__.py running")
# ---------- jukebox/tracks.py ----------
print("jukebox/tracks.py running")
TITLES = ["Blinding Lights", "Titanium", "Levels"]
# ---------- run.py (one level UP from the package) ----------
import os
import sys
import jukebox # runs jukebox/__init__.py -- once
print("name :", jukebox.__name__)
print("__file__ :", "/".join(jukebox.__file__.replace(os.sep, "/").split("/")[-2:]))
print("__path__ :", [os.path.basename(p) + "/" for p in jukebox.__path__])
print("has tracks? :", hasattr(jukebox, "tracks"))
import jukebox.tracks # NOW the submodule runs
print("has tracks? :", hasattr(jukebox, "tracks"))
print("bound onto :", jukebox.tracks.__name__)
print("same object :", sys.modules["jukebox.tracks"] is jukebox.tracks)
print("first title :", jukebox.tracks.TITLES[0])
import jukebox # cached: not one line re-runs
print("import #2 : nothing printed -- sys.modules hit")
# ---------- the terminal, standing in myproject/ ----------
$ python run.py
$ python -c "import jukebox; jukebox.tracks"you see
$ python run.py
jukebox/__init__.py running
name : jukebox
__file__ : jukebox/__init__.py
__path__ : ['jukebox/']
has tracks? : False
jukebox/tracks.py running
has tracks? : True
bound onto : jukebox.tracks
same object : True
first title : Blinding Lights
import #2 : nothing printed -- sys.modules hit
$ python -c "import jukebox; jukebox.tracks"
jukebox/__init__.py running
Traceback (most recent call last):
File "<string>", line 1, in <module>
AttributeError: module 'jukebox' has no attribute 'tracks'- Three
import jukeboxstatements, onejukebox/__init__.py running. That is the cache from Chapter 20, not a trick. - The
AttributeErroris the rule, not the bug.import jukeboxnever openedtracks.py, so there is nothing to attach. - Run from inside
jukebox/and nothing resolves. The parent directory is the one that has to be onsys.path. - An empty
__init__.pyis a complete one. A module body with zero statements is still a module body. - Since 3.3 a folder without
__init__.pyimports anyway, as a namespace package. Write the file regardless — you want the hook and the single directory. from jukebox import tracksandimport jukebox.tracksexecute the identical two files. Only the name left in your namespace differs.
__init__.py; the folder on disk becomes a live module object whose __path__ points back to the folder so submodule imports know where to look.__init__.py is that name's module body — real code that runs once, top to bottom, the first time anyone imports it. The folder is the filing cabinet; __init__.py is the code that decides what's in the drawer you actually open.That sys.modules line in step 3 also explains why a package initialises exactly once. Import playlist twice in the same program and __init__.py runs a single time. On the second import, the machinery finds playlist already sitting in sys.modules and hands back the cached object without re-executing a line. Every module is a process-wide singleton keyed by its dotted name. This is the same cache that, in Ch 22 onward, you'll watch your program lean on as it starts reaching past its own memory.
__init__.py still imports? Since Python 3.3, yes — it becomes a namespace package, and this is the one deliberate exception to everything above. Its __path__ is not a plain list but a special _NamespacePath that can span several directories on sys.path at once, and it has no __file__ and runs no init code, because there is no __init__.py to run. That's the point of them — letting one import name be assembled from folders shipped by different projects. For your own project, always write the __init__.py; you want the init hook and the single, unambiguous directory.✗ The myth
"__init__.py is just a marker file — an empty file Python needs to notice the folder."
✓ The reality
It can be empty, but it is never inert. It is the package's module body, executed with the package's own namespace as globals. An empty one runs zero statements; a full one can import submodules, define the public API, and set state. Empty is a choice, not a requirement.
You have one name for the whole folder. But right now users can reach every file and every helper inside it. Next: how __init__.py lets you publish a clean front counter and hide the plumbing behind it. →
02__init__.py defines the public surface
Your users type from playlist import Track and it works. Good. But nothing stops them typing from playlist.engine._internal_shuffle import _fisher_yates — and now someone's code depends on a private helper you meant to rewrite next week. The moment they do, your freedom to change it is gone. You need a way to say, out loud and enforceable-in-spirit: this is the API I promise to keep; everything else is plumbing I reserve the right to move. Where does that line live, and how do you draw it?
The answer falls straight out of Section 1. A package's public surface is nothing more mystical than the set of names bound in the __init__ module's namespace after it finishes executing. There is no separate export table, no public: keyword. If a name is an attribute of the package module, it's reachable as playlist.that_name; if it isn't, it isn't. So you curate the surface by choosing what __init__.py binds. The tool is the re-export:
# pull the good names UP from the submodules where they live
from .model import Track, Playlist
from .engine import Player
from .storage import save, load
__all__ = ["Track", "Playlist", "Player", "save", "load"]That from .engine import Player runs inside __init__.py, so it binds Player as an attribute of the playlist module, and now from playlist import Player resolves directly. The user never learns, and never needs to learn, that Player physically lives in engine.py. This is facade construction: your folder layout is your implementation, free to be reorganised, while the import path is your API, held stable. Move Player from engine.py into engine/core.py next year, fix the one line in __init__.py, and every caller keeps working. The facade decouples the two.
__init__.py — and the two it cannot doempty is legal, the re-export builds the facade, and __all__ curates import * and nothing else__init__.py publishes nothing and costs nothing. Reach for re-exports when there is an API you are willing to promise.from .tracks import TrackBinds Track as an attribute of the package. from jukebox import Track then resolves, and the caller never learns where the class lives.Track into tracks/core.py next year, edit one line here, and every caller survives.__all__Governs exactly one thing: which names from jukebox import * copies out. It hides nothing and forbids nothing._Also exactly one thing: import * skips it when there is no __all__. Attribute access reaches it every single time.from .tracks import Track imports jukebox.tracks, so jukebox.tracks is reachable too. A facade adds a door; it removes none.__init__.py is a slow import jukebox.you type
# ---------- jukebox/tracks.py ----------
"""tracks.py -- what a track IS. No I/O."""
from dataclasses import dataclass
@dataclass
class Track:
title: str
artist: str
seconds: int
def __str__(self):
minutes, secs = divmod(self.seconds, 60)
return f"{self.title} - {self.artist} ({minutes}:{secs:02d})"
# ---------- jukebox/storage.py ----------
"""storage.py -- the only file that knows what JSON is."""
import json
from .tracks import Track
def save(tracks, path):
with open(path, "w", encoding="utf-8") as f:
json.dump([vars(t) for t in tracks], f)
return path
def load(path):
with open(path, "r", encoding="utf-8") as f:
return [Track(**row) for row in json.load(f)]
def _tmp_name(path): # plumbing: not part of the promise
return path + ".part"
# ---------- jukebox/__init__.py ----------
"""jukebox -- a tiny music library."""
from .tracks import Track
from .storage import load, save
__all__ = ["Track", "load", "save"]
__version__ = "1.0.0"
# ---------- surface.py ----------
import jukebox
print("__version__ :", jukebox.__version__)
print("__all__ :", jukebox.__all__)
ns = {}
exec("from jukebox import *", ns)
print("import * :", sorted(n for n in ns if not n.startswith("__")))
print("same class :", jukebox.Track is jukebox.tracks.Track)
print("submodule :", jukebox.tracks.__name__, "| still reachable")
print("private :", jukebox.storage._tmp_name("library.json"))
ns = {}
exec("from jukebox.storage import *", ns) # storage has NO __all__
print("storage star:", sorted(n for n in ns if not n.startswith("__")))you see
$ python surface.py
__version__ : 1.0.0
__all__ : ['Track', 'load', 'save']
import * : ['Track', 'load', 'save']
same class : True
submodule : jukebox.tracks | still reachable
private : library.json.part
storage star: ['Track', 'json', 'load', 'save']
$ python -c "from jukebox import Track; print(Track('Titanium', 'David Guetta', 245))"
Titanium - David Guetta (4:05)
$ python -c "from jukebox.storage import _tmp_name; print(_tmp_name('a.json'))"
a.json.part- Read
storage startwice. With no__all__,import *also exportedjsonandTrack— namesstoragemerely imported. _tmp_nameescaped that star import only because of its underscore, and it is still one dotted attribute away.__all__is a list of strings. A typo in it is silent until someone runsimport *and gets anAttributeError.- Re-exporting
Trackalso bindsjukebox.tracks. The submodule never becomes invisible, and never can. - Every name in
__all__costs an import at package load. Three light names are free; six heavy ones are a second of startup. __version__is a convention, not a hook. No part of Python reads it; the packaging tools readpyproject.toml.
__init__.py re-exports a small set of public names onto the front counter; the underscore-prefixed internals stay behind the glass, and __all__ governs only what import * grabs.Now the two things people expect to be privacy controls, and neither one is. First, __all__. It is a list of strings, and it governs exactly one thing: what from playlist import * pulls into the caller's namespace. That's it. It does not hide anything. With the __all__ above, from playlist import * brings in those five names. But from playlist import debug_dump still works fine for any name that actually exists, listed or not. So __all__ is a curated star-export manifest and a documentation signal ("these are the headline names"), never a lock.
Second, the leading underscore. Naming a helper _fisher_yates or _seed also changes exactly one behaviour: names starting with _ are skipped by import * when there's no __all__. Attribute access still reaches them perfectly, so playlist.engine._fisher_yates reads out the function without complaint. The underscore is a message to humans and linters: touch this and you're on your own; I may delete it. Be exact about the mechanism, because half-truths here breed superstition. Python has no access control. There is no private keyword and no runtime that forbids reaching a name. "Private" in Python means precisely two non-enforcing things: omitted from __all__, and marked with an underscore, plus the social contract that goes with them. The interpreter will hand out every name you ask for, because the convention is a fence, not a wall.
Here's why the facade earns its keep, in milliseconds. Suppose __init__.py eagerly imports all six submodules, and each drags in a heavy dependency costing about 200 ms on a cold cache. That's 6 × 200 = 1200 ms — over a second — burned before import playlist even returns. A lean facade that re-exports three light names pays perhaps 5 ms, and defers the heavy work until something actually reaches for it.
Player and plain import playlist now executes all of engine.py — its own imports, its top-level work, everything — whether the user wanted the player or not. A heavy __init__.py makes import playlist crawl, and, worse, it is the classic trigger for the import cycles of Section 5, because your __init__ now depends on every submodule finishing first.__getattr__. Define def __getattr__(name) in __init__.py and Python calls it only when an attribute is missing, so you can import the heavy submodule lazily, the first time playlist.Player is actually touched, instead of eagerly at package load. That's exactly how big libraries keep import numpy-style calls snappy. It is an advanced move — reach for it once your __init__ genuinely hurts, not before.from .x import * in __init__.py runs that submodule's entire import at package-load time — its own import numpy, its top-level work, all of it — even for a user who only wanted Track. That is why a “fast” one-liner can detonate a cascade.__init__.py as "the file that makes it a package." Think of it as the package's storefront. The submodules are your workshop out back — messy, reorganised often, full of half-finished tools. __init__.py is the counter facing the street: it decides which finished goods go on display, under names that stay put even when you rebuild the whole workshop behind it.You've published names. But those from .engine import Player lines with a leading dot are doing something specific — and they behave differently depending on how the file is run. Time to understand the dot. →
03Absolute vs relative imports
You reorganise: engine.py moves into a subfolder, and suddenly import storage throws ModuleNotFoundError, even though storage.py is right there next to it. You patch it to from .storage import save, and imported through the package, it works. Relieved, you run python engine.py to test that file alone, and it detonates: ImportError: attempted relative import with no known parent package. Same line, same file, opposite outcomes. You cannot move on until you understand why.
jukebox/ holds an __init__.py, and section 1 said that is all it takes, so on disk this is a package. Which means report.py, sitting in there beside it, is part of the package — the interpreter can see the __init__.py lying right next to the file it is running, so when I write from .tracks import LIBRARY the dot has something obvious to point at. And if python jukebox/report.py still complains about a missing parent package, then it is a directory problem: I am standing in the wrong place, and either a cd or one line of sys.path will sort it out.# ---------- jukebox/__init__.py ----------
print("jukebox/__init__.py running")
# ---------- jukebox/tracks.py ----------
LIBRARY = ["Blinding Lights", "Titanium", "Levels"]
# ---------- jukebox/report.py ----------
print("report __package__ =", repr(__package__))
from .tracks import LIBRARY
print("report sees", len(LIBRARY), "tracks")
# ---------- standing in myproject/, launch the SAME file two ways ----------
$ python jukebox/report.py
$ python -m jukebox.report$ python jukebox/report.py
report __package__ = None
Traceback (most recent call last):
File "C:\tmp\myproject\jukebox\report.py", line 3, in <module>
from .tracks import LIBRARY
ImportError: attempted relative import with no known parent package
$ python -m jukebox.report
jukebox/__init__.py running
report __package__ = 'jukebox'
report sees 3 tracksjukebox/__init__.py running. The package was not merely nameless — it never came into existence: nothing imported it, its module body never ran, and there was no live jukebox object in that process for a dot to be relative to. Run 2 printed it before anything else, because -m imports the package first and only then runs your file. Now read the field. One file nobody edited reports None in the first run and 'jukebox' in the second, and that second value came from the dotted name you typed, jukebox.report — not from the folder the file happens to sit in. Which is also why the second half of the belief dies quietly: run the script form from any directory on the disk and it fails identically, because that form never asks the working directory a question at all. One honest refinement, since section 3 rounds this off: on 3.12 the script form leaves __package__ as None, not the empty string. '' is what a genuine top-level imported module carries — a bare import settings gives you exactly that. The resolver turns both away for the identical reason, no base to strip a name from, which is why the distinction never changes what you do about it.There are two import styles, and they resolve through completely different machinery. An absolute import names the full dotted path from a top-level entry on sys.path: from playlist.storage import save. The finder walks it segment by segment — find playlist, descend into it, find storage, pull out save. It depends on nothing but sys.path and the name.
A relative import, like from .storage import save or from ..util import log, never touches a filesystem name directly. The compiler encodes the leading dots as a level count: one dot is level=1, two dots level=2. At runtime the interpreter resolves the target from the current module's __package__ string. It walks level-1 dots up from it, then appends the name after the dots. The whole thing is relative to one attribute, __package__, and hangs entirely on that string being set correctly.
Count it yourself, because the dots are just arithmetic. Say playlist/engine/core.py is imported, so its __package__ is "playlist.engine". Now from ..model import Track carries two dots, so level=2. The resolver walks level-1 = 1 dot up from playlist.engine, landing on playlist. Then it appends model, giving playlist.model. Every dot past the first is one step up the tree.
# inside module playlist.audio.engine → __package__ = "playlist.audio"
from ..util import log # level = 2 (two dots)
# resolver: start at __package__ -> "playlist.audio"
# strip (level-1)=1 name -> "playlist"
# append the target "util" -> "playlist.util"
# result: from playlist.util import logNow the crash makes sense. When you import playlist.engine, the machinery stamps engine.__package__ = "playlist", so .storage resolves to playlist.storage — clean. But when you run python engine.py, that file is loaded as the startup module: its __name__ becomes "__main__" and its __package__ becomes "" — the empty string. The dot resolver is asked to walk up from nothing. There is no parent package string to strip a name from, so the interpreter can only raise: attempted relative import with no known parent package. The line isn't wrong. The context is missing. This is the exact hole that python -m, the next section, was built to fill.
__package__; the exact same line resolves when the file is a package submodule but fails when it is run directly as __main__.__package__; launch the file as a script and there is nothing to do the arithmetic on. is hereOne dot is level 1, and level 1 strips nothing. .tracks means my sibling, never my parent... is up oneTwo dots strip one name off __package__. Every extra dot is one more step up the tree, and no dot count means "the root".__package__The only input to the whole calculation. A plain string the loader stamped on the module when it loaded it.__package__ becomes "". There is no base to strip from, so the interpreter raises rather than guess at your layout.attempted relative import with no known parent package names the missing thing precisely. It is a context error, never a spelling error.from .tracks import ... never spells jukebox. Rename the folder and every internal import keeps resolving, untouched.from jukebox.tracks import ... reads as an address, always resolves under -m, and is the safe default in an entry point.you type
# ---------- jukebox/report.py ----------
from . import tracks # the sibling MODULE object
from .tracks import as_clock # a NAME out of the sibling
print("__name__ =", __name__)
print("__package__ =", __package__)
print("total =", as_clock(785), "|", tracks.as_clock(785))
# ---------- jukebox/formats/m3u.py ----------
from ..tracks import as_clock # level=2: strip one name, then append
print("__name__ =", __name__)
print("__package__ =", __package__)
print("line =", "#EXTINF:" + as_clock(245))
# ---------- the terminal, standing in myproject/ ----------
$ python -m jukebox.report
$ python jukebox/report.py
$ python -m jukebox.formats.m3uyou see
$ python -m jukebox.report
__name__ = __main__
__package__ = jukebox
total = 13:05 | 13:05
$ python jukebox/report.py
Traceback (most recent call last):
File "C:\tmp\myproject\jukebox\report.py", line 2, in <module>
from . import tracks # the sibling MODULE object
^^^^^^^^^^^^^^^^^^^^
ImportError: attempted relative import with no known parent package
$ python -m jukebox.formats.m3u
__name__ = __main__
__package__ = jukebox.formats
line = #EXTINF:4:05- Under
-m,__name__is__main__and__package__isjukebox. Both true at once, which no plain script can manage. from . import tracksandfrom .tracks import as_clockare both relative. The first binds a module; the second binds a name out of it.- The crash has nothing to do with where the file sits. Move it anywhere, launch it as a script, and
__package__is still"". python -c "from . import jukebox"raises the identical error, for the identical reason. There is no package context at the prompt either.- Three dots is not "the top of the project". It is level 3, stripping two names, and it fails outright if
__package__is not that deep. - A relative import in a file you also launch directly is the bug everyone writes once. Give that file an absolute import, or launch it with
-m.
So which style should you use? The trade is real, and it's about who you're protecting. Relative imports say "my neighbour." from .storage import save means the sibling module in whatever package I live in, and it never spells the top package's name. So if you rename playlist to setlist tomorrow, every relative import inside keeps working untouched, which makes a package renamable and relocatable. Absolute imports say the full address. from playlist.storage import save is explicit and unambiguous, and it always resolves under -m, so it's the safe default for anything you might run as a program. The discipline that keeps you out of trouble is a single rule: never rely on a relative import in a file you also run directly as a script. For library code deep in the package, relative is lovely. For an entry point, use absolute, or run it through -m.
is that proves they are one object# ---------- jukebox/tracks.py ----------
"""tracks.py -- one function, about to be imported five ways."""
print("tracks.py body running")
def as_clock(seconds):
minutes, secs = divmod(seconds, 60)
return f"{minutes}:{secs:02d}"
# ---------- jukebox/__init__.py ----------
"""jukebox -- the facade re-exports as_clock."""
from .tracks import as_clock
__all__ = ["as_clock"]
# ---------- import_styles.py ----------
# import_styles.py -- five spellings, ONE function object
import sys
import jukebox # 1. the package
import jukebox.tracks # 2. the submodule, dotted
from jukebox import tracks # 3. the submodule, bare name
from jukebox.tracks import as_clock # 4. the function itself
from jukebox import as_clock as viafacade # 5. through __init__.py
spellings = [
("jukebox.tracks.as_clock", jukebox.tracks.as_clock),
("tracks.as_clock", tracks.as_clock),
("as_clock", as_clock),
("viafacade", viafacade),
("jukebox.as_clock", jukebox.as_clock),
]
for name, fn in spellings:
print(f"{name:<24} is as_clock -> {fn is as_clock} {hex(id(fn))}")
print()
print("sys.modules keys :", [k for k in sys.modules if k.startswith("jukebox")])
print("one module object :", jukebox.tracks is tracks is sys.modules["jukebox.tracks"])
print("where it lives :", as_clock.__module__)
print("what it computes :", as_clock(245), viafacade(245), tracks.as_clock(245))
$ python import_styles.py tracks.py body running jukebox.tracks.as_clock is as_clock -> True 0x18927226520 tracks.as_clock is as_clock -> True 0x18927226520 as_clock is as_clock -> True 0x18927226520 viafacade is as_clock -> True 0x18927226520 jukebox.as_clock is as_clock -> True 0x18927226520 sys.modules keys : ['jukebox.tracks', 'jukebox'] one module object : True where it lives : jukebox.tracks what it computes : 4:05 4:05 4:05
import_styles.py beside them, then run it. Five import statements, five different spellings, and one line of output tells you the whole story: tracks.py body running printed once. Read the address column next. It is the same hexadecimal number five times, because there is one as_clock function object in this process and every spelling is a name for it. Your number will differ from mine — it is a memory address, and it changes every run — but all five rows will always match each other. That is what is is asserting: not equal values, the same object. Notice which two things are genuinely different here. sys.modules holds two keys, jukebox and jukebox.tracks, because a package and its submodule are two separate module objects. And jukebox.tracks is tracks is True, so line 2 and line 3 did not make two modules — they bound two names to one. The facade adds nothing physical either: jukebox.as_clock came from __init__.py doing from .tracks import as_clock, which is another name for the very same function. Then look at as_clock.__module__. It prints jukebox.tracks no matter which spelling you used to reach it, because the function remembers where it was defined and has no idea how you got hold of it. That is the honest summary of every import in this chapter: import does not copy code, and it does not move it. It runs a module body once and hands out references. Two things to try. Delete the from .tracks import as_clock line in __init__.py, re-run, and watch line 5 fail with an ImportError naming the facade. Then add del jukebox.tracks.as_clock at the end and print as_clock(245) again — your local name still works, because you hold a reference the deletion never touched.ModuleNotFoundError: storage is to bolt the folder onto sys.path so bare import storage resolves. It works — until your package is imported from anywhere else, at which point storage is either missing or, worse, collides with some other project's storage. You've turned a private submodule into a global name. Import it as what it is — from .storage import save, or from playlist.storage import save — and the ambiguity vanishes.__package__" — i.e. stay in the current package and look for a sibling. Two dots (level 2) strip one name and land in the parent package. So . is here, .. is up one, ... is up two. The number of dots is literally the number you feed the resolver as level, and it subtracts one before it starts walking.If relative imports need a real package context, and running a file directly destroys that context, there must be a way to run a package as a package. There is — one command, and it's the front door your project has been missing. →
04python -m and __main__.py: running a package
You want your project to run with one clean command, python -m playlist, not python playlist/some_file_i_happened_to_pick.py. And you're done with the relative-import crash from the last section. It turns out both problems have a single answer. It's a flag you've probably typed without knowing what it repairs. To use it well, you need to see how differently CPython starts up in the two cases.
When you run python playlist/engine.py, the interpreter takes the script path. It sets sys.path[0] to the script's own directory (playlist/), then executes the file as the startup module, with __name__ = "__main__" and __package__ = "". That's the broken world of Section 3. There's no parent package, so every relative import inside dies. And because playlist/ itself is on the path but its parent isn't, import playlist may not even resolve.
When you run python -m playlist, a different subsystem takes over: it's called runpy, and it does three things in order:
# 1. put the CURRENT WORKING DIRECTORY on sys.path[0]
# so the top-level package 'playlist' is importable
sys.path.insert(0, os.getcwd())
# 2. IMPORT the package normally -> runs playlist/__init__.py,
# so 'playlist' is a real, correctly-initialised package
import playlist
# 3. find playlist/__main__.py and exec it AS the startup module,
# but with package context preserved:
# __name__ = "__main__" (so the guard fires)
# __package__ = "playlist" (so relative imports RESOLVE)Step 3 is the whole trick, and it's genuinely clever. The entry module runs under the name __main__, so if __name__ == "__main__": fires and it behaves like the program's start. And it carries __package__ = "playlist", so relative imports inside it resolve exactly as they would in any library module. Both truths hold at once: I am the program and I live inside a package. A plain script can never have both. That is why python -m is the invocation that makes from .engine import Player work in an entry point.
The file that runs is __main__.py, and now its role is clear. It is the package's designated entry point, the folder-level equivalent of the if __name__ == "__main__": block you'd put at the bottom of a single script. You don't pick a random file to be the front door. You write playlist/__main__.py, and python -m playlist finds it by convention.
python -m and __main__.py — giving the folder a front doorrunpy imports the package first, then runs __main__.py with the package context still attached-m takes a module nameNot a path. python -m jukebox — never -m jukebox/, never -m jukebox/__main__.py. Dots, not slashes.__init__.py first-m imports the package before it looks for an entry point, so a broken __init__.py kills the run before line 1 of your program.__main__.pyPure convention, and the same one the standard library uses. python -m http.server is exactly this file, in that folder.sys.path[0]Under -m it is the current directory. Under python file.py it is the file's own folder. That single row is the whole difference.sys.argvIdentical under both forms. sys.argv[0] is the path of the file that ran; your arguments start at index 1, exactly as in a script.-m is the only way to hold both, and it is why the front door works.__main__.py runs only under -m, but keep if __name__ == "__main__": in any submodule you want to self-test.you type
# ---------- jukebox/__init__.py ----------
print("[1] __init__.py -- the package builds itself first")
# ---------- jukebox/tracks.py ----------
print("[2] tracks.py -- imported by the entry point")
LIBRARY = ["Blinding Lights", "Titanium", "Levels"]
def as_clock(seconds):
minutes, secs = divmod(seconds, 60)
return f"{minutes}:{secs:02d}"
if __name__ == "__main__":
print("tracks.py self-test:", as_clock(245))
# ---------- jukebox/__main__.py ----------
import sys
from .tracks import LIBRARY, as_clock # resolves ONLY with a package context
print("[3] __main__.py -- and only now the program runs")
print(" __name__ =", __name__)
print(" __package__ =", __package__)
print(" sys.argv[1:] =", sys.argv[1:])
if sys.argv[1:2] == ["play"]:
print(" now playing :", LIBRARY[int(sys.argv[2]) - 1])
else:
for i, title in enumerate(LIBRARY, start=1):
print(f" {i}. {title}")
# ---------- the terminal, standing in myproject/ ----------
$ python -m jukebox
$ python -m jukebox play 2
$ python -m jukebox.tracks
$ python jukebox/__main__.pyyou see
$ python -m jukebox
[1] __init__.py -- the package builds itself first
[2] tracks.py -- imported by the entry point
[3] __main__.py -- and only now the program runs
__name__ = __main__
__package__ = jukebox
sys.argv[1:] = []
1. Blinding Lights
2. Titanium
3. Levels
$ python -m jukebox play 2
[1] __init__.py -- the package builds itself first
[2] tracks.py -- imported by the entry point
[3] __main__.py -- and only now the program runs
__name__ = __main__
__package__ = jukebox
sys.argv[1:] = ['play', '2']
now playing : Titanium
$ python -m jukebox.tracks
[1] __init__.py -- the package builds itself first
[2] tracks.py -- imported by the entry point
tracks.py self-test: 4:05
$ python jukebox/__main__.py
Traceback (most recent call last):
File "C:\tmp\myproject\jukebox\__main__.py", line 4, in <module>
from .tracks import LIBRARY, as_clock # resolves ONLY with a package context
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
ImportError: attempted relative import with no known parent package[1]always prints before[3].-mconstructs the package, then runs it — there is no way to skip the first half.python -m jukebox.tracksprinted[1]too. Importing a submodule imports its parent first, without exception.- The fourth command dies on the relative import, and it dies identically from every directory. Standing somewhere else does not help.
-mputs the current directory onsys.path. Run it from insidejukebox/and Python reportsNo module named jukebox.- No
__main__.pyin the package gives a very literal error:'jukebox' is a package and cannot be directly executed. sys.argv[0]under-mis the full path of__main__.py, not the stringjukebox. Do not parse a program name out of it.
from .engine import Player # resolves under -m, would crash as a script
from .storage import load
print("__name__ =", __name__) # __main__
print("__package__ =", __package__) # playlist — both true at once
tracks = load("library.json")
Player(tracks).run()python -m playlist routes through runpy, which imports the package first and executes __main__.py with __package__ set.This also demystifies __name__ itself, the most cargo-culted line in Python. __name__ is simply the string the loader stamped on the module when it loaded it. It's "playlist.engine" when imported as a library, and "__main__" when it's the startup module. So if __name__ == "__main__": is not magic. It's a plain string comparison asking one question: am I the program being run, or am I being imported by someone else? Run this only when I'm the program, and stay quiet when I'm a library. The guard has meant that, and only that, all along.
$ python -m jukebox jukebox/__init__.py running C:\Python312\python.exe: No module named jukebox.__main__; 'jukebox' is a package and cannot be directly executed
-m takes a dotted module name, so asking for jukebox sent it looking for a submodule spelled exactly __main__ inside that package — which is the fix written out for you, down to the filename. The second half explains why nothing could sensibly be substituted for it. A package's own body is __init__.py, and you can watch it printing directly above the error, so it has already run; running that same file a second time, now under __name__ == "__main__", would put one file into this one process twice under two module objects, which is the exact duplication the cache from section 1 exists to prevent. So read this as a report rather than a fault. Nothing was misspelled, nothing failed to import, and the package built itself perfectly. It simply has no front door yet.jukebox/__main__.py — the message already gave you the name; -m finds it by convention, never by configurationTraceback (most recent call last):
File "C:\tmp\myproject\run.py", line 1, in <module>
import jukebox.storage
File "C:\tmp\myproject\jukebox\storage.py", line 1, in <module>
from ..settings import DB_PATH # "up one folder on disk", surely?
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
ImportError: attempted relative import beyond top-level packagejukebox/storage.py, __package__ is 'jukebox' — one segment. Two dots is level 2, which strips level - 1 = 1 name, and stripping one name off a one-segment package leaves nothing to append to. The dots ran out of tree. What makes this one so common is that .. looks exactly like the .. you type in a shell, where it does mean the parent directory — and settings.py really is sitting one folder up, in plain sight. But the dots never walk the disk. They walk __package__, which stops at the outermost package, and the repo root is not a package, so no number of dots can ever reach it. The message is not asking you to add a dot. It is telling you the file you want lives outside the package, and that relative imports have no way out.settings.py into jukebox/ and say from .settings import DB_PATH, or leave it outside and let __main__.py read it and pass the value inTraceback (most recent call last):
File "C:\tmp\myproject\run.py", line 1, in <module>
from jukebox import Player
ImportError: cannot import name 'Player' from 'jukebox' (C:\tmp\myproject\jukebox\__init__.py)Player lives; it is the file whose namespace was searched — __init__.py, the package's own body. So the traceback is not accusing engine.py of anything. It is saying your facade never put Player on the counter. And from pkg import X did more work than it looks: it checks the package's attributes first, and only if that misses does it try importing pkg.X as a submodule — which is precisely why from jukebox import engine succeeds even though __init__.py never mentions engine. Both routes came up empty here: no attribute Player, and no jukebox/Player.py. One consequence is worth storing away, because it explains a whole genre of confusion. Once anything in the process imports jukebox.engine, that submodule is bound onto the shared package object for good, so jukebox.engine starts resolving in code that never imported it. That is section 1's binding step seen from a distance — and it is why an attribute that works today can vanish the day somebody deletes an import you have never read.from .engine import Player in __init__.py, or import the real address with from jukebox.engine import Player — and never depend on an attribute you did not import yourselfmyproject/jukebox/ with three files. __init__.py holds only a docstring. tracks.py defines LIBRARY = [("Blinding Lights", 200), ("Titanium", 245), ("Levels", 340)] and as_clock(seconds) returning f"{minutes}:{secs:02d}". report.py does from .tracks import LIBRARY, as_clock, defines summary() returning f"{len(LIBRARY)} tracks, {as_clock(total)}", and ends with a __main__ guard that prints it. From myproject/, run python jukebox/report.py and copy the traceback down. Second, answer three questions before you change one character. Which attribute of the module is wrong, and what is its value in this run? The error says no known parent package — name the parent package it was looking for, and say where the interpreter would have got that string had you imported the file instead. And why does python jukebox/report.py fail identically whether you run it from myproject/ or from anywhere else on the disk? Write the answers out; a fix you cannot explain will be undone by the next person. Third, fix it three ways, then rank them. Fix A: change no code at all and use the launch form that already works. Fix B: copy report.py to report_abs.py, change the import to the absolute from jukebox.tracks import ..., and run it both ways — predict each result before you press Enter, because one of them will surprise you. Fix C: copy it again to report_hack.py, and before the import insert the package's own folder onto sys.path so a bare import tracks resolves; then, in the same file, also import jukebox.tracks and print whether the two are the same object. Say which fix you would ship. One hint and no more: in fix C, append something to tracks.LIBRARY and print the length of both.show the solution
# ---------- myproject/jukebox/__init__.py ----------
"""jukebox -- a package with one sore spot."""
# ---------- myproject/jukebox/tracks.py ----------
"""tracks.py -- the leaf."""
LIBRARY = [("Blinding Lights", 200), ("Titanium", 245), ("Levels", 340)]
def as_clock(seconds):
minutes, secs = divmod(seconds, 60)
return f"{minutes}:{secs:02d}"
# ---------- myproject/jukebox/report.py ----------
from .tracks import LIBRARY, as_clock
def summary():
total = sum(secs for _title, secs in LIBRARY)
return f"{len(LIBRARY)} tracks, {as_clock(total)}"
if __name__ == "__main__":
print(summary())
# ------------- $ python jukebox/report.py -------------
Traceback (most recent call last):
File "C:\tmp\myproject\jukebox\report.py", line 2, in <module>
from .tracks import LIBRARY, as_clock
ImportError: attempted relative import with no known parent package
# The three answers.
#
# 1. __package__. Launched as a script, report.py IS the startup module, so the
# interpreter sets __name__ = "__main__" and __package__ = "". The dot
# resolver is handed an empty base and has nothing to strip a name from.
#
# 2. It wanted "jukebox". Import it instead -- `import jukebox.report` -- and the
# loader stamps __package__ = "jukebox" from the dotted name it resolved. The
# string does not come from the folder on disk; it comes from the import.
#
# 3. Because the script form never consults the working directory. sys.path[0]
# is the folder of the FILE you launched, which is jukebox/ every time. Where
# you happen to be standing is not an input.
# ------------- FIX A: change the launch, not the code -------------
$ python -m jukebox.report
3 tracks, 13:05
$ python -c "from jukebox.report import summary; print(summary())"
3 tracks, 13:05
# ------------- FIX B: absolute import. This is the surprise. -------------
# ---------- jukebox/report_abs.py ----------
from jukebox.tracks import LIBRARY, as_clock
if __name__ == "__main__":
total = sum(secs for _title, secs in LIBRARY)
print(f"{len(LIBRARY)} tracks, {as_clock(total)}")
$ python jukebox/report_abs.py
Traceback (most recent call last):
File "C:\tmp\myproject\jukebox\report_abs.py", line 2, in <module>
from jukebox.tracks import LIBRARY, as_clock
ModuleNotFoundError: No module named 'jukebox'
$ python -m jukebox.report_abs
3 tracks, 13:05
# Absolute imports do NOT rescue the script form. The error simply changes its
# name. sys.path[0] is jukebox/ -- the package's INSIDE -- so the top-level name
# `jukebox` is not on the path at all. Different message, same missing context.
# ------------- FIX C: the sys.path hack, and what it really costs -------------
# ---------- jukebox/report_hack.py ----------
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent)) # jukebox/ itself
import tracks # now a TOP-LEVEL name
import jukebox.tracks # and the real one
print("bare import works :", tracks.as_clock(785))
print("same module? :", tracks is jukebox.tracks)
print("two keys in cache :", [k for k in sys.modules if k.endswith("tracks")])
tracks.LIBRARY.append(("Ghost Track", 1))
print("edit through one :", len(tracks.LIBRARY), "vs", len(jukebox.tracks.LIBRARY))
$ python -m jukebox.report_hack
bare import works : 13:05
same module? : False
two keys in cache : ['tracks', 'jukebox.tracks']
edit through one : 4 vs 3
# There it is. The hack "works" -- and it loaded tracks.py a SECOND time, under a
# second name, into a second module object. Two Track lists, two sets of globals,
# and an append through one that the other never sees. Every class defined in
# there now exists twice, so isinstance() starts returning False for objects that
# obviously match. This is the bug that eats an afternoon.
# The ranking, and why.
# A (python -m) SHIP THIS. No code changed, no name leaked, and the
# package keeps exactly one identity in sys.modules.
# B (absolute import) Fine as a style for entry points, but it is not a fix
# for the script form -- run it with -m regardless.
# C (sys.path.insert) Never. It converts a private submodule into a global
# name and buys you two copies of one module.
#
# The one honest sentence to take away:
# A relative import needs a package context, and only an IMPORT can create one.
# Launching a file cannot -- so launch the package with -m instead.python -m playlist runs __init__.py before it ever looks at __main__.py. So a syntax error or a bad import in __init__.py makes the whole thing fail before your entry point runs a single line. If python -m playlist dies with a traceback pointing at __init__.py, that's why — the package must construct itself before it can be run.python -m json.tool pretty-prints JSON; python -m http.server serves a folder; python -m pip install ... is pip. Each is a package with a __main__.py front door. When you write your own, you're not inventing a pattern — you're joining the exact convention the standard library uses to make a package a runnable program.✗ The myth
"python file.py and python -m pkg are two spellings of the same thing."
✓ The reality
They take different startup paths in CPython. The script form sets sys.path[0] to the file's folder and gives it no package context. The -m form imports the package first, then runs __main__.py with __package__ intact. Only one of them lets relative imports work in your entry point.
You have a package that imports as one name, publishes a clean surface, and runs from a real front door. One structural landmine remains — the one where two perfectly correct imports poison each other. →
05Circular imports and the half-built module
You split a class across two files. model.py needs a type from engine.py, and engine.py needs a type from model.py. Then import playlist dies with a line that sounds like an accusation, the circular import: ImportError: cannot import name 'Player' from partially initialized module 'playlist.engine' (most likely due to a circular import). Nothing is misspelled. Both files are correct in isolation. You could shuffle import lines until it stops crashing, but that's superstition. You want to know precisely why a valid pair of imports poisons each other, and the exact fix.
This builds directly on Section 1, on that one line you were told to remember. Import inserts an empty module object into sys.modules before running the module's code. Watch the timeline of import playlist.model when the two files import each other at the top:
t0 import model -> sys.modules["...model"] = <empty> # seed, before code
t1 model line 1: from .engine import Player
t2 import engine -> sys.modules["...engine"] = <empty> # seed, before code
t3 engine line 1: from .model import Track
t4 model already in sys.modules -> DO NOT re-run itRead t4 slowly, because it's the crux. When engine does from .model import Track, the machinery finds model already in sys.modules, the empty seed from t0. Correctly, it does not re-run it, because re-running would loop forever, so it hands engine the half-built model object exactly as it stands. But at t4, model has only executed its first line and never reached class Track, so the name Track isn't in model's namespace yet. from .model import Track demands that name right now, can't find it, and raises cannot import name 'Track' from partially initialized module. The module isn't broken — it's just unfinished, caught mid-execution by a neighbour who reached across too early.
sys.modules and the second module reads a name that isn't defined yet; deferring the import into a function body lets both bodies finish first.Now the distinction that unlocks everything — the difference between two import forms you probably thought were interchangeable:
from .model import Track # needs the NAME 'Track' to exist RIGHT NOW, at import time
import playlist.model # binds the MODULE object; you touch .Track LATER, at call timefrom .model import Track copies a name out of model's namespace at the instant the import runs, during module execution, when model may still be half-built. import playlist.model only binds the module object, which exists from t0, empty or not. You don't read model.Track until later, at call time, and by then both module bodies have finished and Track is present. So the cycle itself is not fatal. What's fatal is depending on a not-yet-executed name during module execution. Change when you need the name, and the cycle becomes harmless.
That understanding gives four fixes, ranked worst to best:
# 1. move the import to CALL time — runs after both bodies finished
def shuffle(self):
from .model import Track # local import, deferred until called
...
# 2. bind the module, read the attribute lazily
import playlist.model
# ...later... playlist.model.Track
# 3. type-checking only — exists for the checker, never at runtime
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .model import Track # used only in string annotations
# 4. BEST — break the cycle: extract Track into a leaf module both importfrom X import name reaches at import time (fragile); import X then X.name reaches at call time (safe). You don't fix circular imports by shuffling lines — you fix them by deferring the reach or removing the loop.shuffle() is actually invoked, import playlist has long finished, both model and engine are fully built, and Track is sitting in model's namespace waiting. Same line, later moment — and the moment was the whole problem.Every mechanism is now on the table: the package name, the facade, the dot, the front door, the cycle. Time to cash them all in — turn the sprawling playlist into a real, layered, cycle-proof program. →
06Restructuring the playlist into a package
Everything so far has been mechanism. Now you spend it. The playlist has swollen into one bloated file mixing data classes, playback logic, and file-saving. You can't test the storage in isolation, can't hand a teammate "just the engine," and can't change the save format without risking the shuffle. You want to convert the pile into a program: a package with clear modules, one public surface, one entry point, and, by construction, no import cycles. This is the exact shape the capstone in Ch 23 will ship.
The single design decision that makes all of it fall into place is dependency direction: decide which module depends on which, and make every arrow point the same way, toward a foundation. Here's the layout:
playlist/
__init__.py # facade: re-exports the public names + __all__
model.py # LEAF — Track, Playlist. imports nothing internal
storage.py # imports model. save/load to JSON
engine.py # imports model. Player, shuffle, next/prev
__main__.py # entry point: wires storage + engine. python -m playlistLook at the arrows. model.py is a leaf: pure data, the Track and Playlist dataclasses, importing nothing else in the package. storage.py imports model, one direction, and engine.py imports model, one direction too. Neither storage nor engine imports the other. They meet only at the top, in __main__.py, which wires them together. Because every arrow points down toward the model leaf, there is no cycle possible — it's not "we avoided one," but "the shape forbids one." This is the deep lesson of Section 5 turned into architecture: you don't dodge circular imports with clever import placement, you make them structurally impossible by layering.
__init__.py and is never imported. It is the folder you cd into and the folder git tracks.jukebox/. A second top-level utils.py at the root is the junk drawer growing back.tests/ outsideTests then import jukebox exactly as a stranger would. Put them inside the package and they ship with it, and stop testing the public surface.tests/__init__.pyNot decoration. unittest discover refuses a start directory it cannot import, and this one empty file is the whole fix.formats/ is a package like any other — a directory with an __init__.py — reached as jukebox.formats.json_io.pyproject.tomlWhere the name, the version and the dependencies actually live. It is not read by import; Chapter 22 is where it starts to matter.sys.path[0] under -m is the current directory, so the repo root is the only place both commands work.you type
# ---------- tree.py (at the repo root) ----------
import os
SKIP = {"__pycache__", ".git", ".venv"}
for root, dirs, files in os.walk("."):
dirs[:] = sorted(d for d in dirs if d not in SKIP)
depth = root.count(os.sep)
mark = " <- package" if "__init__.py" in files else ""
print(" " * depth + os.path.basename(root or ".") + "/" + mark)
for name in sorted(files):
if not name.endswith(".pyc"):
print(" " * (depth + 1) + name)
# ---------- the terminal ----------
$ python tree.py
$ cd jukebox && python -m jukebox
$ cd .. && python -m jukebox
$ python -m unittest discover -s tests -t .you see
$ python tree.py
./
README.md
pyproject.toml
tree.py
jukebox/ <- package
__init__.py
__main__.py
storage.py
tracks.py
formats/ <- package
__init__.py
json_io.py
tests/ <- package
__init__.py
test_tracks.py
$ cd jukebox && python -m jukebox
C:\...\python.exe: No module named jukebox
$ cd .. && python -m jukebox
Blinding Lights - The Weeknd (3:20)
Titanium - David Guetta (4:05)
jukebox 1.0.0
$ python -m unittest discover -s tests -t .
..
----------------------------------------------------------------------
Ran 2 tests in 0.000s
OK- Run
python -m jukeboxfrom insidejukebox/and it reports no such module. The parent goes on the path, not the package. - A
tests/with no__init__.pymakes discovery raiseStart directory is not importable. One empty file ends it. - Do not let
jukebox-project/andjukebox-project/jukebox/blur together. Two folders, two jobs, and only one of them is importable. __pycache__appears inside every package you import. Add the one.gitignoreline and stop noticing it.- The
src/jukebox/layout is this same picture with one more folder. It stops the repo root shadowing your installed package during tests, and nothing else. tree.pylives at the root, outside the package, because it is a chore script and not part of what ships.
# model.py — the leaf, imports nothing internal
from dataclasses import dataclass
# storage.py — one arrow down to model
from .model import Track, Playlist
# engine.py — one arrow down to model
from .model import Track, Playlist
# __init__.py — the facade on top
from .model import Track, Playlist
from .engine import Player
from .storage import save, load
__all__ = ["Track", "Playlist", "Player", "save", "load"]Every prior mechanism is doing a job here, in situ. The relative imports (from .model import ...) mean the package is renamable and works under -m, from Section 3. Those imports resolve because __package__ is set correctly when the package is imported, and __main__.py keeps that context under python -m playlist, from Section 4. __init__.py re-exports the public trio behind a stable facade and declares __all__, from Section 2. And the acyclic layering is the structural cure for the poisoning of Section 5. None of the modules is meant to be run directly. The only front door is __main__.py.
python -m jukebox# ---------- the layout, after ----------
myproject/
jukebox/
__init__.py # the facade
tracks.py # ch 19's tracks.py, unedited
storage.py # ch 19's storage.py, ONE import line changed
__main__.py # ch 19's jukebox.py, now the front door
# ---------- jukebox/tracks.py ----------
"""tracks.py -- what a track and a playlist ARE. No I/O, no menu."""
from dataclasses import dataclass
@dataclass
class Track: # ch 12's dataclass, unmoved
title: str
artist: str
seconds: int
plays: int = 0
def __str__(self):
minutes, secs = divmod(self.seconds, 60)
return f"{self.title} - {self.artist} ({minutes}:{secs:02d})"
class Playlist:
def __init__(self, name, tracks):
self.name, self.tracks = name, list(tracks)
def __len__(self):
return len(self.tracks)
def __iter__(self):
return iter(self.tracks)
def __repr__(self):
minutes, secs = divmod(sum(t.seconds for t in self), 60)
return f"Playlist({self.name!r}, {len(self)} tracks, {minutes}:{secs:02d})"
def play(self, number):
track = self.tracks[number - 1]
track.plays += 1
return track
# ---------- jukebox/storage.py ----------
"""storage.py -- the only file that knows what JSON is. Chapter 18, boxed."""
import json
from pathlib import Path
from .tracks import Track, Playlist # ch 19 said: from tracks import ...
DB = Path("library.json")
def save(playlist, path=DB):
payload = {"version": 1, "name": playlist.name,
"tracks": [vars(t) for t in playlist]}
with open(path, "w", encoding="utf-8", newline="\n") as f:
json.dump(payload, f, indent=2)
return path.stat().st_size
def load(path=DB):
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return Playlist(data["name"], [Track(**row) for row in data["tracks"]])
# ---------- jukebox/__init__.py ----------
"""jukebox -- the package. Chapter 19's three flat files, folded under one name."""
from .tracks import Track, Playlist
from .storage import save, load
__all__ = ["Track", "Playlist", "save", "load"]
__version__ = "2.0.0"
# ---------- jukebox/__main__.py ----------
"""__main__.py -- the front door. Chapter 19's jukebox.py, promoted."""
from . import __version__ # from the facade above it
from .storage import DB, load, save
from .tracks import Track, Playlist
def seed():
return Playlist("Friday Night", [
Track("Blinding Lights", "The Weeknd", 200),
Track("Titanium", "David Guetta", 245),
Track("Levels", "Avicii", 340),
])
def report(playlist):
print(repr(playlist))
for i, track in enumerate(playlist, start=1):
print(f" {i}. {track} played {track.plays}x")
def main():
print(f"jukebox {__version__} | __name__ = {__name__} | __package__ = {__package__}")
night = load() if DB.exists() else seed()
print("loaded from disk:", DB.exists())
report(night)
print()
for number in (2, 1, 2):
print(" now playing", night.play(number))
size = save(night)
print()
print(f"saved {size} bytes to {DB.name}")
if __name__ == "__main__":
main()
$ python -m jukebox
jukebox 2.0.0 | __name__ = __main__ | __package__ = jukebox
loaded from disk: False
Playlist('Friday Night', 3 tracks, 13:05)
1. Blinding Lights - The Weeknd (3:20) played 0x
2. Titanium - David Guetta (4:05) played 0x
3. Levels - Avicii (5:40) played 0x
now playing Titanium - David Guetta (4:05)
now playing Blinding Lights - The Weeknd (3:20)
now playing Titanium - David Guetta (4:05)
saved 392 bytes to library.json
$ python -m jukebox
jukebox 2.0.0 | __name__ = __main__ | __package__ = jukebox
loaded from disk: True
Playlist('Friday Night', 3 tracks, 13:05)
1. Blinding Lights - The Weeknd (3:20) played 1x
2. Titanium - David Guetta (4:05) played 2x
3. Levels - Avicii (5:40) played 0x
now playing Titanium - David Guetta (4:05)
now playing Blinding Lights - The Weeknd (3:20)
now playing Titanium - David Guetta (4:05)
saved 392 bytes to library.json
$ python -c "from jukebox import Track, load; print(load().name, '|', Track('X', 'Y', 61))"
Friday Night | X - Y (1:01)
myproject/, make jukebox/ inside it, and save the four files. Then, from myproject/, type python -m jukebox. This is Chapter 19's capstone — tracks.py, storage.py, jukebox.py, three flat files sitting in a folder — grown into a real package, and it is worth being precise about how little actually changed. tracks.py is byte for byte the same file. storage.py changed exactly one line: from tracks import became from .tracks import. jukebox.py was renamed to __main__.py and its two imports grew a dot. Two files are genuinely new, and both are small: an __init__.py that re-exports four names, and nothing else. That is the whole conversion. Now read the first line of output, because it is the thing a flat script cannot print. __name__ is __main__, so main() ran; and __package__ is jukebox, which is why from .storage import DB, load, save resolved at all. Both facts at once — that is runpy doing exactly what section 4 described. Look at the import lines as a shape, too. tracks.py imports nothing internal, storage.py imports only tracks, and __main__.py imports both. Every arrow points down at the leaf, so section 5's circular import is not something you avoided here — it is something the shape forbids. Then run it a second time. loaded from disk flips to True and the play counts come back 1, 2, 0: the first run's plays survived the process that made them, and the JSON on disk is the same JSON Chapter 18 taught you to write. Two things to try. From myproject/, run python jukebox/__main__.py and watch it die on the very first relative import — the front door only opens with -m. Then add a shuffle() method to Playlist and notice that no other file needs an edit, which is the entire point of the leaf.engine/storage in the middle, a dependency-free model at the base — makes circular imports structurally impossible and leaves clean seams to swap later.jukebox/formats/ — a package inside a package, reaching up to its parent with two dotsjukebox/ holding __init__.py, tracks.py with a Track dataclass (title, artist, seconds, and a __str__ that prints a clock), and a storage.py that owns save and load. Now create jukebox/formats/ with an __init__.py in it, and inside that json_io.py exposing write_tracks(tracks, path) and read_tracks(path). json_io.py needs the Track class, which lives one level up — reach for it with a relative import, and count the dots yourself before you type them. Second, wire it in and move the JSON. Give formats/__init__.py a re-export so the rest of the package can say from .formats import read_tracks, write_tracks without ever naming json_io. Then strip storage.py down: it should import json nowhere, and simply delegate the file shape while keeping the policy — the default path DB. When you are done, exactly one file in the whole project imports json; find it and say so out loud. Third, prove the names. Write __main__.py that saves two tracks, loads them back, prints them, and then prints json_io.__name__ and json_io.__package__. Predict both strings before you run it. Run python -m jukebox from the project root. Finally, answer one question in a sentence: if you renamed the top folder from jukebox to setlist tomorrow, how many import lines inside the package would you have to edit? One hint and no more: from formats/json_io.py, __package__ is "jukebox.formats", and the resolver strips level - 1 names off it.show the solution
# ---------- the tree, after ----------
$ python tree.py
./
tree.py
jukebox/ <- package
__init__.py
__main__.py
storage.py
tracks.py
formats/ <- package
__init__.py
json_io.py
# ---------- jukebox/tracks.py ----------
"""tracks.py -- the leaf."""
from dataclasses import dataclass
@dataclass
class Track:
title: str
artist: str
seconds: int
def __str__(self):
minutes, secs = divmod(self.seconds, 60)
return f"{self.title} - {self.artist} ({minutes}:{secs:02d})"
# ---------- jukebox/formats/json_io.py ----------
"""json_io.py -- the only file in the project that imports json."""
import json
from ..tracks import Track # two dots: up to jukebox, then down
def write_tracks(tracks, path):
rows = [vars(t) for t in tracks]
with open(path, "w", encoding="utf-8", newline="\n") as f:
json.dump(rows, f, indent=2)
return path
def read_tracks(path):
with open(path, "r", encoding="utf-8") as f:
return [Track(**row) for row in json.load(f)]
# ---------- jukebox/formats/__init__.py ----------
"""formats -- one module per file shape. A package inside a package."""
from .json_io import read_tracks, write_tracks
__all__ = ["read_tracks", "write_tracks"]
# ---------- jukebox/storage.py ----------
"""storage.py -- delegates the file shape to formats/, keeps the policy."""
from .formats import read_tracks, write_tracks
DB = "library.json"
def save(tracks, path=DB):
return write_tracks(tracks, path)
def load(path=DB):
return read_tracks(path)
# ---------- jukebox/__init__.py ----------
"""jukebox -- now with a formats subpackage."""
from .tracks import Track
from .storage import load, save
__all__ = ["Track", "load", "save"]
# ---------- jukebox/__main__.py ----------
"""__main__.py -- the front door."""
from .storage import load, save
from .tracks import Track
save([Track("Blinding Lights", "The Weeknd", 200),
Track("Titanium", "David Guetta", 245)])
for track in load():
print(" ", track)
from .formats import json_io
print("json_io.__package__ =", json_io.__package__)
print("json_io.__name__ =", json_io.__name__)
# ------------- $ python -m jukebox -------------
Blinding Lights - The Weeknd (3:20)
Titanium - David Guetta (4:05)
json_io.__package__ = jukebox.formats
json_io.__name__ = jukebox.formats.json_io
# ------------- the dot arithmetic, checked -------------
# In jukebox/formats/json_io.py, __package__ is "jukebox.formats".
# from ..tracks import Track -> level = 2
# strip level-1 = 1 name -> "jukebox"
# append "tracks" -> "jukebox.tracks"
# One dot would have resolved to "jukebox.formats.tracks", which does not exist.
$ python -c "import jukebox.formats.json_io as j; print(j.__name__, j.__package__)"
jukebox.formats.json_io | jukebox.formats
# The rename question:
# ZERO import lines inside the package. Every one of them is relative, so not
# one of them spells `jukebox`. Rename the folder to setlist, run
# `python -m setlist`, and it works untouched. Only code OUTSIDE the package --
# which said `from jukebox import ...` -- has to change.
#
# And exactly one file imports json: jukebox/formats/json_io.py. Adding an
# m3u.py beside it later needs no edit to storage.py, because storage.py asks
# formats/ for a shape and never for a format.The payoff is the seams, and this is why the layering is worth the ceremony. Because storage.py isolates all the I/O, you can swap JSON for SQLite later without touching a line of engine.py. The engine never knew where tracks came from, only that load() returns them. Because engine.py holds the algorithm, you can swap the shuffle strategy without touching storage. And model.py is the stable contract both sides agree on: the Track shape that storage produces and engine consumes. Layering doesn't just prevent cycles. It hands you a program you can change one piece at a time. That's exactly the surface Ch 23's capstone builds on.
add "walk the dog" appends a new one. It must run as one command, and the streak rule must be testable without touching the disk. Before writing any code, sketch the repository: which folder is the package, which files live in it, which one is the leaf, and where the tests go. Draw an arrow from each module to the modules it imports, and check that they all point the same way. Second, write it. Four modules and an entry point is enough. Keep the streak rule in a file that imports no I/O of any kind, and keep every open() and every json call in one file. Pin "today" to a fixed date in the entry point rather than calling date.today(), so your output is reproducible and so is mine. Add tests/ beside the package with three cases: an unbroken chain, a chain that was broken and reset, and a habit never done. Third, run both commands and defend two decisions. python -m habits, then python -m unittest discover -s tests -t ., both from the repo root. Then write two sentences. Why does the streak function take today as an argument instead of reading the clock? And why is tests/ outside the package rather than inside it? One hint and no more: unittest discover refuses a start directory it cannot import.show the solution
# ---------- the tree ----------
$ python tree.py
./
README.md
pyproject.toml
tree.py
habits/ <- package
__init__.py
__main__.py
model.py
storage.py
streaks.py
tests/ <- package
__init__.py
test_streaks.py
# The arrows, all pointing down at the leaf:
#
# __main__.py (the front door: wires, owns no logic)
# / | \
# storage streaks model
# \ | /
# model.py (the LEAF: imports nothing internal)
#
# __init__.py sits above all of it as the facade. No module imports __main__,
# and no module imports __init__ except __main__ (for the version string), so
# there is no cycle available to write.
# ---------- habits/model.py ----------
"""model.py -- LEAF. What a habit IS. Imports nothing internal."""
from dataclasses import dataclass, field
@dataclass
class Habit:
name: str
done: list = field(default_factory=list) # ISO dates, most recent last
# ---------- habits/streaks.py ----------
"""streaks.py -- pure logic. One arrow down to model, no I/O at all."""
from datetime import date, timedelta
from .model import Habit
def streak(habit, today):
"""Consecutive days ending today (or yesterday) -- 0 if the chain is broken."""
days = {date.fromisoformat(d) for d in habit.done}
cursor = today if today in days else today - timedelta(days=1)
count = 0
while cursor in days:
count += 1
cursor -= timedelta(days=1)
return count
# ---------- habits/storage.py ----------
"""storage.py -- the only file that knows what JSON is. One arrow down to model."""
import json
from pathlib import Path
from .model import Habit
DB = Path("habits.json")
def load(path=DB):
if not path.exists():
return []
with open(path, "r", encoding="utf-8") as f:
return [Habit(**row) for row in json.load(f)]
def save(habits, path=DB):
with open(path, "w", encoding="utf-8", newline="\n") as f:
json.dump([vars(h) for h in habits], f, indent=2)
return path
# ---------- habits/__init__.py ----------
"""habits -- track a streak. Run it with `python -m habits`."""
from .model import Habit
from .storage import load, save
from .streaks import streak
__all__ = ["Habit", "load", "save", "streak"]
__version__ = "1.0.0"
# ---------- habits/__main__.py ----------
"""__main__.py -- the front door. Wires storage + streaks; owns no logic."""
import sys
from datetime import date, timedelta
from .model import Habit
from .storage import load, save
from .streaks import streak
TODAY = date(2026, 8, 16) # fixed so the output is reproducible
def seed():
days = [(TODAY - timedelta(days=n)).isoformat() for n in (3, 2, 1, 0)]
return [Habit("read 20 pages", days), Habit("run 5k", days[-2:])]
def main(argv):
habits = load() or seed()
if argv[:1] == ["add"]:
habits.append(Habit(argv[1], [TODAY.isoformat()]))
for habit in habits:
print(f" {habit.name:<16} {streak(habit, TODAY)} day streak")
save(habits)
if __name__ == "__main__":
main(sys.argv[1:])
# ---------- tests/__init__.py ----------
"""tests -- a package, so `unittest discover` can import it."""
# ---------- tests/test_streaks.py ----------
"""The logic is testable because streaks.py never touches the disk."""
import unittest
from datetime import date
from habits import Habit, streak
class StreakTests(unittest.TestCase):
TODAY = date(2026, 8, 16)
def test_unbroken_chain(self):
h = Habit("read", ["2026-08-14", "2026-08-15", "2026-08-16"])
self.assertEqual(streak(h, self.TODAY), 3)
def test_broken_chain_resets(self):
h = Habit("read", ["2026-08-10", "2026-08-16"])
self.assertEqual(streak(h, self.TODAY), 1)
def test_never_done(self):
self.assertEqual(streak(Habit("run"), self.TODAY), 0)
# ------------- both commands, from the repo root -------------
$ python -m habits
read 20 pages 4 day streak
run 5k 2 day streak
$ python -m habits add "walk the dog"
read 20 pages 4 day streak
run 5k 2 day streak
walk the dog 1 day streak
$ python -m unittest discover -s tests -t .
...
----------------------------------------------------------------------
Ran 3 tests in 0.000s
OK
# The two defences.
#
# 1. streak() takes `today` because a function that reads the clock cannot be
# tested -- its answer changes at midnight and you cannot ask it about a
# Tuesday in 2026. Passing the date makes the rule a pure function of its
# inputs, which is why all three tests are three lines each. The clock is
# read in exactly one place, __main__.py, where the program meets the world.
#
# 2. tests/ is outside habits/ because it must import the package the way a
# stranger does -- `from habits import Habit, streak`, through the facade.
# Put it inside and it ships to users, it can reach private names by
# accident, and it stops testing the surface you actually promised.
#
# The layout rule, in one line:
# Every arrow points at model.py, and only __main__.py is allowed to know
# about more than one layer at a time.__main__.py front door are the four things that convert a folder of files into a program. Get the arrows pointing one way and the hardest bug in this chapter — the circular import — can never occur.✗ The myth
"A package is just a folder with the files thrown in. Structure is tidiness — nice to have, not load-bearing."
✓ The reality
The direction of the imports is load-bearing. Point every arrow at a leaf and cycles are impossible, testing-in-isolation is free, and each module has a stable interface you can swap behind. The same files with the arrows tangled give you a fragile knot that crashes on import. Structure is the program.
engine holds the actual Track object, not a copy. From Ch 22 on, the boundaries your program crosses are the disk, the network, and other processes, and none of them can carry a live object across. Only bytes survive the crossing. This clean, in-memory layering is the ground you'll stand on while you learn to flatten objects down to bytes and rebuild them on the far side.Your program has a shape. Next volume-thread: the moment it needs to speak to anything outside itself — a file, a socket, another process — the objects stop crossing and the bytes begin. That's Ch 22. →
- A package is a directory the import system treats as one name, and the single thing that makes it one is
__path__— an ordinary list holding that folder, which is the map every sub-import consults — so importing the package runs__init__.pyand stops, andjukebox.tracksis anAttributeErroruntil something actually imports it and the machinery binds the child onto its parent. __init__.pyis not a marker file, it is the package's module body: real code executed once, top to bottom, with the package's own namespace as its globals — which means empty is a complete choice, the names it binds are the public surface, and every re-export you add runs a whole submodule at package-import time, so a generous facade and a fastimportpull against each other.__all__and the leading underscore each govern exactly one thing, which is whatfrom pkg import *copies out — neither hides anything, attribute access reaches every name you ask for, and Python's "private" is a fence rather than a wall, a promise between people that the interpreter takes no part in enforcing.- The dots are arithmetic on
__package__, and only an import can create that string — one dot is level 1 and strips nothing, each extra dot strips one more name, and the string itself is stamped on by the loader from the dotted name it resolved — which is whypython -m pkgcan hold both truths at once,__name__ == "__main__"and__package__ == "pkg", and whypython pkg/file.pycan hold neither. - A cycle is not fatal by itself; what is fatal is reaching for a name while the other module is still executing — the half-built object was already in
sys.modules, sofrom X import namedemands something not yet defined whileimport XthenX.namewaits until call time — and pointing every arrow down at a leaf makes the whole question structurally impossible to ask.
jukebox.tracks reads as one more name in one more namespace rather than a path on a disk; chapter 7 gave you the hash table that a module's __dict__ and sys.modules both literally are, which is why a package's public surface is a lookup and not a manifest; chapter 12 gave you the dataclass that model.py holds as its leaf — the stable contract storage produces and engine consumes; chapter 13 gave you the honest reading of a traceback, which is the only reason partially initialized module lands as information rather than alarm; chapter 18 gave you the JSON that storage.py boxes up so nothing else in the package has to know a file format exists; chapter 19 gave you the four verbs — find, compile, exec, cache — and the three flat files this chapter folds under one name; and chapter 20 gave you the one line everything here turns on, sys.modules[name] = mod executed before a single statement of the body runs, which is at once why __init__.py executes exactly once and why a circular import hands you a half-built module instead of looping forever. Chapter 21 added no new machinery at all — not one new verb, not one new call. What it added is a direction. The same import statements, arranged so that every arrow points at a leaf and only the front door knows about more than one layer, stop being a folder of files and become a program you can change one piece at a time.A package is just a folder Python agrees to treat as a namespace. Each program here builds a tiny package on disk at runtime — it writes the .py files, puts the folder on sys.path, then imports it — so every effect is real: the files exist, the imports actually run, and the errors are Python's own. Watch __init__.py fire exactly once, the public surface assemble through re-exports, relative imports resolve against __package__, and the circular-import crash appear and then get fixed.