Setting Up Python Projects — uv, pip, and pytest

Published on June 19, 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

Notice there’s no .venv/ yet. That’s by design — uv init only writes files (pyproject.toml, main.py, README.md, .python-version) and runs git init. It never resolves or installs anything, so there’s nothing to put in an environment yet.

uv creates .venv lazily, on the first command that actually needs one:

uv venv          # explicit — creates .venv right now
uv run main.py   # creates .venv on demand, then runs
uv add pytest    # creates .venv, then installs
uv sync          # creates .venv from the lockfile

So it’s not that the venv appears automatically when you init — it appears the first time you ask uv to do something that requires an interpreter and packages.


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.pytests/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), e in except clauses, and f for file handles. Avoid l, O, I — they look like 1 and 0.
  • No “dunder” naming for your own modules — names like __author__ are discouraged.
  • Prefer descriptive names over abbreviations. error_count, not err_cnt.
  • CapWords for class names even when they’re acronymsHttpServer, not HTTPServer.
  • 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.

VSCode + Jupyter notebooks: kernels and venvs

When you open a Jupyter notebook in VSCode, it asks you to “select a kernel.” This is slightly confusing because two separate things are involved.

What a kernel actually is

A Jupyter kernel is a background process that receives code from the notebook, executes it, and sends results back. It is described by a kernel.json spec file, something like:

{
  "argv": ["/home/user/projects/ml/.venv/bin/python3.12", "-m", "ipykernel_launcher", "-f", "{connection_file}"],
  "display_name": "Python 3.12 (.venv)",
  "language": "python"
}

The argv field is the key — it points to a Python executable. That executable determines which packages the kernel can see.

So are they the same thing?

No — the kernel and the venv are different, but tightly linked:

  • The venv is the Python environment — the interpreter plus all installed packages under .venv/.
  • The kernel is a running process that uses one specific Python executable.
  • When you point a kernel at .venv/bin/python3.12, the kernel process runs inside that venv — it can import any package installed there.

The venv doesn’t know it’s being used by a kernel. The kernel just happens to use the venv’s Python.

How to connect a notebook to your venv in VSCode

Click “Select Kernel” → “Python Environments” → and either pick the venv VSCode detected automatically, or choose “Enter interpreter path” and paste the full path:

~/projects/ml/kaggle/stellar-class/.venv/bin/python3.12

Use the full path because ~ expansion can be unreliable in some VSCode picker inputs — use the absolute path if the tilde form doesn’t work:

/home/youruser/projects/ml/kaggle/stellar-class/.venv/bin/python3.12

VSCode then launches a kernel process using that Python, giving the notebook access to everything installed in that venv.

Good to know: register the venv as a named kernel

Instead of pasting paths every time, register your venv as a proper kernel once:

# activate the venv first, or use its Python directly
.venv/bin/python -m ipykernel install --user --name stellar-class --display-name "Python (stellar-class)"

After this, “Python (stellar-class)” appears in the kernel picker automatically — no path needed. The kernel spec is stored at ~/.local/share/jupyter/kernels/stellar-class/kernel.json.

Why VSCode sometimes doesn’t auto-detect your venv

VSCode scans a few standard locations for venvs (the workspace root, ~/.virtualenvs, etc.). If your venv lives somewhere else, it won’t appear in the list. Pasting the full path or registering via ipykernel install are both reliable workarounds.


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

One naming trap first: it’s uv run pytest, not uv run test. uv run executes a command inside the project venv, so test means nothing unless you’ve defined a script by that name.

How pytest finds your tests

You never register anything. Pytest walks the directory tree and collects by name:

  • files matching test_*.py
  • classes matching Test* — and they must not define __init__
  • methods and functions matching test_*

That last rule about __init__ catches people coming from unittest. A pytest test class isn’t an object you construct; it’s just a namespace for grouping related tests. If you give it a constructor, pytest silently skips the whole class with a warning.

# tests/test_multidict.py

import pytest                       # not collected — not a test
from my_project import MultiDict    # not collected

@pytest.fixture                     # not collected — it's a fixture
def md():
    return MultiDict([("a", 1), ("b", 2), ("a", 3)])

class TestConstruction:             # collected: name starts with Test, no __init__
    def test_from_pairs(self, md):  # collected: name starts with test_
        assert len(md) == 3

class TestLookup:                   # collected
    def test_getitem_returns_first_value(self, md):
        assert md["a"] == 1

TestConstruction and TestLookup aren’t special types you inherit from — they are plain classes that happen to be named Test*. Everything else in the file (the imports, the md fixture) is just module-level code that pytest loads but doesn’t run as a test.

Running pytest tests/ runs all of them. Each test runs independently: they share no state, and one failing doesn’t stop the rest.

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

The part that matters most: the fixture re-runs for every test that asks for it. Each test gets a fresh object, not a shared one. Stick a print in a fixture and you can watch it happen:

>>> fixture ran, building a fresh MultiDict
test_one sees: [('a', 1), ('a', 999)]     <- test_one mutated it
>>> fixture ran, building a fresh MultiDict
test_two sees: [('a', 1)]                 <- test_two is unaffected

That’s why a test class can freely del md["a"] or call md.clear() without wrecking the test that runs next. (If you want one shared instance, that’s what @pytest.fixture(scope="module") and scope="session" are for.)

Where you define a fixture decides who can see it:

  • inside a test class — only that class’s tests
  • at module level in a test file — every test in that file
  • in tests/conftest.py — every test file in the directory and below

Plain assert — no assertEqual needed

Pytest rewrites the bytecode of your assert statements so a failure reports what the values actually were, which is why you never need unittest’s assertEqual family:

>       assert md.keys() == ["a", "b", "b"]
E       AssertionError: assert ['a', 'b', 'a'] == ['a', 'b', 'b']
E         At index 2 diff: 'a' != 'b'

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.

A parametrized test can take fixtures too — pytest matches arguments by name, so it works out which is which:

@pytest.mark.parametrize("key", ["Content-Type", "content-type", "CONTENT-TYPE"])
def test_lookup_ignores_case(self, ci, key):   # ci = fixture, key = parameter
    assert ci[key] == "json"

Finally, if you get tired of typing a path every time, the [tool.pytest.ini_options] block back in pyproject.toml is where testpaths = ["tests"] lives — with that set, a bare uv run pytest knows where to look.


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