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.

⏱ ~2 hoursPrerequisites: M07 Errors & Exceptions, M00 Setup

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:

  1. Check the cache. If "money" is already in the dict sys.modules, use that module object and stop. This is why importing the same module twice is cheap and does not run its code twice.
  2. Find and load. Otherwise, search the directories in sys.path for money.py (or a package directory money/), create a new module object, store it in sys.modules, and execute the file's code from top to bottom inside the module's own namespace.
  3. Bind a name. Bind the name money in 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

FormBindsUse it when
import mathmathThe default. math.sqrt reads clearly and shows where names come from.
import numpy as npnpLong names with a community-standard alias (np, pd, plt).
from math import sqrt, pisqrt, piYou use a few names often and they are unambiguous.
from pathlib import Path as PPAvoiding a clash between two names.
from math import *everything publicAlmost 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:

  1. the directory of the script you ran (or the current directory in the REPL, or with -m),
  2. directories in the PYTHONPATH environment variable, if set,
  3. the standard library directories,
  4. 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.

budget/ pixi.tomlenvironment, dependencies, tasks pixi.lockexact versions (commit it) pyproject.tomlpackage metadata, tool config src/ budget/the importable package __init__.py cli.py money.py tests/ test_money.pyimport budget, like a user would .pixi/the environment (never commit)
The src layout keeps the package out of the project root, so it can only be imported once it is installed.

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

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

Mini-projecttoolbox: a real Pixi project

Collect the bill splitter (M05), contact book (M06), and validator (M07) into one installable package called toolbox, with tests, an entry point, and Pixi tasks.

pixi init toolbox
cd toolbox
pixi add python=3.14 pytest ruff
mkdir -p src/toolbox tests
# move your modules into src/toolbox/, write pyproject.toml, then:
# add toolbox = { path = ".", editable = true } under [pypi-dependencies] in pixi.toml, then:
pixi install
pixi run python -m toolbox --help
toolbox/
    pixi.toml
    pyproject.toml
    src/toolbox/
        __init__.py      # re-export the main functions; define __all__
        __main__.py      # from toolbox.cli import main; raise SystemExit(main())
        cli.py           # main(argv=None) -> int, using argparse subcommands
        splitter.py
        contacts.py
        validate.py
    tests/
        test_splitter.py
  • pixi.toml created with pixi init; dependencies added with pixi add
  • [pypi-dependencies] has toolbox = { path = ".", editable = true }
  • pyproject.toml has [project], a hatchling [build-system], and [project.scripts] toolbox = "toolbox.cli:main"
  • Modules inside the package import each other with relative imports
  • pixi run python -m toolbox split 120 4 and pixi run toolbox split 120 4 both work
  • Tasks test and lint exist; pixi run test passes with at least three tests
  • No module does work at import time; every script entry point is behind if __name__ == "__main__": or in __main__.py
  • pixi.lock is committed; .pixi/ is in .gitignore

Stretch goals: add a ci task with depends-on = ["lint", "test"]; make toolbox --version print the version read with importlib.metadata.version("toolbox"); try your package under 3.15 with pixi exec --spec "python=3.15" python -c "print('hi')".

Quiz

Recap

Going further