Skip to content

11 - Modules, Packages, pip, venv

What this session is

About an hour. You'll learn how Python code is organized (modules and packages), how to use code other people wrote (pip and PyPI), how virtual environments isolate dependencies, and what pyproject.toml is for. This is the page that bridges you from "I write standalone scripts" to "I work with real codebases."

A module is a file

Any .py file IS a module. The filename (without .py) is the module name. To use code from another file, import it.

Make a folder ~/code/python-learning/greetapp/. Inside, two files:

greet.py:

def hello(name):
    return f"Hello, {name}!"

def _internal(name):
    return f"(internal) {name}"

main.py:

import greet

print(greet.hello("Alice"))         # Hello, Alice!
print(greet._internal("Alice"))     # works, but you shouldn't (see below)

Run from the greetapp/ folder:

python main.py

Three import shapes you'll see:

import greet                              # use as greet.hello
from greet import hello                   # use directly as hello
from greet import hello as say_hi         # rename on import
from greet import *                       # import everything (avoid)

The * form pulls in everything not starting with _ (and pollutes your namespace). Avoid it in real code; you'll see it occasionally in scripts.

The leading-underscore convention

Names starting with _ (one underscore) are conventionally private - "internal use, don't touch from outside." Python doesn't enforce this; it's a contract.

def public_thing():       # use freely
    pass

def _internal_thing():    # "don't use from outside this module"
    pass

Names with __ (two underscores) at the start of a class trigger name mangling - Python rewrites them to discourage external access. You don't need to write __names yourself for a while.

The slogan: "we're all consenting adults." Python trusts you to respect the contract.

A package is a folder of modules

When you have several related modules, group them in a folder. Add an __init__.py to make it a package:

greetapp/
├── main.py
└── greet/
    ├── __init__.py
    ├── english.py
    └── yoruba.py

greet/english.py:

def hello(name):
    return f"Hello, {name}!"

greet/yoruba.py:

def hello(name):
    return f"Bawo ni, {name}!"

greet/__init__.py (can be empty, or can re-export):

from .english import hello as hello_english
from .yoruba import hello as hello_yoruba

main.py:

from greet import hello_english, hello_yoruba

print(hello_english("Alice"))     # Hello, Alice!
print(hello_yoruba("Alice"))      # Bawo ni, Alice!

The .english in the __init__.py is a relative import - "from this package's english module." Use relative imports inside packages; absolute imports (from greetapp.greet.english import ...) work too, but get verbose.

Modern Python (3.3+) actually allows packages without __init__.py ("namespace packages") - but writing an __init__.py is still the safer, more explicit choice.

pip and PyPI

PyPI (Python Package Index, pypi.org) hosts hundreds of thousands of third-party libraries. pip is the tool that installs them.

Inside your active venv (you remembered to activate, right?):

pip install requests

That downloads requests (a popular HTTP library) and its dependencies into your venv. Now you can:

import requests
response = requests.get("https://api.github.com/users/octocat")
print(response.json()["name"])

Useful pip commands:

Command What it does
pip install <pkg> Install a package.
pip install <pkg>==1.2.3 Pin to a specific version.
pip install -U <pkg> Upgrade to latest.
pip install -r requirements.txt Install from a requirements file.
pip list List installed packages.
pip show <pkg> Show details (version, location, dependencies).
pip uninstall <pkg> Remove a package.
pip freeze Print all installed packages with exact versions (suitable as a requirements.txt).

pip freeze > requirements.txt is the old way to capture exact versions for reproducibility. Modern projects use pyproject.toml + a lockfile instead.

requirements.txt: the traditional way

A simple text file listing dependencies:

requests>=2.31.0
pytest>=8.0.0
httpx

Install everything with pip install -r requirements.txt. Common in older projects and quick scripts.

pyproject.toml: the modern way

Modern Python projects use a single pyproject.toml file at the project root for everything: dependencies, build configuration, tool settings.

[project]
name = "myapp"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
    "requests>=2.31.0",
    "httpx",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.0.0",
    "ruff",
    "mypy",
]

With this:

pip install -e .          # install your project + its dependencies
pip install -e .[dev]     # also install dev dependencies

