Skip to main content
Updated Feb 24, 2026

The pyproject.toml and the Discipline Stack

In Lesson 2, James installed uv and created the SmartNotes project with uv init smartnotes. He has a clean project directory with five files. But the project cannot do much yet. It has no linter, no type checker, and no test framework. The pyproject.toml that uv generated is minimal -- it knows the project name and Python version, but nothing about code quality. James needs to install the discipline stack: pytest, pyright, and ruff. The question is how.

James opens his terminal inside the smartnotes directory. His instinct says pip install pytest. That is what every tutorial he has ever read tells him to do. Emma stops him before he presses Enter.

"You already have uv," she says. "Use it."

James replaces the command: uv add --dev pytest pyright ruff. Three tools, one command, under a second. He opens pyproject.toml and sees all three listed in a new section called [dependency-groups]. Then Emma shows him something he did not expect. She adds configuration sections for each tool -- pyright strictness, ruff rules, pytest test paths -- all inside the same file. James stares at it. "Everything I need to know about SmartNotes is right here?" Emma nods. "One file. Every dependency, every tool setting, every project detail. That is what a central configuration gives you."

The Problem Without a Central Config

Before pyproject.toml became the standard, a typical Python project scattered its configuration across half a dozen files:

WhatWhere It LivedProblem
Dependenciesrequirements.txtNo lockfile, no distinction between dev and production deps
Linter config.flake8 or setup.cfgSeparate file, separate syntax, easy to forget
Formatter configpyproject.toml or .black.tomlSome tools supported pyproject.toml, others did not
Type checker configpyrightconfig.jsonJSON file, different format from everything else
Test configpytest.ini or setup.cfgYet another file, yet another format
Build systemsetup.py or setup.cfgPython script that ran arbitrary code during installation

A new developer joining the project had to find and read six files in three different formats to understand how the project was configured. An AI assistant trying to understand the project faced the same problem -- it had to parse JSON, INI, TOML, and Python, four different formats with four different rules. And the worst part: none of these files knew about each other. A version constraint in requirements.txt could contradict a version constraint in setup.cfg. Nothing would catch the conflict until deployment failed.

This is the world James narrowly avoided by not typing pip install pytest.

pyproject.toml Defined

pyproject.toml is the single configuration file for a Python project. It declares the project's identity (name, version, Python version), its dependencies, and the configuration for every tool in the development stack. One file replaces six. One format replaces four.

The name is precise: pyproject means "Python project" and .toml is the format -- Tom's Obvious Minimal Language, a configuration format designed to be easy for humans to read and easy for machines to parse. It has the structured precision of JSON without the visual clutter of braces and quotes.

If you're new to programming

TOML is just a way to write settings in a file. Each line is a label and a value, like filling out a form. Section headers in square brackets ([project], [tool.ruff]) group related settings together. You do not need to memorize the format -- just recognize the pattern of key = value inside named sections.

After running uv init smartnotes in the previous lesson, your pyproject.toml looks like this:

[project]
name = "smartnotes"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []

This is the project's identity card. Every field answers a specific question:

FieldWhat It AnswersExample Value
nameWhat is this project called?"smartnotes"
versionWhat version is it?"0.1.0"
descriptionWhat does it do? (one sentence)"A personal note-taking assistant"
readmeWhere is the detailed description?"README.md"
requires-pythonWhat Python versions are supported?">=3.12"
dependenciesWhat packages does the project need to run?[] (empty for now)

This is the [project] section. It follows a standard called PEP 621 — a set of rules that the Python community agreed on so that every tool reads project files the same way. Before this standard, different tools like Poetry, Hatch, and pip each had their own format, and switching between them meant rewriting your configuration. With PEP 621, you write your project details once and every tool understands it — no matter which tool you or your team prefers.

From Scattered Files to One Source of Truth

