Skip to content
Educora
Advanced20 min22 / 42

Packages, modules and virtual environments

Structure a project like a professional: modules vs packages, `__init__.py`, relative imports, `if __name__ == '__main__'`, `venv`, `pip`, `requirements.txt`, `pyproject.toml` and version specifiers.

Check yourself
In this lesson you will learn
  • Explain modules, packages, sys.path and sys.modules
  • Use if __name__ == '__main__' so a file works both as a module and as a script
  • Create a virtual environment with venv, install packages with pip and record dependencies
  • Read requirements.txt and pyproject.toml and understand version specifiers

One of your projects needs Django 4, another needs Django 5 — install the package for the whole computer, and one of them will break. A teammate runs your code and gets ModuleNotFoundError, because the libraries you installed are not on their machine. Real projects follow three rules: the code is split into modules and packages, every project gets its own virtual environment, and the dependencies are recorded in a file together with their versions.

Modules and packages

A module is any .py file. When you write import pricing, Python searches the folders listed in sys.path, runs the file's top-level code once and stores the module object in sys.modules — later imports reuse that object. A package is a folder of modules; a regular package contains an __init__.py file, which runs when the package is imported for the first time. Names are dotted: shop.pricing is the module pricing inside the package shop. A typical project looks like this:

Text
shop/
├── pyproject.toml
├── requirements.txt
├── .venv/                 (not in git)
├── src/
│   └── shop/
│       ├── __init__.py
│       ├── pricing.py
│       └── cli.py
└── tests/
    └── test_pricing.py
The “src layout”: the package code lives in src/, and the tests are kept separately in tests/.

The example below builds a tiny package in a temporary folder and imports it in three different ways (the sys.modules.pop lines are there only so that the example can be run again):

Python
import importlib
import sys
import tempfile
from pathlib import Path

root = Path(tempfile.mkdtemp())
(root / 'shop').mkdir()
(root / 'shop' / '__init__.py').write_text('from .pricing import final_price\n')
(root / 'shop' / 'pricing.py').write_text(
    'VAT = 0.18\n'
    'print("running pricing.py as", __name__)\n'
    'def final_price(net):\n'
    '    return round(net * (1 + VAT), 2)\n'
)
sys.path.insert(0, str(root))
sys.modules.pop('shop', None)
sys.modules.pop('shop.pricing', None)
importlib.invalidate_caches()

import shop
import shop.pricing
from shop import final_price
print(final_price(100), shop.pricing.VAT)
print(__name__)
▸ Expected output
running pricing.py as shop.pricing
118.0 0.18
__main__

pricing.py ran only once despite three imports. Inside the module, __name__ is its full name (shop.pricing), while in the file that was started directly it is '__main__'. The line from .pricing import final_price in __init__.py is a relative import: the dot means “this package”. Thanks to it, users can write from shop import final_price without knowing the internal layout.

if __name__ == '__main__': script or module

A file can be both an importable module and a runnable script. The condition if __name__ == '__main__': runs the script code only when the file is started directly, not when it is imported. The command python -m shop.cli finds the module as part of its package and runs it as a script, so imports inside the package keep working:

Python
import sys
from shop.pricing import final_price

def main() -> int:
    for arg in sys.argv[1:]:
        print(f'{arg} -> {final_price(float(arg))}')
    return 0

if __name__ == '__main__':
    sys.exit(main())
The file src/shop/cli.py
Terminal
$ python -m shop.cli 10 25.5
10 -> 11.8
25.5 -> 30.09

Virtual environments: venv and pip

Without a virtual environment, all projects share one site-packages folder and package versions clash. A virtual environment is a folder inside the project (usually .venv) with its own Python interpreter and its own packages. The standard venv module creates it, and pip installs packages into it:

  1. 1
    Create

    In the project folder: python -m venv .venv.

  2. 2
    Activate

    Windows: .venv\Scripts\activate; macOS and Linux: source .venv/bin/activate. The prompt now starts with (.venv).

  3. 3
    Install

    python -m pip install requests — the package goes into this environment only.

  4. 4
    Record

    python -m pip freeze > requirements.txt writes the exact versions of the installed packages to a file.

  5. 5
    Reproduce

    A teammate creates their own environment and runs python -m pip install -r requirements.txt.

  6. 6
    Leave

    The command deactivate returns you to the normal prompt.

