Skip to content
System nominalUTC --:--:--Articles 041
MODULE 03 Lesson 09 / 18 35 MIN

Functions and structure

Modules, Packages, and Laying Out a Project

How the import system finds a file, why a module inside a package cannot be run as a script, and a src layout that a new teammate can install and test.

The single file works. A month later it is nine hundred lines, the functions at the top cannot be tested without running everything below them, and the sensible move is to split it. That is where the errors start: a ModuleNotFoundError, an ImportError naming a module you did not import, and a program that runs from one directory and not from another.

None of those are random. The import system is a short set of rules: a module is a file, importing executes it once and caches the result, and the interpreter searches a list of directories in order. Every import error is one of those rules reporting a disagreement between what you wrote and what is on disk.

A module is a file, and importing it executes it once

The module name is the filename without the .py suffix. import tasklog.store makes the interpreter look for tasklog/store.py, execute its top-level statements, and keep the resulting module object in sys.modules under the key "tasklog.store". A second import of the same name finds the cached object and returns it without executing anything again.

You can watch the execution happen. Put a print at the top of src/tasklog/__init__.py:

print("tasklog/__init__.py ran")
python3 -c "import tasklog.report"
tasklog/__init__.py ran

One line of output for an import of a different file, because importing tasklog.report imports the tasklog package first. Remove the print and run it again, and the import is silent.

The practical rule is that top-level code is code that runs whenever anything touches the module. Definitions, constants and imports belong there. File reads, network calls and prints belong in functions that the caller chooses to run.

__name__ decides whether a module is a program or a library

The interpreter sets one variable in every module it loads. For the module it was told to run, __name__ is the string "__main__". For a module reached through an import, it is the module's own name. The CLI in this project ends with the guard:

import sys

from .report import count_by_owner
from .store import load


def main(argv):
    tasks = load(argv[0])
    for owner, count in count_by_owner(tasks).items():
        print(f"{owner}: {count}")


if __name__ == "__main__":
    main(sys.argv[1:])
python3 -m tasklog.cli tasks.txt
ana: 2
bo: 1
python3 -c "import tasklog.cli; print(tasklog.cli.__name__)"
tasklog.cli

The first command runs the module, so the guard is true, so main runs. The second imports it, so __name__ is "tasklog.cli", so the guard is false and nothing is printed. The same file is a program and a library depending on how it was reached.

In a package, the guard answers a second question as well: which module owns main. Put main in cli.py with the guard, add a two-line __main__.py that calls it, and python3 -m tasklog works as well as python3 -m tasklog.cli. Without a __main__.py the package itself is an import target with nothing to run.

sys.path is the search list behind almost every import failure

The interpreter does not search the whole disk. It searches a list of directories in order, called sys.path, and the first match wins. Printing it is the fastest diagnostic you have.

python3 /home/ana/tasklog/show_path.py
/home/ana/tasklog

Where show_path.py prints sys.path[0]. For a script, the first entry is the directory containing that script, not the directory you are standing in. The rest of the list follows a fixed order:

PYTHONPATH=/home/ana/tasklog/src python3 -c "import sys; print(sys.path[:3])"
['', '/home/ana/tasklog/src', '/usr/lib/python3.14/python314.zip', ...]

Entry zero is the current directory when you run with -c or -m (as an empty string or an absolute path respectively), and the script's directory when you name a file. Then come the PYTHONPATH entries in the order given, then the standard library, then site-packages where installed packages live.

Two consequences are worth keeping. Installed packages lose to anything in your working directory, so a file called json.py next to your script shadows the standard library module. And a path that is not on the list cannot be found, however correct the import statement is. Printing the list answers most import questions in one line, and when your package's directory is missing from it, the fix belongs in the environment or the layout rather than in the import statement.

A module inside a package cannot be run as a script

This is the error that follows a successful split. The file works when imported and fails when named on the command line:

python3 src/tasklog/cli.py tasks.txt
Traceback (most recent call last):
  File "/home/ana/tasklog/src/tasklog/cli.py", line 3, in <module>
    from .report import count_by_owner
