Skip to main content
Published / updated

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.

ModuleFor
pathlibFilesystem paths
datetimeDates and times
decimalExact decimal arithmetic
json, csvData formats
collectionsdefaultdict, Counter, deque
itertoolsIterator building blocks
functoolslru_cache, wraps, partial
dataclassesRecords
enumEnumerations
loggingLogging
argparseCommand-line arguments
unittest, sqlite3, re, uuidAs 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

ErrorCause
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 packageRun as a script instead of python -m
Works in the terminal, fails in VS CodeWrong interpreter selected
Works locally, fails on the serverA 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 modulejson.py, csv.py, logging.py, types.py, email.py.

Errors you will hit

MessageCauseFix
ModuleNotFoundErrorNot installed, or installed in a different environmentActivate the venv, then pip install
Package installed but VS Code cannot see itWrong interpreter selectedCtrl+Shift+P → Python: Select Interpreter
ImportError: attempted relative import with no known parent packageRan a module inside a package directlyRun with -m, or fix the layout
It works for you and not a colleagueNo pinned dependenciespip freeze > requirements.txt
Two projects break each otherOne shared global environmentOne 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 freeze for 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

  1. Create a project with the src/ layout and a school package.
  2. Create a virtual environment, activate it, and confirm sys.executable points inside it.
  3. Install a package without activating, then check pip list in both environments.
  4. Add .venv/ to .gitignore and confirm it is not tracked.
  5. Split code into models.py, calculations.py and data/students.py. Import across them absolutely.
  6. Add a print at module level and import the module. Confirm it runs, then move it behind the __main__ guard.
  7. Create a circular import between two modules. Read the error, then fix it by restructuring.
  8. Use a relative import in a module and run it directly. Record the error, then run it with python -m.
  9. Write a pyproject.toml with dependencies, dev extras and tool configuration. Install with pip install -e ".[dev]".
  10. Add a [project.scripts] entry and run the command.
  11. Create requirements.txt and requirements-dev.txt with -r.
  12. Install a package, use it, and forget to record it. Rebuild the environment from requirements.txt and confirm the failure.
  13. Create a file named json.py next to your code and import json. Record what breaks.
  14. Replace string status constants with an Enum. Pass an invalid value and compare the failure.
  15. Add argparse to an import script with --dry-run and --school-id. Run --help.
  16. Print sys.executable and sys.path from 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

  1. What problem do virtual environments solve?
  2. What does if __name__ == "__main__" prevent?
  3. Why prefer absolute imports over relative ones?
  4. Why must a file never be named after a standard-library module?

Next: Object-oriented Python