Terminal
$ python -m venv .venv
$ source .venv/bin/activate
(.venv) $ python -m pip list
Package Version
------- -------
pip     25.2
(.venv) $ python -m pip install requests
Collecting requests
...
Installing collected packages: urllib3, idna, charset-normalizer, certifi, requests
Successfully installed certifi-2024.8.30 charset-normalizer-3.4.0 idna-3.10 requests-2.32.3 urllib3-2.2.3
(.venv) $ python -m pip freeze
certifi==2024.8.30
charset-normalizer==3.4.0
idna==3.10
requests==2.32.3
urllib3==2.2.3
A new environment is empty; requests brings its own dependencies along. The version numbers will differ depending on the day you install.

requirements.txt and pyproject.toml

requirements.txt is a simple list: one package per line, optionally with a version specifier. For applications it is common to pin exact versions (==), so that the program runs with the same libraries on every computer:

Text
# requirements.txt
requests==2.32.3
fastapi>=0.110,<1.0
python-dotenv~=1.0
SpecifierMeaning
==2.32.3exactly this version
>=0.110,<1.0a range: 0.110 or newer, but older than 1.0
~=1.4compatible release: >=1.4 and <2.0
(none)any version — risky

The main file of a modern project is **pyproject.toml**: it holds the name, the version, the supported Python versions, the dependencies, tools needed only for development, and command-line scripts. Running python -m pip install -e . in the project folder installs the package in “editable” mode: you do not need to reinstall after every change, and the shop command from [project.scripts] works right away.

Text
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[project]
name = "shop"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["requests>=2.31"]

[project.optional-dependencies]
dev = ["pytest>=8"]

[project.scripts]
shop = "shop.cli:main"
The file pyproject.toml (TOML format). The development tools are installed with python -m pip install -e ".[dev]".
Exercise

Write parse_requirements(text): it returns a dictionary {package: specifier} from the text of a requirements.txt. Skip blank lines and comments starting with #; if there is no specifier, the value is 'any version'.

Exercise · Python
import re

requirements = '''
# web
requests==2.32.3
fastapi>=0.110,<1.0

python-dotenv~=1.0
numpy
'''

def parse_requirements(text):
    result = {}
    # go through the lines, skip blanks and comments,
    # split each line into the package name and the specifier
    return result

for name, spec in parse_requirements(requirements).items():
    print(f'{name}: {spec}')
▸ Expected output
requests: ==2.32.3
fastapi: >=0.110,<1.0
python-dotenv: ~=1.0
numpy: any version
Exercise

Sorting versions as strings is wrong: '0.10.0' ends up before '0.9.3'. Write the function version_key so that the versions are sorted correctly and the newest one is printed.

Exercise · Python
versions = ['0.10.0', '0.9.3', '1.0.0', '0.1.0', '0.9.12']

def version_key(v):
    # turn '0.10.0' into something that compares correctly
    return v

print(sorted(versions))
print(sorted(versions, key=version_key))
print('latest:', max(versions, key=version_key))
▸ Expected output
['0.1.0', '0.10.0', '0.9.12', '0.9.3', '1.0.0']
['0.1.0', '0.9.3', '0.9.12', '0.10.0', '1.0.0']
latest: 1.0.0

Key points

  • A module is a .py file and a package is a folder of modules; a module's code runs once, on the first import, and is cached in sys.modules.
  • __name__ is '__main__' in the file that is run directly; if __name__ == '__main__': keeps script code from running on import.
  • Give every project its own virtual environment: python -m venv .venv, activate it, python -m pip install ....
  • Dependencies are recorded in requirements.txt or pyproject.toml; ==, >=,< and ~= constrain versions.
  • .venv stays out of git: the environment is always recreated from the dependency file.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
What does the command python -m venv .venv do?