ImportError: attempted relative import with no known parent package

Both halves of the import system explain it. The script's directory, src/tasklog, became sys.path[0], so the search root is the package directory rather than the directory containing tasklog. And the module was run, so its __name__ is "__main__", which has no parent package for the leading dot to resolve against.

Run the same code as a module instead, from the project root, with the package importable:

PYTHONPATH=src python3 -m tasklog.cli tasks.txt
ana: 2
bo: 1

Nothing about the code changed. The module now has a package, so .report resolves, and the package is found through PYTHONPATH rather than through the working directory. Install the package and the same command works with no prefix at all. The habit worth forming: entry points are run with -m, and files inside a package are not scripts.

__init__.py no longer decides what can be imported

Since Python 3.3, a directory with no __init__.py is still importable. It becomes a namespace package, and the interpreter reports that clearly:

python3 -c "import plain; print(plain.__file__); print(list(plain.__path__))"
None
['/home/ana/tasklog/plain']

__file__ is None because there is no file to execute, while __path__ still lists the directory, so plain.mod imports normally. __init__.py is therefore not what makes a directory importable. What it does now is run once when the package is first imported, which makes it the place for package-level setup and a deliberate import surface:

"""Task log: count who owns what in a plain-text task list."""

from .store import load

__all__ = ["load"]

That gives callers from tasklog import load instead of from tasklog.store import load, which pays off when the internal layout changes. __all__ is what from tasklog import * reads; it does not restrict named imports, so it documents intent more than it enforces it.

One caution. The file runs for import tasklog.report as well as for import tasklog, so work placed there is paid on every import, and importing submodules in it is a common way to build the circular import below.

Absolute imports at the edges, relative imports inside

Absolute imports name the package, so they keep working when a file moves to a different depth, and they work from tests and scripts that sit outside the package. Relative imports say "the sibling module in whatever package I am part of", so the package can be renamed without editing them. Inside the package I use relative imports and keep them to one dot: a from ..other import thing that climbs a level usually means a layer is importing from the layer above it. In tests/ and in anything that might be run as an entry point I use absolute imports, because those files have no package context.

Circular imports are a dependency pointing both ways

Two modules that import each other produce a failure that looks unrelated to imports:

Traceback (most recent call last):
  File "/home/ana/circ/a.py", line 1, in <module>
    from b import render
  File "/home/ana/circ/b.py", line 1, in <module>
    from a import format_line
ImportError: cannot import name 'format_line' from 'a' (consider renaming '/home/ana/circ/a.py' if it has the same name as a library you intended to import)

Here is the sequence. a starts executing and imports b on line 1. b imports a, which is already in sys.modules but only half built, because its line 1 never finished, so there is no format_line on it yet. The parenthetical hint varies between releases, in this case suggesting a name clash that is not happening. Read the frames: the two files import each other, and the name was missing because its definition had not run.

Two fixes are standard. Move what both modules need into a third module that imports neither of them:

python3 -c "import a, b; print(a.show('hello')); print(b.show('hello'))"
* hello
[* hello]

Where formatting.py holds format_line and render, and both modules import from it. This removes the cycle instead of hiding it. The second fix moves the import inside the function that needs it, so the module finishes loading before the name is requested:

def show(text):
    from a_fixed import format_line

    return render(format_line(text))

That works and it is legitimate when the dependency genuinely runs one way at call time. It also makes the coupling invisible at the top of the file, so it is the fix to use when the alternative is a large refactor, not the fix to reach for first.

A layout that survives: src, tests and pyproject.toml

tasklog/
├── pyproject.toml
├── src/
│   └── tasklog/
│       ├── __init__.py
│       ├── __main__.py
│       ├── cli.py
│       ├── report.py
│       └── store.py
└── tests/
    └── test_report.py

