Modules, Packages and Virtual Environments
Before you start
You need: files and JSON (Article 06).
Time: about 45 minutes, plus the practice.
Learning objective
Structure a project into importable packages, and manage its dependencies in an isolated environment that another developer can reproduce exactly.
Topics
- Modules and imports
if __name__ == "__main__"- Packages
- Project layout
- Virtual environments
- pip and requirements
pyproject.toml- Diagnosing import errors
Modules
A module is a .py file. Importing one runs it and binds its names.
# calculations.py
MAX_MARKS = 100
def calculate_percentage(marks_obtained: int, max_marks: int) -> float:
if max_marks <= 0:
raise ValueError(f"max_marks must be positive, got {max_marks}")
return (marks_obtained / max_marks) * 100
import calculations
calculations.calculate_percentage(87, 100)
calculations.MAX_MARKS
from calculations import calculate_percentage, MAX_MARKS
calculate_percentage(87, 100)
import calculations as calc
from calculations import calculate_percentage as percentage
from calculations import * # never do this
A wildcard import pollutes the namespace with every public name in the module, so a later import can silently shadow an earlier one and nobody can tell where a name came from.
Prefer from module import name for a few names and import module when you want the qualification for readability.
Importing runs the module. Top-level code executes on first import — which is why a module printing or connecting to a database at import time is a problem.
if name == "main"
# calculations.py
def calculate_percentage(marks_obtained: int, max_marks: int) -> float:
return (marks_obtained / max_marks) * 100
if __name__ == "__main__":
print(calculate_percentage(87, 100))
__name__ is "__main__" when the file is run directly and the module's name when it is imported.
Without the guard, importing the module runs its demonstration code. A test that imports calculations would print output, or worse, start a server.
python calculations.py # __name__ == "__main__" — the block runs
import calculations # __name__ == "calculations" — it does not
Put the entry point in a function:
def main() -> int:
try:
run()
return 0
except KeyboardInterrupt:
print("\nCancelled.")
return 130
if __name__ == "__main__":
raise SystemExit(main())
raise SystemExit(main()) sets the process exit code, which matters when the script runs in a pipeline or a scheduled job.
Packages
A package is a directory of modules with an __init__.py.
school/
├── __init__.py
├── models.py
├── calculations.py
└── data/
├── __init__.py
├── students.py
└── fees.py
from school.calculations import calculate_percentage
from school.data.students import StudentRepository
import school.models as models
__init__.py marks the directory as a package and runs on first import. It is usually empty, or it curates the public surface:
# school/__init__.py
from school.models import Student, ExamResult
from school.calculations import calculate_percentage
__all__ = ["Student", "ExamResult", "calculate_percentage"]
from school import Student, calculate_percentage
__all__ names what from school import * exports, and documents the intended public API.
Keep __init__.py light. Heavy imports there run on every import of anything in the package, which makes startup slow and creates circular-import risk.
Absolute and relative imports
# Absolute — clear and unambiguous
from school.data.students import StudentRepository
# Relative — within the same package
from .students import StudentRepository # same package
from ..models import Student # parent package
Prefer absolute imports. They work regardless of how the module is invoked, and they say exactly where a name comes from. Relative imports are acceptable inside a package and fail when the module is run directly:
ImportError: attempted relative import with no known parent package
That error means the file was run as a script rather than imported as part of a package. Run it as a module instead:
python -m school.data.students
Circular imports
# school/models.py
from school.data.students import StudentRepository # imports data
# school/data/students.py
from school.models import Student # imports models — circular
ImportError: cannot import name 'Student' from partially initialized module
Three fixes, in order of preference:
Restructure. The cycle usually means the dependency direction is wrong. models should not know about data; data depends on models, one way.
Import inside the function, when the dependency is genuinely needed late:
def save(self, student):
from school.services import notify # imported at call time
notify(student)
Import the module rather than the name:
import school.models
def build() -> "school.models.Student":
return school.models.Student(...)
For type hints only, TYPE_CHECKING avoids the runtime import entirely:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from school.models import Student
def save(student: "Student") -> None:
...
Project layout
school-portal/
├── pyproject.toml
├── README.md
├── .gitignore
├── .env.example
├── src/
│ └── school/
│ ├── __init__.py
│ ├── models.py
│ ├── calculations.py
│ ├── data/
│ │ ├── __init__.py
│ │ └── students.py
│ └── api/
│ ├── __init__.py
│ └── main.py
├── tests/
│ ├── test_calculations.py
│ └── test_students.py
└── data/
└── students.csv
The src/ layout is the current recommendation. The package is not importable from the project root unless it is installed, which means your tests exercise the installed package rather than accidentally importing loose files — the same thing a user will get.
pip install -e .
An editable install makes school importable from anywhere while still reading your working files.
tests/ sits outside src/ so it is not shipped with the package.
Virtual environments
Every project gets its own environment. Without one, two projects needing different versions of the same library cannot both work, and pip install pollutes the system Python.
python -m venv .venv
# Activate
.venv\Scripts\activate # Windows
source .venv/bin/activate # macOS and Linux
# Confirm
which python # should point inside .venv
python -c "import sys; print(sys.prefix)"
deactivate
The prompt shows (.venv) when active.
Add .venv/ to .gitignore. It contains hundreds of megabytes of platform-specific binaries and is rebuilt from requirements.txt in seconds.
In VS Code, Ctrl+Shift+P → Python: Select Interpreter and choose the one in .venv. When code runs in the terminal but VS Code shows import errors, this is why.
"It works on my machine" is nearly always a missing or unactivated virtual environment. Two symptoms: a package installed globally that is not in requirements.txt, and a package installed in the wrong environment because activation was forgotten.
pip
pip install requests
pip install "fastapi>=0.110,<1.0"
pip install -r requirements.txt
pip install -e . # this project, editable
pip list
pip show requests
pip uninstall requests
pip install --upgrade requests
pip freeze > requirements.txt
pip freeze records exact versions of everything installed, including transitive dependencies. That is right for an application, where reproducibility matters, and wrong for a library, where it over-constrains consumers.
# requirements.txt — an application
fastapi==0.111.0
pydantic==2.7.1
uvicorn==0.29.0
# requirements-dev.txt
-r requirements.txt
pytest==8.2.0
mypy==1.10.0
black==24.4.2
ruff==0.4.4
Splitting development tools out keeps them off the production server.
pip install -r requirements.txt
pip install -r requirements-dev.txt
A dependency installed but not recorded is the classic deployment failure — it works locally and the server cannot start. Record it the moment you install it.
pyproject.toml
The modern replacement for setup.py.
[project]
name = "school-portal"
version = "0.1.0"
description = "NexCoding Academy school management"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.110,<1.0",
"pydantic>=2.7,<3.0",
"uvicorn[standard]>=0.29",
]
[project.optional-dependencies]
dev = ["pytest>=8.0", "mypy>=1.10", "black>=24.0", "ruff>=0.4"]
[project.scripts]
school-import = "school.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.black]
line-length = 88
[tool.ruff]
line-length = 88
select = ["E", "F", "I", "UP", "B"]
[tool.mypy]
python_version = "3.12"
strict = true
pip install -e ".[dev]"
One file holds the metadata, dependencies and every tool's configuration.
[project.scripts] creates a command — school-import on the PATH, calling school.cli:main.
Ruff's B rules catch the mutable-default-argument bug automatically, which is worth enabling on its own.
uv
pip install uv
uv venv
uv pip install -r requirements.txt
uv pip sync requirements.txt
uv is a much faster drop-in replacement for pip and venv. Worth adopting; the concepts are identical.
The standard library
Python ships with a great deal, and reaching for a dependency before checking is a common mistake.
| Module | For |
|---|---|
pathlib | Filesystem paths |
datetime | Dates and times |
decimal | Exact decimal arithmetic |
json, csv | Data formats |
collections | defaultdict, Counter, deque |
itertools | Iterator building blocks |
functools | lru_cache, wraps, partial |
dataclasses | Records |
enum | Enumerations |
logging | Logging |
argparse | Command-line arguments |
unittest, sqlite3, re, uuid | As named |
from enum import Enum
class StudentStatus(Enum):
ACTIVE = 0
INACTIVE = 1
GRADUATED = 2
TRANSFERRED = 3
status = StudentStatus.ACTIVE
status.name # "ACTIVE"
status.value # 0
StudentStatus(1) # StudentStatus.INACTIVE
An enum beats string constants: a typo is an error rather than a silent mismatch, and the valid values are discoverable.
import argparse
parser = argparse.ArgumentParser(description="Import students from a CSV file")
parser.add_argument("path", type=Path, help="The CSV file to import")
parser.add_argument("--dry-run", action="store_true", help="Validate without saving")
parser.add_argument("--school-id", type=int, required=True)
args = parser.parse_args()
argparse gives --help, type conversion and validation for free.
Diagnosing import errors
| Error | Cause |
|---|---|
ModuleNotFoundError: No module named 'x' | Not installed, or the wrong environment is active |
ImportError: cannot import name 'Y' | The name does not exist, or a circular import |
ImportError: attempted relative import with no known parent package | Run as a script instead of python -m |
| Works in the terminal, fails in VS Code | Wrong interpreter selected |
| Works locally, fails on the server | A dependency not in requirements.txt |
import sys
print(sys.executable) # which Python is running
print(sys.path) # where imports are searched
sys.executable answers most of these in one line. If it is not inside .venv, the environment is not active — and every other symptom follows from that.
pip list | grep fastapi
python -c "import fastapi; print(fastapi.__file__)"
The second shows where a module was actually loaded from, which resolves "I installed it but it is not found".
A local file shadowing a library is a subtle one:
json.py # your file
import json now finds yours, and every library depending on the real json breaks with confusing errors. Never name a file after a standard-library module — json.py, csv.py, logging.py, types.py, email.py.
Errors you will hit
| Message | Cause | Fix |
|---|---|---|
ModuleNotFoundError | Not installed, or installed in a different environment | Activate the venv, then pip install |
| Package installed but VS Code cannot see it | Wrong interpreter selected | Ctrl+Shift+P → Python: Select Interpreter |
ImportError: attempted relative import with no known parent package | Ran a module inside a package directly | Run with -m, or fix the layout |
| It works for you and not a colleague | No pinned dependencies | pip freeze > requirements.txt |
| Two projects break each other | One shared global environment | One virtual environment per project |
One virtual environment per project, always. Installing globally is how two projects end up needing incompatible versions of the same package.
Common mistakes
- No virtual environment
- Forgetting to activate it
- The wrong interpreter selected in VS Code
.venv/committed to Git- A dependency installed but not recorded
pip freezefor a library, over-constraining consumers- Wildcard imports
- No
if __name__ == "__main__"guard - Circular imports, patched instead of restructured
- Relative imports in a module run directly
- Heavy work in
__init__.py - A file named after a standard-library module
- Adding a dependency for something in the standard library
- No
pyproject.toml, with tool configuration scattered
Practice
- Create a project with the
src/layout and aschoolpackage. - Create a virtual environment, activate it, and confirm
sys.executablepoints inside it. - Install a package without activating, then check
pip listin both environments. - Add
.venv/to.gitignoreand confirm it is not tracked. - Split code into
models.py,calculations.pyanddata/students.py. Import across them absolutely. - Add a
printat module level and import the module. Confirm it runs, then move it behind the__main__guard. - Create a circular import between two modules. Read the error, then fix it by restructuring.
- Use a relative import in a module and run it directly. Record the error, then run it with
python -m. - Write a
pyproject.tomlwith dependencies, dev extras and tool configuration. Install withpip install -e ".[dev]". - Add a
[project.scripts]entry and run the command. - Create
requirements.txtandrequirements-dev.txtwith-r. - Install a package, use it, and forget to record it. Rebuild the environment from
requirements.txtand confirm the failure. - Create a file named
json.pynext to your code andimport json. Record what breaks. - Replace string status constants with an
Enum. Pass an invalid value and compare the failure. - Add
argparseto an import script with--dry-runand--school-id. Run--help. - Print
sys.executableandsys.pathfrom inside and outside the environment.
Exercises 12 and 13 correspond to two real deployment failures.
You can now
- Structure a project into modules and packages
- Create and activate a virtual environment
- Pin dependencies in
requirements.txt - Point VS Code at the right interpreter
- Diagnose a
ModuleNotFoundError
Review questions
- What problem do virtual environments solve?
- What does
if __name__ == "__main__"prevent? - Why prefer absolute imports over relative ones?
- Why must a file never be named after a standard-library module?
Next: Object-oriented Python