Consider what happens when you add a tool to your project. Without a central config, that knowledge splits: the dependency goes in one file, the configuration goes in another, and the version constraint goes in a third. Three files, three formats, one decision. With pyproject.toml, the dependency, the configuration, and the version constraint all live in one file. A human developer opens one file and sees everything. An AI agent reads one file and understands the entire project setup. Git tracks one file and shows every configuration change in a single diff.

Practical Application

Installing the Discipline Stack

Open your terminal inside the smartnotes directory and run:

uv add --dev pytest pyright ruff

Output:

Resolved 9 packages in 215ms
Prepared 9 packages in 163ms
Installed 9 packages in 28ms
+ iniconfig==2.1.0
+ nodeenv==1.9.1
+ packaging==24.2
+ pluggy==1.5.0
+ pyright==1.1.408
+ pytest==9.0.2
+ ruff==0.15.2
+ tomli==2.2.1
+ typing-extensions==4.12.2

Three tools and their transitive dependencies (packages that your packages depend on), installed in under a second. Your version numbers will likely differ from the ones shown here -- tools release frequently, so newer versions are normal. The workflow is the same regardless of the exact version numbers.

The --dev flag is important: it tells uv these tools are for development, not for running the application in production. A user installing your SmartNotes app does not need ruff or pytest -- those are for you, the developer.

What uv add --dev Actually Does

This single command performs three operations simultaneously:

StepWhat HappensFile Modified
1Records the dependencypyproject.toml updated with [dependency-groups]
2Locks exact versionsuv.lock created or updated
3Installs into environment.venv/ synced with new packages

This is the "three steps in one" model. With pip, you would need to run pip install, then manually update requirements.txt, then hope your colleague installs the same versions. With uv, the config file, the lockfile, and the virtual environment are always in sync. One command, three results, zero drift.

Quick Check: If a teammate runs uv sync on their machine after you committed pyproject.toml and uv.lock, will they get the exact same package versions you have? Which of the two files guarantees this?

Examining pyproject.toml After Installation

Open pyproject.toml again. A new section has appeared:

[project]
name = "smartnotes"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []

[dependency-groups]
dev = ["pytest>=9.0.2", "pyright>=1.1.408", "ruff>=0.15.2"]

The [dependency-groups] section is where development dependencies live. The dev group contains your three tools with minimum version constraints. This section follows PEP 735, a standard that allows projects to define named groups of dependencies for different purposes (development, testing, documentation).

The Lockfile: uv.lock

Look at the new uv.lock file that uv created. This file records the exact version of every package installed, including transitive dependencies. While pyproject.toml says "pytest>=9.0.2" (any version 9.0.2 or higher), the lockfile says exactly which version was resolved.

The lockfile is a cross-platform reproducibility guarantee. When another developer runs uv sync on their machine -- whether that machine runs Windows, macOS, or Linux -- they get the exact same package versions. No "it works on my machine" surprises.

Rule: Commit uv.lock to Git. It belongs in version control alongside pyproject.toml.

Adding Tool Configuration Sections

The discipline stack is installed. Now each tool needs to know how to behave. Instead of creating separate config files for each tool, you add configuration sections directly to pyproject.toml.

Add the following sections after the [dependency-groups] block:

Pyright configuration:

[tool.pyright]
typeCheckingMode = "strict"
pythonVersion = "3.12"

Python lets you add type annotations — labels that say what kind of data a variable holds or what a function expects and returns. For example, name: str says "name is text" and age: int says "age is a whole number." You will learn how to write these annotations in later chapters. For now, just know that pyright reads them and warns you when something does not match — like passing text where a number is expected. "strict" mode means pyright checks everything thoroughly, and pythonVersion = "3.12" tells it which version of Python your project uses.

Ruff configuration:

[tool.ruff]
line-length = 88
target-version = "py312"

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]

[tool.ruff.format]
quote-style = "double"
indent-style = "space"

