Setting Up Python Projects — uv, pip, and pytest
Published on June 18, 2026
Python has historically had too many ways to manage dependencies. This post covers the modern approach with uv, the pip fallback, and how to run tests with pytest.
Creating a new project with uv
uv is a fast Python package manager (written in Rust by Astral, the team behind Ruff). It replaces pip, venv, pip-tools, and partly poetry with a single tool.
Install uv:
curl -LsSf https://astral.sh/uv/install.sh | sh
# or
pip install uv
Create a new project:
uv init my-project
cd my-project
This scaffolds:
my-project/
├── pyproject.toml # project metadata and dependencies
├── README.md
├── .python-version # pins the Python version for this project
├── .gitignore
└── src/
└── my_project/
└── __init__.py
uv init also creates a virtual environment at .venv/ automatically on first use.
Typical project structure
For anything beyond a one-file script, use the src layout:
my-project/
├── pyproject.toml
├── README.md
├── .gitignore
├── .python-version
├── src/
│ └── my_project/ # your package (underscores, not hyphens)
│ ├── __init__.py
│ ├── main.py
│ ├── models/
│ │ ├── __init__.py
│ │ └── user.py
│ └── services/
│ ├── __init__.py
│ └── auth.py
└── tests/
├── conftest.py # shared fixtures
├── test_main.py
├── models/
│ └── test_user.py
└── services/
└── test_auth.py
Why src/ layout? Without it, import my_project in tests would find the local directory instead of the installed package — which can hide import errors that only show up in production. The src/ layout forces the package to be installed before it can be imported.
Test files mirror the source structure — same as Java/Gradle. src/my_project/models/user.py → tests/models/test_user.py. Pytest discovers test files by looking for files named test_*.py or *_test.py.
Python naming conventions
| Construct | Convention | Example |
|---|---|---|
| Module / file | snake_case |
user_service.py, auth_utils.py |
| Package / directory | snake_case |
my_project/, http_client/ |
| Class | PascalCase |
UserService, HttpClient |
| Function / method | snake_case |
get_user(), parse_response() |
| Variable | snake_case |
user_id, max_retries |
| Constant | UPPER_SNAKE_CASE |
MAX_CONNECTIONS, DEFAULT_TIMEOUT |
| Private method / attribute | _leading_underscore |
_validate(), _cache |
| Name-mangled (class-private) | __double_leading |
__secret, __init_state() |
| Type alias | PascalCase |
UserId = int, ResponseMap = dict[str, Any] |
| Dunder / magic method | __double_both__ |
__init__, __repr__, __len__ |
These follow PEP 8, the official Python style guide.
Google’s Python conventions
Google’s Python Style Guide builds on PEP 8 but adds a few opinions of its own. These are the ones worth internalizing:
Naming, the Google way
Google spells out the casing rules explicitly. They line up with PEP 8 above, but the guide phrases them as a table you can memorize:
| Type | Convention | Example |
|---|---|---|
| Module | lower_with_under |
socket_server |
| Package | lower_with_under |
my_package |
| Class / Exception | CapWords (PascalCase) |
HttpClient, ValueError |
| Function / Method | lower_with_under() |
send_request() |
| Global / Class constant | CAPS_WITH_UNDER |
MAX_RETRIES |
| Variable / Parameter | lower_with_under |
retry_count |
| Instance var (public) | lower_with_under |
self.user_id |
| Instance var (protected) | _lower_with_under |
self._cache |
Google explicitly avoids __double_leading_underscore for “private” attributes — they prefer a single underscore, because name mangling is rarely worth the friction.
Things Google is opinionated about
- No single-character names except for counters/iterators (
i,j),einexceptclauses, andffor file handles. Avoidl,O,I— they look like1and0. - No “dunder” naming for your own modules — names like
__author__are discouraged. - Prefer descriptive names over abbreviations.
error_count, noterr_cnt. CapWordsfor class names even when they’re acronyms —HttpServer, notHTTPServer.- Module names match the file name — keep them short and
lower_with_under. - Use one statement per line, and keep lines ≤ 80 chars (PEP 8 allows 79; Google says 80).
Docstrings
Google has a distinctive docstring style — sectioned with Args:, Returns:, Raises::
def fetch_user(user_id: int, *, retries: int = 3) -> User:
"""Fetches a user by ID.
Args:
user_id: The unique identifier of the user.
retries: Number of times to retry on failure.
Returns:
The User object matching the given ID.
Raises:
UserNotFoundError: If no user exists with that ID.
"""
This is the “Google style” you’ll see picked up by tools like Sphinx’s Napoleon extension. The alternative is NumPy or reST style — pick one and stay consistent across the project.
pyproject.toml — the modern config file
pyproject.toml is the single file for project metadata, dependencies, and tool config. It replaces setup.py, setup.cfg, and requirements.txt for most purposes.
[project]
name = "my-project"
version = "0.1.0"
description = "A short description"
requires-python = ">=3.11"
dependencies = [
"requests>=2.31.0",
"pydantic>=2.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov",
"ruff",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.pytest.ini_options]
testpaths = ["tests"]
Virtual environments — why they matter
A virtual environment is an isolated Python installation for your project. Without one, every project on your machine shares the same global packages — version conflicts are inevitable.
# Create a venv manually
python -m venv .venv
# Activate it (Linux/Mac)
source .venv/bin/activate
# Activate it (Windows)
.venv\Scripts\activate
# You're now in the venv — pip install goes here, not system-wide
(my-project) $ pip install requests
uv manages the venv for you automatically — you rarely need to activate it manually.
Managing dependencies with uv
Add a dependency
uv add requests # adds to [project.dependencies] in pyproject.toml
uv add pytest --dev # adds to dev dependencies
uv add "pydantic>=2.0" # with version constraint
uv add also updates uv.lock — the lockfile that pins every dependency’s exact version.
Install all dependencies (e.g. after cloning a repo)
uv sync # installs everything in pyproject.toml
uv sync --dev # includes dev dependencies too
Remove a dependency
uv remove requests
Run a command inside the project environment
uv run python src/my_project/main.py
uv run pytest
uv run automatically uses the project’s venv without needing to activate it first.
Show installed packages
uv pip list
Managing dependencies with pip (the fallback)
If you’re not using uv, the traditional approach uses pip and a requirements.txt file.
# Create and activate a venv
python -m venv .venv
source .venv/bin/activate
# Install packages
pip install requests pydantic
# Save current environment to a file
pip freeze > requirements.txt
# Install from requirements file (e.g. after cloning)
pip install -r requirements.txt
# Install dev dependencies separately
pip install -r requirements-dev.txt
A typical requirements-dev.txt:
-r requirements.txt # include production deps
pytest>=8.0
pytest-cov
ruff
pip freeze vs pyproject.toml: pip freeze pins every transitive dependency including ones you didn’t explicitly ask for, which makes upgrades painful. pyproject.toml with uv.lock is cleaner — you declare direct dependencies, the lockfile handles the rest.
Running tests with pytest
Install pytest:
uv add pytest --dev
# or
pip install pytest
Basic usage
# Run all tests
pytest
# With verbose output (shows each test name)
pytest -v
# Run a specific file
pytest tests/test_main.py
# Run a specific test function
pytest tests/test_main.py::test_add
# Run a specific test class
pytest tests/test_main.py::TestCalculator
# Run a specific method inside a class
pytest tests/test_main.py::TestCalculator::test_add
Filter by name with -k
# Run tests whose name contains "auth"
pytest -k "auth"
# Run tests matching an expression
pytest -k "auth or login"
pytest -k "not slow"
Failure output
# Short traceback (default)
pytest
# Full traceback
pytest --tb=long
# Just the error line, no traceback
pytest --tb=line
# Stop after first failure
pytest -x
# Stop after 3 failures
pytest --maxfail=3
Coverage
uv add pytest-cov --dev
pytest --cov=src/my_project # coverage for your package
pytest --cov=src/my_project --cov-report=html # generates htmlcov/index.html
Writing tests
Pytest doesn’t require test classes — plain functions work fine.
# tests/test_math.py
def test_add():
assert 1 + 1 == 2
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
1 / 0
Fixtures — shared setup and teardown
Fixtures are functions that provide data or resources to tests. Pytest injects them by parameter name.
# tests/conftest.py — fixtures here are available to all test files
import pytest
from my_project.models.user import User
@pytest.fixture
def sample_user():
return User(name="Alice", age=30)
@pytest.fixture
def db_connection():
conn = create_test_db()
yield conn # test runs here
conn.close() # teardown runs after the test
# tests/test_user.py
def test_user_name(sample_user): # pytest injects sample_user automatically
assert sample_user.name == "Alice"
def test_user_age(sample_user):
assert sample_user.age == 30
Parametrize — run one test with multiple inputs
import pytest
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
])
def test_add(a, b, expected):
assert a + b == expected
This runs test_add three times with different inputs and reports each separately.
How Python import resolution actually works (and why src/ requires installing)
When you write from cat_images.client import CatClient, Python takes the first segment cat_images and looks for a directory with that exact name inside each entry in sys.path. It does not search recursively.
So if your project root is in sys.path, Python looks for <project-root>/cat_images/ — which doesn’t exist. The actual package lives at <project-root>/src/cat_images/. That src/ layer in between is what breaks the import.
Why from src.cat_images.client import ... works without installing
Because src is a real directory sitting directly in the project root. Python finds src/ → cat_images/ → client.py by traversing the import path segments. It’s treating src as a plain namespace, not a package boundary. It works, but it’s an ugly import path and the wrong approach.
Why uv pip install -e . fixes it
An editable install reads pyproject.toml, sees the package source is in src/, and registers src/ itself as a path in the venv’s site-packages. Now Python can find cat_images directly. This is the intended workflow for the src/ layout — it forces you to install before importing as a proper package, which prevents accidentally importing from your working tree instead of the installed package.
Why VSCode still warns after installing
Pylance (VSCode’s type checker) does static analysis — it doesn’t execute the venv to discover editable install paths the way the Python runtime does. You need to tell it explicitly where to look. Add this to .vscode/settings.json:
{
"python.analysis.extraPaths": ["src"]
}
This tells Pylance to also look in src/ when resolving imports, matching what the editable install does at runtime.
uv vs pip — when to use which
uv |
pip |
|
|---|---|---|
| Speed | Very fast (written in Rust) | Slower |
| Lockfile | uv.lock — exact reproducible installs |
None (use pip freeze) |
| Config file | pyproject.toml |
requirements.txt |
| Virtual env | Managed automatically | Manual (python -m venv) |
| Best for | New projects, teams | Legacy projects, simple scripts |
For new projects, start with uv. For existing projects using requirements.txt, pip works fine — no need to migrate unless you want to.
Quick reference
| Task | uv |
pip |
|---|---|---|
| Create project | uv init my-project |
mkdir my-project && python -m venv .venv |
| Add dependency | uv add requests |
pip install requests |
| Add dev dependency | uv add pytest --dev |
pip install pytest |
| Install from lockfile | uv sync |
pip install -r requirements.txt |
| Run a command | uv run python main.py |
source .venv/bin/activate && python main.py |
| Run tests | uv run pytest |
pytest (inside activated venv) |
| Run specific test | uv run pytest tests/test_foo.py::test_bar |
pytest tests/test_foo.py::test_bar |
| Verbose test output | pytest -v |
same |
| Stop on first failure | pytest -x |
same |
Tags: python, uv, pip, pytest, project-setup