The -e (editable) install means: install in development mode - Python imports your local code, so edits show up immediately without reinstalling.

uv: the modern alternative to pip

uv (astral.sh/uv) is a Rust-implemented replacement for pip + venv + a lot more. ~10-100× faster than pip. The 2026 default for new Python projects.

pip install uv
uv venv                     # create .venv
source .venv/bin/activate
uv pip install requests     # install (much faster than pip)
uv pip install -r requirements.txt
uv add httpx                # add to pyproject.toml + install
uv lock                     # generate lockfile
uv sync                     # install exactly what's in the lockfile

If you start a new project, use uv. If you're working in an existing project that uses pip, keep using pip - don't mix tools mid-project.

Lockfiles

A lockfile records the exact version of every direct and transitive dependency. Reproducible installs: if I have your lockfile, I get exactly the same package versions you have.

Tools that produce lockfiles: - pip-tools (pip-compilerequirements.txt with pinned versions). - poetry (poetry.lock). - uv (uv.lock). - pipenv (Pipfile.lock) - older, less used now.

For applications (something you deploy): always use a lockfile. For libraries (something other people import): don't ship a lockfile; let downstream users resolve.

Standard project layout

A typical small Python project:

myapp/
├── README.md
├── LICENSE
├── pyproject.toml
├── src/
│   └── myapp/
│       ├── __init__.py
│       ├── core.py
│       └── cli.py
├── tests/
│   ├── test_core.py
│   └── test_cli.py
└── .gitignore

The src/myapp/ layout (vs putting myapp/ at the top level) prevents a class of import bugs and is the modern recommendation. Old projects often have myapp/ at the top.

Exercise

Two parts.

Part 1 - your own multi-module package:

  1. Create ~/code/python-learning/bank/. cd in. Create a venv.

  2. Create a package:

    mkdir bank
    touch bank/__init__.py
    touch bank/account.py
    

  3. In bank/account.py, define a dataclass Account with owner and balance fields, plus deposit(amount) and withdraw(amount) methods (page 05).

  4. Create main.py at the top level that imports from bank.account import Account, creates one, deposits and withdraws.

  5. Run python main.py.

Part 2 - install and use a third-party library:

  1. Activate the same venv.
  2. pip install requests.
  3. Write a script that fetches https://api.github.com/users/octocat and prints the user's name and bio.
  4. pip freeze > requirements.txt. Open the file. Find the requests line and its version.
  5. Stretch: create a pyproject.toml with requests as a dependency. pip install -e .. Confirm it works.

What you might wonder

"Why do I need both requests and httpx libraries?" requests is the venerable HTTP client - battle-tested, sync only. httpx is the modern one - sync + async, HTTP/2, mostly drop-in compatible. New projects often pick httpx; existing projects keep requests.

"What's the difference between a script and a package?" A script is a single .py file you run. A package is a structured folder you import. Scripts grow into packages when they become unwieldy.

"Why so many ways to manage dependencies?" Python's packaging history is messy. Each generation tried to fix the last. pip → pip + virtualenv → pipenv → poetry → uv. As of 2026, uv is the front-runner; the others still work.

"What if I pip install something globally by accident?" Probably fine; uninstall it (pip uninstall <pkg>) and try again in a venv. Sometimes you'll need --user or sudo issues on Linux. The fix is always: activate a venv first.

"What's conda / Anaconda?" A separate package manager popular in scientific computing. Manages Python itself + non-Python dependencies (C libraries). Different ecosystem from pip/PyPI. If you'll do heavy data-science work involving compiled scientific libraries (NumPy, scikit-learn, JAX), conda is sometimes easier; otherwise stick with pip/uv.

Done

You can now: - Split code across modules (.py files) and import between them. - Group modules into packages with __init__.py. - Install third-party libraries with pip (or uv). - Use requirements.txt or pyproject.toml for dependency declarations. - Recognize the standard project layout. - Know the convention: leading _ is "internal."

You've now covered every fundamental Python concept needed to read and write real codebases. The remaining pages are about applying them - reading real OSS code, picking a project, contributing.

Next: Reading other people's code →

Comments