Isolate each project's dependencies in a virtual environment, pin them for reproducible installs, and describe your own project in pyproject.toml so it can be installed and published.
The venv lifecycle
$ python3 -m venv .venv # create (once per project; don't commit it)
$ source .venv/bin/activate # activate — Windows: .venv\Scripts\activate
(.venv) $ which python # → /path/to/project/.venv/bin/python
(.venv) $ python -m pip install --upgrade pip
(.venv) $ pip install "requests>=2.31" pytest
(.venv) $ deactivate # back to the system Python
$ rm -rf .venv # environments are disposable: delete & recreateimport sys
print("inside a venv:", sys.prefix != sys.base_prefix) # True inside a venv, False otherwise
print(sys.version_info >= (3, 10)) # Truepip essentials
| Command | Does |
|---|---|
pip install pkg | Install the latest compatible version (plus dependencies) |
pip install "pkg==1.4.2" | Install an exact version |
pip install -U pkg | Upgrade |
pip uninstall pkg | Remove (dependencies stay installed) |
pip list / pip list --outdated | What's installed / what can be upgraded |
pip show pkg | Version, location, dependencies of one package |
pip freeze > requirements.txt | Exact versions of everything installed |
pip install -r requirements.txt | Install from a requirements file |
pip install -e . | Editable install of the current project |
Version specifiers
| Specifier | Means |
|---|---|
==1.4.2 | Exactly 1.4.2 |
>=1.4,<2 | At least 1.4, below 2.0 |
~=1.4.2 | Compatible release: >=1.4.2, ==1.4.* |
~=1.4 | >=1.4, ==1.* (i.e. below 2.0) |
!=1.5.0 | Anything except 1.5.0 |
requests[socks] | Install with an optional extra |
tomli; python_version < "3.11" | Environment marker — only on older Pythons |
requirements.txt vs lock files
pyproject.toml
[project]
name = "lvn-notes"
version = "0.3.0"
description = "Study notes toolkit"
readme = "README.md"
requires-python = ">=3.10"
dependencies = ["requests>=2.31", "rich~=13.7"]
[project.optional-dependencies]
dev = ["pytest>=8", "mypy", "ruff"]
[project.scripts]
lvn = "lvn_notes.cli:main" # installs an `lvn` command
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.ruff]
line-length = 100It's plain TOML, so the standard library can read it:
import tomllib
text = """
[project]
name = "lvn-notes"
requires-python = ">=3.10"
dependencies = ["requests>=2.31", "rich~=13.7"]
[project.scripts]
lvn = "lvn_notes.cli:main"
"""
meta = tomllib.loads(text)["project"]
print(meta["name"], meta["dependencies"]) # lvn-notes ['requests>=2.31', 'rich~=13.7']
print(tomllib.loads(text)["project"]["scripts"]["lvn"]) # lvn_notes.cli:mainProject layout, editable installs & publishing
lvn-notes/
├── pyproject.toml
├── README.md
├── src/
│ └── lvn_notes/
│ ├── __init__.py
│ └── cli.py
└── tests/
└── test_cli.py(.venv) $ pip install -e ".[dev]" # editable: code changes apply without reinstalling
(.venv) $ python -m pip install build twine
(.venv) $ python -m build # → dist/lvn_notes-0.3.0.tar.gz and .whl
(.venv) $ twine upload dist/* # publish to PyPI (use TestPyPI first)| Artifact | What it is |
|---|---|
sdist (.tar.gz) | Source distribution — built on the user's machine |
wheel (.whl) | Pre-built zip that installs by just unpacking — fast; platform-specific if it contains C extensions |
The tool landscape
| Tool | Role |
|---|---|
venv + pip | Built in. Enough for most projects |
| uv | Very fast Rust replacement for pip / venv / pip-tools; manages Python versions and uv.lock (uv add, uv run) |
| Poetry | Dependency management + lock file + build/publish in one tool |
| pipx | Installs CLI applications (black, httpie) each in its own isolated venv |
| conda | Environments with non-Python binaries too (CUDA, MKL) — popular in data science |
| pyenv | Install and switch between several Python versions |
| pip-tools | pip-compile turns loose requirements.in into pinned requirements.txt |
Why use a virtual environment?
What is the difference between pip freeze output and pyproject.toml dependencies?
What does pip install -e . do?