Package Structure — Step-by-Step Walkthrough¶
Before You Start¶
Read the project README first. Try to solve it on your own before following this guide. Spend at least 20 minutes attempting it independently. The goal is to understand the src layout for Python packages, the role of pyproject.toml, and how __init__.py controls imports. If you can run python -m mymath.calculator and see the output, you are on the right track.
Thinking Process¶
Every Python project you have written so far has been a script — a single file (or a few files) that you run directly. A package is different: it is a library that other people install with pip install and import in their own code. The transition from "script that runs" to "package that others can install" requires a specific file structure and configuration.
The src layout puts your package code inside a src/ directory. This prevents a common bug where tests accidentally import the local source code instead of the installed package. When your code is in src/mymath/, you cannot accidentally import mymath from the project root — you must install the package first. This catches import errors early.
pyproject.toml is the single source of truth for your package. It tells build tools the package name, version, dependencies, where to find the code, and how to build it. Before pyproject.toml existed, Python packaging required setup.py, setup.cfg, and sometimes MANIFEST.in. Now everything goes in one file.
Step 1: Understand the src Layout¶
What to do: Examine the directory structure of this project.
Why: The file structure is the foundation of a Python package. Every file and directory has a specific purpose. Getting this wrong means your package cannot be installed or imported.
01-package-structure/
├── pyproject.toml # Package metadata and build config
├── README.md # Rendered on PyPI
├── LICENSE # MIT license
├── src/
│ └── mymath/
│ ├── __init__.py # Makes mymath a package, exports version
│ ├── calculator.py # Core math functions
│ └── statistics.py # Mean, median, mode functions
└── tests/
├── test_calculator.py
└── test_statistics.py
Three directories to understand:
src/mymath/— the actual package code. The directory namemymathbecomes the import name.tests/— test files live outside the package so they are not included in the distribution.- Root directory — contains
pyproject.toml, README, and LICENSE, which are metadata, not code.
Predict: What happens if you rename the mymath/ directory to my_math/? What would need to change in the rest of the project?
Step 2: Understand init.py¶
What to do: Read src/mymath/__init__.py and understand what it does when someone writes import mymath.
Why: __init__.py serves two purposes. First, it marks the directory as a Python package (without it, import mymath fails). Second, it controls what is available when someone imports the package. By importing key functions here, users can write from mymath import add instead of the longer from mymath.calculator import add.
__version__ = "0.1.0"
from mymath.calculator import add, subtract, multiply, divide
from mymath.statistics import mean, median, mode
Three things this file does:
- Sets
__version__— a single source of truth for the package version that tools can read. - Re-exports functions from submodules —
from mymath import addworks because__init__.pyimportsaddfromcalculator.py. - Exists as a file — its mere presence tells Python "this directory is a package."
Predict: If you remove the from mymath.calculator import ... line, can you still use from mymath.calculator import add? What about from mymath import add?
Step 3: Write a Module with Functions¶
What to do: Examine calculator.py and statistics.py — the two modules inside the package.
Why: A module is a single .py file. A package is a directory containing modules (plus __init__.py). Each module should have a focused purpose. calculator.py does arithmetic. statistics.py does statistics. This separation makes the code easier to navigate and test.
# calculator.py
def add(a, b):
return a + b
def divide(a, b):
if b == 0:
raise ZeroDivisionError("Cannot divide by zero")
return a / b
# statistics.py
from collections import Counter
def mean(numbers):
if not numbers:
raise ValueError("Cannot calculate mean of empty list")
return sum(numbers) / len(numbers)
def mode(numbers):
if not numbers:
raise ValueError("Cannot calculate mode of empty list")
counts = Counter(numbers)
return counts.most_common(1)[0][0]
Both modules validate their inputs (empty lists, division by zero) and raise clear errors. This is good practice for any library code — the caller should get a useful error message, not a cryptic traceback.
Predict: Why does calculator.py have a main() function and an if __name__ == "__main__" guard? What does python -m mymath.calculator do?
Step 4: Configure pyproject.toml¶
What to do: Read pyproject.toml and understand each section.
Why: pyproject.toml is the configuration file that build tools read to create an installable package. Without it, pip install . does not know the package name, version, or where to find the code.
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "mymath-demo"
version = "0.1.0"
description = "A simple math utilities package"
requires-python = ">=3.10"
dependencies = []
[tool.setuptools.packages.find]
where = ["src"]
Three sections to understand:
[build-system]— tells pip which tool builds the package (setuptools is the most common).[project]— the metadata that appears on PyPI: name, version, description, author.[tool.setuptools.packages.find]— tells setuptools to look in thesrc/directory for packages.
Predict: What happens if you change where = ["src"] to where = ["."]? Where would setuptools look for packages?
Step 5: Run the Package as a Module¶
What to do: Run the calculator module directly and verify the output.
Why: The if __name__ == "__main__" guard in calculator.py lets you run the file as a script with python -m mymath.calculator. The -m flag tells Python to find the module inside the package and execute it. This is useful for demos, CLIs, and quick testing.
Expected output:
Predict: What is the difference between python calculator.py and python -m mymath.calculator? Which one uses the package's import system?
Common Mistakes¶
| Mistake | Why It Happens | Fix |
|---|---|---|
ModuleNotFoundError: No module named 'mymath' |
Package not installed or wrong directory | Run from project root, or pip install -e . for editable install |
Deleting __init__.py breaks imports |
It marks the directory as a package | Always include __init__.py, even if empty |
| Tests import wrong version of code | Flat layout imports local code, not installed | Use src layout to force importing the installed version |
| Version number out of sync | Version in multiple places | Keep __version__ in __init__.py as the single source of truth |
Testing Your Solution¶
Run the tests:
Expected output:
tests/test_calculator.py::test_add PASSED
tests/test_calculator.py::test_subtract PASSED
tests/test_calculator.py::test_multiply PASSED
tests/test_calculator.py::test_divide PASSED
tests/test_calculator.py::test_divide_by_zero PASSED
tests/test_statistics.py::test_mean PASSED
tests/test_statistics.py::test_median PASSED
tests/test_statistics.py::test_mode PASSED
...
Also verify the module runs directly:
What You Learned¶
- The
srclayout puts package code insrc/<package>/, preventing accidental imports of local code instead of the installed package. __init__.pymarks a directory as a Python package and controls what is importable — re-exporting functions from submodules creates a clean public API.pyproject.tomlis the single configuration file for package metadata, build system, and tool settings — it replaces the oldersetup.pyapproach.- Modules vs packages — a module is a single
.pyfile, a package is a directory with__init__.pyand one or more modules. Packages can be installed with pip and imported by anyone.