The importable package lives under src/ so that nothing in the project root is importable by accident. Tests then have exactly two ways to find it: install the package, or tell the test runner where it is. Both force the import to happen the way it happens on a machine that installed your package, which is how a module left out of the build gets caught before a user finds it.

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[project]
name = "tasklog"
version = "0.1.0"
requires-python = ">=3.12"

[tool.setuptools.packages.find]
where = ["src"]

[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]

One file carries the package metadata, the build backend and the tool settings. where = ["src"] tells the build which directory holds packages, so tests/ is never installed. pythonpath is read by pytest, which adds its paths to the front of sys.path; that is the alternative to pip install -e . while a project is small, and if your pytest does not read the key, use the editable install or PYTHONPATH instead.

python3 -m pytest -q
..                                                                       [100%]
2 passed in 0.00s

That run is CPython 3.14.7 with pytest 9.1.1 in a virtual environment, on the two tests in tests/test_report.py, which import tasklog.report and assert on count_by_owner. Module 5 covers what those tests should be. The layout is what makes them possible: the test imports the package by its real name, the way an installed copy would be imported.

What this layout does not do is pin dependency versions or decide how the service starts. Module 6 handles both. What it gives you now is a project where a new checkout needs one command to install and one to test, and where the import rules are the same in both.

Lab 35 minutes

This file works. Run it once to confirm the output, then split it.

import sys


def load(path):
    with open(path, encoding="utf-8") as handle:
        return [line.strip() for line in handle if line.strip()]


def count_by_owner(tasks):
    counts = {}
    for task in tasks:
        owner = task.rsplit("@", 1)[-1]
        counts[owner] = counts.get(owner, 0) + 1
    return counts


tasks = load(sys.argv[1])
for owner, count in count_by_owner(tasks).items():
    print(f"{owner}: {count}")

Build the src/tasklog layout above so that all three of these work: python3 -m tasklog tasks.txt, python3 -m tasklog.cli tasks.txt, and python3 -m pytest -q from the project root.

Then answer two questions. Why does python3 src/tasklog/__main__.py tasks.txt fail, and what exactly does python3 -m tasklog use that the direct file run does not? Which file would you change if the counting rule changed, and which if the file format changed?

Show the solution and the reasoning

The split follows the two reasons the code can change. store.py owns reading a file into a list of task strings, and it has no opinion about what a task means. report.py owns the counting rule and knows nothing about files, which is why it can be tested with a list of strings and no fixture. cli.py owns argument handling and printing, and __main__.py exists so that python3 -m tasklog has something to run:

# src/tasklog/__main__.py
import sys

from .cli import main

main(sys.argv[1:])
# src/tasklog/cli.py
import sys

from .report import count_by_owner
from .store import load


def main(argv):
    tasks = load(argv[0])
    for owner, count in count_by_owner(tasks).items():
        print(f"{owner}: {count}")


if __name__ == "__main__":
    main(sys.argv[1:])
# src/tasklog/__init__.py
"""Task log: count who owns what in a plain-text task list."""

from .store import load

__all__ = ["load"]

The three commands and their output:

python3 -m tasklog tasks.txt
python3 -m tasklog.cli tasks.txt
ana: 2
bo: 1
ana: 2
bo: 1

Both paths end in main; they differ only in which file the interpreter runs as __main__. python3 -m tasklog finds __main__.py inside the package, and python3 -m tasklog.cli runs cli.py directly, where the guard lets a test import it without running the CLI.

The direct file run fails for the same two reasons as the earlier example: the script's own directory becomes the search root, so tasklog is not importable, and the module's __name__ is "__main__", so from .cli import main has no parent package.

python3 -m tasklog finds tasklog as an importable package through PYTHONPATH=src or an installed copy, and runs the package's __main__.py with the package context intact, so relative imports resolve. Whether the package is importable matters more than the working directory, which is why the command works from anywhere.

For the last question: a counting change goes in report.py, and a file-format change in store.py. Neither should require touching cli.py, and if one does, the split is in the wrong place. That is what makes the test possible: the test for count_by_owner passes a list of strings and never opens a file.

Progress

Mark this lesson done and it stays marked in this browser.