Part 1 · Foundations · Module 08
Modules, Packages & Pixi Projects
A single file is fine for a script, but real programs are split across many files, reuse the
standard library, and depend on other people's packages. This module explains exactly what
import does, how to organize code into packages, and how to turn a folder of code into a proper,
reproducible Pixi project.
- Explain what happens when Python executes an
importstatement, including caching insys.modules - Use every import form and know which to prefer
- Describe the module search path and diagnose shadowing and
ModuleNotFoundError - Use
if __name__ == "__main__":to make files both importable and runnable - Build packages with
__init__.py, subpackages, and relative imports - Lay out a project with the src layout,
pyproject.toml, andpixi.toml, including an editable install, entry points, and tasks - Add dependencies with
pixi add(conda-forge first) and understandpixi.lock
What a module is, and what import does
A module is simply a .py file. Its name is the file name without the extension:
money.py is the module money. Everything defined at the top level of the file
(functions, classes, variables) becomes an attribute of the module object.
When Python runs import money it does three things:
- Check the cache. If
"money"is already in the dictsys.modules, use that module object and stop. This is why importing the same module twice is cheap and does not run its code twice. - Find and load. Otherwise, search the directories in
sys.pathformoney.py(or a package directorymoney/), create a new module object, store it insys.modules, and execute the file's code from top to bottom inside the module's own namespace. - Bind a name. Bind the name
moneyin the importing namespace to that module object.
The browser runtime has an in-memory file system, so we can create a real module file and import it:
The forms of import
| Form | Binds | Use it when |
|---|---|---|
import math | math | The default. math.sqrt reads clearly and shows where names come from. |
import numpy as np | np | Long names with a community-standard alias (np, pd, plt). |
from math import sqrt, pi | sqrt, pi | You use a few names often and they are unambiguous. |
from pathlib import Path as P | P | Avoiding a clash between two names. |
from math import * | everything public | Almost never: you cannot tell where names come from, and it can silently overwrite your own names. |
A module can declare its public API with a list named __all__. It controls what
from module import * exports and documents intent for readers and tools.
import json
from pathlib import Path
import numpy as np
from ledger.models import Transaction
The module search path
Where does Python look for money.py? In each directory of the list sys.path, in
order, and it takes the first match. A typical sys.path contains:
- the directory of the script you ran (or the current directory in the REPL, or with
-m), - directories in the
PYTHONPATHenvironment variable, if set, - the standard library directories,
site-packages: where installed packages live. With Pixi this is inside your project's.pixi/envs/default/folder.
flowchart LR
I["import money"] --> C{"in sys.modules?"}
C -->|yes| R[reuse cached module]
C -->|no| S1["script directory"]
S1 -->|not found| S2[PYTHONPATH]
S2 -->|not found| S3[standard library]
S3 -->|not found| S4["site-packages (.pixi env)"]
S4 -->|not found| E[ModuleNotFoundError]
S1 -->|found| L[execute and cache]
S2 -->|found| L
S3 -->|found| L
S4 -->|found| L
The shadowing trap
Because the script's own directory comes first, a file you name random.py, json.py,
or statistics.py hides the standard-library module of the same name. Then
import random imports your file, and you get baffling errors like AttributeError: module
'random' has no attribute 'randint'.
__name__ and the main guard
Every module has a __name__ attribute. When a file is imported, __name__ is the
module's name ("money"). When a file is run directly (pixi run python
money.py or pixi run python -m money), Python names it "__main__" instead.
That lets a file be both a reusable module and a runnable program:
"""Currency helpers."""
def with_tax(amount: float, rate: float = 0.08) -> float:
"""Return amount plus tax."""
return round(amount * (1 + rate), 2)
def main() -> None:
amount = float(input("Amount? "))
print(with_tax(amount))
if __name__ == "__main__": # true only when run as a program
main()
Importing money from a test now gives access to with_tax without triggering the
input() prompt. Without the guard, the prompt would appear on every import.
For packages, the file __main__.py plays the same role: pixi run python -m ledger
runs ledger/__main__.py. Project 1 uses exactly this.
Packages and relative imports
A package is a directory of modules, imported with dotted names. A directory containing an
__init__.py file is a regular package. __init__.py runs when the package is
first imported; it can be empty, or it can import the most useful names so users can write
from shop import Cart instead of from shop.cart.models import Cart.
shop/
__init__.py # makes "shop" a package
pricing.py # module shop.pricing
cart/
__init__.py # subpackage shop.cart
items.py # module shop.cart.items
Inside a package, modules can import each other with absolute imports
(from shop.pricing import with_tax) or relative imports, where leading dots mean
"this package" (.) and "the parent package" (..):
# inside shop/cart/items.py (its package is "shop.cart")
from . import helpers # shop.cart.helpers
from .helpers import slugify # shop.cart.helpers.slugify
from ..pricing import with_tax # shop.pricing.with_tax
A tour of the standard library
Python ships "batteries included": hundreds of modules, already installed, documented, and tested. Before writing something general-purpose, check whether the standard library already has it.
Numbers
math, statistics, random, decimal, fractions
Text
string, re, textwrap, difflib, string.templatelib (3.14)
Data
collections, itertools, functools, heapq, bisect, dataclasses
Time
datetime, zoneinfo, time, calendar
Files and formats
pathlib, csv, json, tomllib, sqlite3, compression.zstd (3.14)
Programs
sys, os, argparse, logging, subprocess, unittest
Project layout: the src layout and pyproject.toml
Once code grows beyond a couple of files, professional Python projects use the src layout:
the importable package lives inside a src/ directory, and tests live beside it.
Why the extra folder? Without it, running tests from the project root can import your package straight from
the working directory (thanks to sys.path[0]), even if it is not installed correctly. Missing
files or broken packaging then go unnoticed until a user installs it. With src/, tests import the
installed package, exactly as users will.
pyproject.toml is the standard file (PEP 621) that describes the package itself: its name,
version, required Python, entry points, and the build backend that turns the source tree into
an installable package. It is also where tools like pytest, ruff, and mypy keep their configuration.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "budget"
version = "0.1.0"
description = "Split bills and track spending."
requires-python = ">=3.12"
dependencies = []
[project.scripts]
budget = "budget.cli:main" # an entry point: installs a `budget` command that calls budget.cli.main()
[tool.ruff]
line-length = 120
An entry point ([project.scripts]) asks the installer to create a small
executable named budget that imports budget.cli and calls main(). Its
return value becomes the exit code.
Pixi projects: environments, editable installs, tasks
pixi.toml describes the environment your project runs in: which Python, which
packages, which platforms, and which commands (tasks) you run often. Create one with pixi init;
never write it by hand from memory.
pixi init budget
cd budget
pixi add python=3.14 # conda-forge package
pixi add pytest ruff # more conda-forge packages
pixi add --pypi some-package # only if it is NOT on conda-forge
[workspace]
name = "budget"
channels = ["conda-forge"]
platforms = ["osx-arm64", "linux-64", "win-64"]
version = "0.1.0"
[dependencies]
python = "3.14.*"
pytest = ">=8"
ruff = "*"
[pypi-dependencies]
budget = { path = ".", editable = true } # install our own package, editable
[tasks]
test = "pytest tests/"
lint = "ruff check ."
start = "budget" # runs the entry-point command
The pieces
[workspace]: the project name, the channels to download from (conda-forge, a huge community repository of pre-built packages), and the platforms the lock file must support.[dependencies]: conda-forge packages. Prefer these: they are pre-compiled and can include non-Python libraries.[pypi-dependencies]: packages from PyPI, for anything not on conda-forge. The linebudget = { path = ".", editable = true }installs your own project in editable mode: the environment points at yoursrc/folder, so edits take effect immediately without reinstalling, while imports still behave like an installed package.[tasks]: named commands.pixi run testrunspytest tests/inside the environment. Tasks can depend on others withdepends-on.pixi.lock: generated automatically. It records the exact version and hash of every package for every platform, so a teammate (or CI) runningpixi installgets an identical environment. Commit it.
pixi install # create or update .pixi/ from pixi.lock
pixi run python -m budget # run the package's __main__.py
pixi run budget # run the entry point installed by the editable install
pixi run test # run a task
pixi shell # open a shell with the environment activated
pixi exec --spec "python=3.15" python # try another Python without touching the project
Preview: lazy imports in Python 3.15
Importing a large module can take noticeable time, which slows down command-line tools that only need it for one rarely used subcommand. Today, the workaround is to move the import inside the function that needs it.
In 3.14, get a similar effect by importing inside a function:
def export_report(data):
import json # loaded only when this function first runs
return json.dumps(data)
Practice
Write make_module(directory, name, source). It writes source to
<directory>/<name>.py, makes sure directory is on
sys.path (without adding duplicates), invalidates import caches, and returns the imported
module via importlib.import_module(name). directory is a Path.
Hint
if str(directory) not in sys.path: sys.path.insert(0, str(directory))
Show solution
import importlib
import sys
from pathlib import Path
def make_module(directory, name, source):
(directory / f"{name}.py").write_text(source)
if str(directory) not in sys.path:
sys.path.insert(0, str(directory))
importlib.invalidate_caches()
return importlib.import_module(name)
Write resolve_relative(name, package) that turns a relative module name into an absolute
one, the way Python does. package is the current package (for example
"shop.cart"). One leading dot means package itself, each extra dot goes up one
level, and the rest is appended. Raise ImportError when the dots go above the top-level package.
Names without leading dots are returned unchanged. Do not use importlib.util.resolve_name.
Hint
Count the dots with len(name) - len(name.lstrip(".")).
Split package on dots and drop dots - 1 parts from the end.
Show solution
def resolve_relative(name, package):
rest = name.lstrip(".")
dots = len(name) - len(rest)
if dots == 0:
return name
parts = package.split(".")
if dots - 1 >= len(parts):
raise ImportError("attempted relative import beyond top-level package")
base = parts[: len(parts) - (dots - 1)]
return ".".join(base + ([rest] if rest else []))
In a src-layout project, write module_name(path) that converts a path string such as
"src/shop/cart/items.py" into the dotted module name "shop.cart.items". Drop the
leading src/ if present, drop .py, and treat __init__.py as the
package itself ("src/shop/__init__.py" → "shop"). Use
pathlib.PurePosixPath.
Hint
PurePosixPath(path).with_suffix("").parts gives a
tuple of parts; drop a leading "src" and a trailing "__init__".
Show solution
from pathlib import PurePosixPath
def module_name(path):
parts = list(PurePosixPath(path).with_suffix("").parts)
if parts and parts[0] == "src":
parts = parts[1:]
if parts and parts[-1] == "__init__":
parts = parts[:-1]
return ".".join(parts)
Write pixi_tasks(toml_text) that parses the text of a pixi.toml with
tomllib.loads and returns a dict of task name → command string. A task is either a plain
string or a table with a cmd key; tables without cmd (only
depends-on) map to "". A file without [tasks] returns
{}.
Hint
isinstance(value, str) tells the two forms apart;
for a dict use value.get("cmd", "").
Show solution
import tomllib
def pixi_tasks(toml_text):
tasks = tomllib.loads(toml_text).get("tasks", {})
return {
name: value if isinstance(value, str) else value.get("cmd", "")
for name, value in tasks.items()
}
Mini-project: package your earlier scripts
Quiz
Recap
- A module is a file; the first import runs it and caches it in
sys.modules. - Prefer
import mandfrom m import name; avoidimport *. sys.pathis searched in order; beware shadowing standard-library names.if __name__ == "__main__":makes a file importable and runnable; packages use__main__.py.- Packages are directories with
__init__.py; relative imports count package levels with dots. - Src layout +
pyproject.tomlfor the package;pixi.toml+pixi.lockfor the environment and tasks. pixi add(conda-forge) first,pixi add --pypisecond; your own package is an editable PyPI dependency.- Python 3.15 adds
lazy importfor deferred loading.
Going further
- Python tutorial: Modules
- Language reference: The import system
- Python module index (the standard library)
- Python Packaging User Guide: Writing your pyproject.toml
- Pixi documentation: workspaces, tasks, and PyPI dependencies
- PEP 810: Explicit lazy imports
- Next: M09 Files & Data Formats; much later, M41 Pixi & Packaging goes deeper.