Packaging#
This notebook covers how Python modules are organized into packages, how to install third-party packages, and how to manage project dependencies.
Learning Goals
Distinguish a module from a package
Understand the role of
__init__.pyImport from a multi-module package
Install and inspect packages with
pipRecord and restore dependencies with
requirements.txtUnderstand why virtual environments matter
Modules vs. Packages#
So far you have worked with modules — single .py files that hold reusable functions or classes. As a project grows, related modules are grouped into a package: a folder that Python treats as a single importable unit.
Concept |
What it is |
Example |
|---|---|---|
Module |
One |
|
Package |
A folder containing modules + |
|
Library |
A collection of packages distributed via |
|
The key ingredient that turns a plain folder into a package is the __init__.py file inside it.
Package Structure#
A minimal package looks like this:
my_package/
__init__.py ← marks the folder as a package (can be empty)
math_tools.py ← module 1
text_tools.py ← module 2
__init__.py serves two purposes:
It tells Python that the folder is a package, not just a directory.
It can optionally re-export names so callers don’t need to know which sub-module they come from.
The code below creates this layout on disk so you can import and test it in subsequent cells:
from pathlib import Path
pkg = Path('my_package')
pkg.mkdir(exist_ok=True)
# __init__.py — re-exports key names for convenience
(pkg / '__init__.py').write_text(
"from .math_tools import add, square\n"
"from .text_tools import shout\n",
encoding='utf-8'
)
# math_tools.py
(pkg / 'math_tools.py').write_text(
"def add(x, y):\n"
" return x + y\n\n"
"def square(x):\n"
" return x ** 2\n",
encoding='utf-8'
)
# text_tools.py
(pkg / 'text_tools.py').write_text(
"def shout(text):\n"
" return text.upper() + '!'\n",
encoding='utf-8'
)
print('Package written to:', pkg.resolve())
Package written to: /Users/tychen/workspace/py/chapters/appendices/05-tooling/my_package
Importing from a Package#
Once the package folder is on the Python path, you can import from it using dot notation that mirrors the folder structure:
import my_package # import the package itself
from my_package import math_tools # import a specific module
from my_package.math_tools import add # import a specific function
from my_package import add # works if __init__.py re-exports it
The dotted path always maps left-to-right onto the filesystem: my_package.math_tools → my_package/math_tools.py.
import sys, importlib
# make sure our local folder is on the path
if '.' not in sys.path:
sys.path.insert(0, '.')
# reload in case the package was already imported earlier in this session
import my_package
importlib.reload(my_package)
# --- three import styles ---
# 1. import via the package's __init__.py re-exports
from my_package import add, square, shout
print(add(3, 4)) # 7
print(square(5)) # 25
print(shout('hello')) # HELLO!
# 2. import a specific sub-module
from my_package import math_tools
print(math_tools.add(10, 20)) # 30
# 3. import directly from the sub-module
from my_package.text_tools import shout as yell
print(yell('quiet')) # QUIET!
7
25
HELLO!
30
QUIET!
### Exercise: Extend the Package
# Add a third module `list_tools.py` to my_package with a function `flatten(nested)`
# that takes a list of lists and returns a single flat list.
# Then import and call it here.
### Your code starts here.
### Your code ends here.
### Solution
from pathlib import Path
(Path('my_package') / 'list_tools.py').write_text(
"def flatten(nested):\n"
" return [item for sublist in nested for item in sublist]\n",
encoding='utf-8'
)
from my_package.list_tools import flatten
print(flatten([[1, 2], [3, 4], [5]])) # [1, 2, 3, 4, 5]
[1, 2, 3, 4, 5]
Installing Packages with pip#
Python’s package installer, pip, downloads and installs packages from PyPI (the Python Package Index). The essential commands:
Command |
What it does |
|---|---|
|
Install a package |
|
Install a specific version |
|
Remove a package |
|
Show all installed packages |
|
Details about one package (version, location, deps) |
|
Upgrade to the latest version |
In a notebook you can prefix any shell command with ! to run it:
import subprocess, sys
# show installed packages (first 10 lines)
result = subprocess.run(
[sys.executable, '-m', 'pip', 'list'],
capture_output=True, text=True
)
lines = result.stdout.strip().splitlines()
for line in lines[:12]:
print(line)
Package Version
--------------------------------- -----------
accessible-pygments 0.0.5
alabaster 0.7.16
anyio 4.11.0
appnope 0.1.4
argon2-cffi 25.1.0
argon2-cffi-bindings 25.1.0
arrow 1.4.0
asttokens 3.0.0
async-lru 2.0.5
attrs 25.3.0
# show details for a specific package
result = subprocess.run(
[sys.executable, '-m', 'pip', 'show', 'requests'],
capture_output=True, text=True
)
print(result.stdout if result.stdout else 'requests is not installed.')
Name: requests
Version: 2.32.5
Summary: Python HTTP for Humans.
Home-page: https://requests.readthedocs.io
Author: Kenneth Reitz
Author-email: me@kennethreitz.org
License: Apache-2.0
Location: /Users/tychen/workspace/py/.venv/lib/python3.13/site-packages
Requires: certifi, charset_normalizer, idna, urllib3
Required-by: jupyterlab_server, Sphinx
requirements.txt and Reproducibility#
When you share a project, others need to know which packages to install. A requirements.txt file records the exact packages (and optionally their versions) so anyone can recreate your environment with one command:
pip install -r requirements.txt
Generate it from your current environment:
pip freeze > requirements.txt
Minimal format (version-pinned, recommended for sharing):
requests==2.31.0
numpy==1.26.4
pandas==2.2.1
Loose format (no versions, accepts any compatible release):
requests
numpy
pandas
Pin versions when reproducibility matters (data science projects, deployed apps). Use loose versions only for development experiments.
from pathlib import Path
# write a minimal requirements.txt by hand
reqs = Path('requirements_demo.txt')
reqs.write_text(
"requests\n"
"numpy\n",
encoding='utf-8'
)
print(reqs.read_text())
requests
numpy
Virtual Environments#
A virtual environment is an isolated Python installation for a single project. It keeps each project’s dependencies separate so installing a package for one project doesn’t break another.
# Create a venv in the current project folder
python -m venv .venv
# Activate it (macOS/Linux)
source .venv/bin/activate
# Activate it (Windows)
.venv\Scripts\activate
# Deactivate when done
deactivate
Once activated, pip install and python refer to the isolated environment, not your global Python. Your requirements.txt then captures only the packages needed for that project.
Rule of thumb: one virtual environment per project, activated before any pip install.
import sys
# show which Python executable is active — in a venv it will point inside .venv/
print('Python executable:', sys.executable)
print('Version :', sys.version)
Python executable: /Users/tychen/workspace/py/.venv/bin/python3.13
Version : 3.13.7 (main, Aug 14 2025, 11:12:11) [Clang 17.0.0 (clang-1700.0.13.3)]
### Exercise: pip show
# Use subprocess to run `pip show` on a package that IS installed in this environment
# (e.g., 'pathlib', 'jupyter', or any package from pip list above).
# Print the Name and Version lines only.
### Your code starts here.
### Your code ends here.
### Solution
import subprocess, sys
result = subprocess.run(
[sys.executable, '-m', 'pip', 'show', 'pip'],
capture_output=True, text=True
)
for line in result.stdout.splitlines():
if line.startswith('Name') or line.startswith('Version'):
print(line)
Name: pip
Version: 25.2