Ruff does two jobs: linting (finding mistakes and bad habits in your code) and formatting (making your code look consistent). line-length = 88 means no line of code should be longer than 88 characters — long lines are hard to read. target-version = "py312" tells ruff your project uses Python 3.12.

The select list tells ruff which categories of problems to look for. Each letter is a code for a category:

CodeNameWhat It Catches
"E"pycodestyle ErrorsBasic style problems — wrong spacing, bad indentation
"F"pyFlakesReal bugs — unused imports, undefined variables, code that cannot run
"I"isortImport ordering — keeps your import lines organized alphabetically
"UP"pyUpgradeOld Python syntax — suggests modern replacements available in Python 3.12
"B"BugbearSubtle bugs — common mistakes that Python allows but usually cause problems
"SIM"SimplifyUnnecessary complexity — code that can be written in a simpler way

The [tool.ruff.format] section controls how ruff formats your code: double quotes around strings ("hello" not 'hello') and spaces for indentation (not tabs).

Pytest configuration:

[tool.pytest.ini_options]
addopts = "-ra -q"
testpaths = ["tests"]

Pytest is the tool that runs your tests — small checks you write to prove your code does what it should. addopts sets default options that apply every time you run tests: -ra means "after all tests finish, show a summary of which ones passed and which failed," and -q means "keep the output short instead of printing every detail." testpaths = ["tests"] tells pytest where to find your test files — in a folder called tests/ inside your project.

The Complete pyproject.toml

After adding all tool configurations, your SmartNotes pyproject.toml looks like this:

[project]
name = "smartnotes"
version = "0.1.0"
description = "A personal note-taking assistant"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []

[dependency-groups]
dev = ["pytest>=9.0.2", "pyright>=1.1.408", "ruff>=0.15.2"]

[tool.pyright]
typeCheckingMode = "strict"
pythonVersion = "3.12"

[tool.ruff]
line-length = 88
target-version = "py312"

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]

[tool.ruff.format]
quote-style = "double"
indent-style = "space"

[tool.pytest.ini_options]
addopts = "-ra -q"
testpaths = ["tests"]

One file. Four tools configured. Every developer and every AI agent reading this file knows exactly how SmartNotes is built, what tools it uses, and how those tools are configured.

Checkpoint: Your SmartNotes project should now look like this
smartnotes/
├── .gitignore
├── .python-version
├── .venv/
├── README.md
├── main.py
├── pyproject.toml ← now has [dependency-groups] and [tool.*] sections
└── uv.lock ← updated with exact versions of pytest, pyright, ruff

The file list has not changed since Lesson 2. What changed is the content of pyproject.toml (new sections) and uv.lock (new dependency versions).

Read and Predict: Look at the pyproject.toml above and answer these questions:

  1. What happens if you try to use Python 3.11 with this project?
  2. Which ruff rules are enabled? What does the "F" prefix catch?
  3. Where will pytest look for test files?

Section Map

For reference, here is every section in the final pyproject.toml and what it controls:

SectionPurposeStandard
[project]Project identity: name, version, Python version, dependenciesPEP 621
[dependency-groups]Development-only dependencies grouped by purposePEP 735
[tool.pyright]Type checker configuration: strictness, Python versionPyright-specific
[tool.ruff]Linter/formatter base settings: line length, target versionRuff-specific
[tool.ruff.lint]Linting rule selectionRuff-specific
[tool.ruff.format]Formatting preferences: quotes, indentationRuff-specific
[tool.pytest.ini_options]Test runner configuration: options, test pathsPytest-specific

The [project] and [dependency-groups] sections follow Python standards that all tools agree on. The [tool.*] sections follow a convention: each tool gets its own namespace under [tool], so configurations never collide.

Anti-Patterns

James now understands why Emma stopped him from typing pip install pytest. Here are the patterns Emma warned him to avoid:

Anti-PatternWhat HappensThe Fix
requirements.txt without a lockfileDifferent developers get different package versions; builds break randomlyUse uv add which generates uv.lock automatically
pip install globallyPackages conflict across projects; one project's upgrade breaks anotherUse uv add which installs into the project's .venv automatically
Separate config files per tool.flake8, pyrightconfig.json, pytest.ini scattered across the root directory; new developers miss config filesPut all tool config in pyproject.toml under [tool.*] sections
Not pinning the Python versionCode works on 3.12 locally, breaks on 3.10 in CISet requires-python in [project] and .python-version in the root

Every anti-pattern shares a root cause: configuration scattered across multiple locations instead of centralized in one file. The fix is always the same: pyproject.toml.

Try With AI

Prompt 1: Understand Each Section

Here is my pyproject.toml file:

[paste your complete pyproject.toml from this lesson]

Explain each section in plain language. For every section, tell me:
1. What it controls
2. What would happen if I deleted it
3. One thing I could change and what the effect would be

What you're learning: How to read a configuration file as a knowledge document. You are building the ability to treat pyproject.toml as a readable specification of your project -- not a mysterious file that tools generate and humans ignore.

Prompt 2: Generate Tool Configuration

I have a Python project using uv with pytest, pyright, and ruff installed
as dev dependencies. I want to configure ruff to also check for:
- Security vulnerabilities (the S rules from flake8-bandit)
- Print statements left in code (the T20 rules)
- Naming conventions (the N rules from pep8-naming)

Show me the updated [tool.ruff.lint] section and explain what each
new rule prefix catches. Also tell me: is there a downside to enabling
too many rules at once?

What you're learning: How to extend tool configurations beyond the defaults. You are practicing the skill of treating configuration as a design decision -- choosing which rules to enforce based on your project's needs, not just copying a config from the internet.

Prompt 3: Inspect the Lockfile, Then Ask AI

Before prompting, open uv.lock in your editor and look at the first 20-30 lines. You will see version numbers, package names, and hashes that look nothing like the clean pyproject.toml you wrote. Now ask AI:

I opened my uv.lock file and it looks very different from my
pyproject.toml. My pyproject.toml says pytest>=9.0.2 but uv.lock
has much more detail for every package.

1. Why do I need both pyproject.toml AND uv.lock?
2. What information does uv.lock contain that pyproject.toml does not?
3. Should I commit uv.lock to Git? Why or why not?
4. What happens if I delete uv.lock and run uv sync?
5. How does uv.lock help when my teammate uses Windows and I use macOS?

Explain each answer using my project (smartnotes) as the example.

What you're learning: You are seeing the difference between a specification and a resolution firsthand. Your pyproject.toml says "I need pytest 9 or higher" (the specification). Your uv.lock says "I am using pytest 9.0.2 specifically, with these exact transitive dependencies" (the resolution). You saw this difference in the files before asking AI to explain it -- which means the explanation will stick.

Key Takeaways

  1. pyproject.toml is the single source of truth for your Python project. It replaces requirements.txt, setup.py, .flake8, pyrightconfig.json, and pytest.ini with one file in one format.

  2. uv add --dev performs three operations in one command: it updates pyproject.toml, generates uv.lock, and syncs the virtual environment. Config, lockfile, and environment are always in sync.

  3. [dependency-groups] separates dev tools from production dependencies. Users of your application do not need pytest, pyright, or ruff -- those are for developers only.

  4. uv.lock guarantees reproducibility across platforms. While pyproject.toml specifies version ranges, the lockfile records exact versions. Commit it to Git.

  5. Tool configuration lives under [tool.*] sections. Each tool gets its own namespace -- [tool.pyright], [tool.ruff], [tool.pytest.ini_options] -- so configurations never collide.

Looking Ahead

Your SmartNotes project now has three discipline tools installed and configured in one file. But you have not run any of them yet. What does ruff actually catch? What does its output look like? What happens when your code has style violations?

In Lesson 4, James writes his first Python function -- and discovers the difference between code that runs and code that is correct.