Skip to content

Quality and handoff

  • Lesson 18
  • 50 min
  • Module 6: From notebook to maintainable project
Lesson 18 of 18 · Python for Practical AI EngineeringQuality and handoff

What you will be able to do

Hand ticket-cleaner to another person. They get a folder and follow the README. In five minutes, the program runs and the tests pass on their computer, without a question to you. This is the test of every project that you build after this course.

Before you startWhat to know and to have readyAbout 50 minutes

What you need to know

What you need on your computer

Tested with Python 3.14.6, ruff 0.16.10 and pytest 9.1.1 on macOS, and Python 3.14.4 on Ubuntu 26.04.

Code is read many more times than it is written. Two tools make it easier to read and find some bugs before you run it:

  • A formatter rewrites the layout of your code to one standard style: spaces, line length, quotes, empty lines. It does not change what the code does. Nobody spends time on layout in reviews, and every file looks the same.
  • A linter reads your code, and reports patterns that are often bugs. Examples are an import that you do not use, a name that does not exist, and a list as a default value.

ruff is one tool that does both, and it is fast. Add it to the project as a development dependency:

  1. Add ruff==0.16.10 to requirements.txt. Add comments that separate what the program needs from what you need to work on it:

    requirements.txt
    # Needed to run the program
    httpx==0.28.1
    # Needed to work on the program: tests, formatting and linting
    pytest==9.1.1
    ruff==0.16.10
  2. Install it: python -m pip install -r requirements.txt.

  3. Make pyproject.toml in the project folder. Python tools read their settings from this file:

    pyproject.toml
    # Settings for the development tools. The program itself needs only
    # requirements-lock.txt.
    [tool.ruff]
    line-length = 88
    [tool.ruff.lint]
    # The default rules, plus import sorting (I) and common bug patterns (B).
    extend-select = ["I", "B"]
    [tool.pytest.ini_options]
    testpaths = ["tests"]

    testpaths tells pytest to look for tests only in tests/.

Amira makes a file with some typical problems, ticket_cleaner/messy.py, to see what ruff does:

ticket_cleaner/messy.py
from ticket_cleaner.records import Ticket
import json
def add_problem(problem, problems=[]):
problems.append(problem)
return problems
def count_open(tickets:list[Ticket])->int:
return len( [t for t in tickets if t.status=='open'] )

She runs the linter on it:

Terminal window
ruff check ticket_cleaner/messy.py
Output (shortened)
I001 [*] Import block is un-sorted or un-formatted
--> ticket_cleaner/messy.py:1:1
F401 [*] `json` imported but unused
--> ticket_cleaner/messy.py:2:8
B006 Do not use mutable data structures for argument defaults
--> ticket_cleaner/messy.py:5:35
|
5 | def add_problem(problem, problems=[]):
| ^^
help: Replace with `None`; initialize within function
Found 3 errors.
[*] 2 fixable with the `--fix` option (1 hidden fix can be enabled with the `--unsafe-fixes` option).

Each finding has a code, a message and a place (file:line:column). B006 is the default list that remembers, from Function contracts: the linter finds it without running the code. The [*] findings are safe to fix automatically:

Terminal window
ruff check --fix ticket_cleaner/messy.py

This removes the unused import and sorts the imports. B006 remains, because the fix changes what the function does, so a person must decide. Then the formatter fixes the layout:

Terminal window
ruff format ticket_cleaner/messy.py
ticket_cleaner/messy.py (after the fix and format)
from ticket_cleaner.records import Ticket
def add_problem(problem, problems=[]):
problems.append(problem)
return problems
def count_open(tickets: list[Ticket]) -> int:
return len([t for t in tickets if t.status == "open"])

Two empty lines between functions, spaces around :, -> and ==, double quotes, no spaces inside brackets. The code does the same thing, and now looks like all other Python code. She fixes B006 by hand, as in Module 3, then deletes messy.py, because it was only an example.

During the course, you made some files to practise: first_run.py, ticket_values.py, urgent_report.py, try_mock.py, try_token.py, show_args.py. They are not part of the program. A new person cannot tell which files matter, so remove them (or move them to a folder outside the project).

The project then has a clear layout:

ticket-cleaner/
ticket-cleaner/
├── .env.example the settings, without secret values
├── .gitignore
├── README.md how to set up, run and work on it
├── pyproject.toml settings for ruff and pytest
├── requirements.txt what the project uses directly
├── requirements-lock.txt the exact version of every package
├── data/ sample data
├── tests/ one test file for each module
└── ticket_cleaner/ the package

Some files must not be in the copy that you hand over, because the other person makes them again, or because they are private. List them in .gitignore, which Git and many other tools respect:

.gitignore
.venv/
__pycache__/
.pytest_cache/
.ruff_cache/
reports/
*.tmp
.env

Update .env.example: TICKETS_SOURCE is now the input argument, so remove it, and add LOG_LEVEL.

The README is the first file that a person reads. For a project that someone must run, it answers these questions, in this order:

Section Answers
First line What does it do, in one or two sentences?
Requirements What must I have before I start?
Set up Exactly which commands do I run, once?
Run Which command runs it, and what do I see when it works?
Settings and options What can I change?
Exit codes How does a script that runs it know the result?
Work on the code How do I run the tests and the checks?

Write the commands exactly as a person types them, and show the expected output, so they know that each step worked. These are the same rules as for a lesson. Here is the start of Amira’s README. The full file has every section from the table:

README.md (start)
# ticket-cleaner
Checks support-ticket records from a CSV file, a JSON file or a web service, and writes a summary report. Every record that is not valid is listed in the report, with the reason.
## Requirements
- Python 3.12 or newer. Check with `python3 --version` (`python --version` on Windows).
- No account. The `remote` input downloads a public file.
## Set up
Do these steps once, in a terminal in this folder.
1. Make a virtual environment: `python3 -m venv .venv`. On Windows, type `python` instead of `python3`.
2. Activate it. On macOS and Linux: `source .venv/bin/activate`. On Windows (PowerShell): `.venv\Scripts\Activate.ps1`. The prompt now starts with `(.venv)`.
3. Install the exact versions of the dependencies: `python -m pip install -r requirements-lock.txt`
## Run
```sh
python -m ticket_cleaner data/tickets.csv
```
Expected output (the last lines):
```text
15 rows: 9 valid, 6 rejected.
Report: reports/summary.json
```
  1. Add ruff and pyproject.toml as above.

  2. Run the checks on the whole project:

    Terminal window
    ruff check .
    ruff format .

    Fix what ruff check reports. Run both again, until ruff check . shows All checks passed! and ruff format . reports no files to change.

  3. Run the tests again: python -m pytest. The formatter must not have changed what the code does, and the tests prove it.

  4. Remove the practice files. Make .gitignore, and update .env.example.

  5. Make the lock file again in a new environment, so it has only the packages in requirements.txt (see the common mistake below):

    Terminal window
    deactivate
    rm -rf .venv
    python3 -m venv .venv
    source .venv/bin/activate
    python -m pip install -r requirements.txt
    python -m pip freeze > requirements-lock.txt

    The lock file has 13 lines: httpx, pytest, ruff and the packages that they need.

  6. Write README.md with every section from the table.

You cannot test a README on the computer where you wrote it: your environment, your variables and your memory fill every gap. Test it the way a new person meets it: a fresh copy, a new terminal, and only the README.

  1. Open a new terminal. Do not activate anything.

  2. Make a copy of the project without the files that .gitignore lists. If you use Git and you committed your work, clone it:

    Terminal window
    cd ~/projects
    git clone ticket-cleaner ticket-cleaner-fresh

    Without Git, copy the ticket-cleaner folder in your file manager, name the copy ticket-cleaner-fresh, and delete .venv, reports and every __pycache__ folder in the copy.

  3. cd ticket-cleaner-fresh, open README.md, and do exactly what it says, nothing more. Do not use a command from your memory.

  4. Each time that you must do something that the README does not say, stop. Add the missing step to the README in the original project.

  5. When the README is complete, delete ticket-cleaner-fresh and do it again. Continue until a fresh copy works with the README only. The program prints 15 rows: 9 valid, 6 rejected., and python -m pytest and ruff check . pass.

Ask a person who has not seen the project to do the fresh-copy test, on their own computer, if possible with a different operating system. Do not help them. Write down each place where they stop or ask a question.

If nobody is available, do it on a different computer. Or wait one day, and do it again yourself. A gap that you missed today is easier to see tomorrow.

Show the questions that people ask most often
  • “Which Python?” → Requirements needs the version and the command to check it.
  • “The prompt does not show (.venv).” → Set up needs the activate command for each system, and the PowerShell execution-policy fix from Isolate dependencies.
  • “Where is the report?” → Run needs the path of the output, and the expected output.
  • “Is a WARNING line a problem?” → Run needs one sentence: each rejected record appears as a WARNING, and that is expected.
  • “How do I run the tests?” → Work on the code.

Common mistake

A lock file with packages that the project does not use

Symptom: requirements-lock.txt has many more lines than expected, for example ipykernel, jupyter_client, traitlets and twenty more. Or a fresh install takes a long time, or fails on a different system.

Cause: pip freeze lists every package in the environment, not only the ones in requirements.txt. Perhaps you installed something to try it, for example ipykernel for a notebook in Notebook discipline. Now it is in the lock file, and every new person installs it too.

Fix:

  1. Make the lock file in a new environment, with only requirements.txt installed (step 5 of the guided practice).
  2. Compare the old and the new lock file. Every line must come from a package in requirements.txt, or from what one of them needs.
  3. Install personal tools, such as ipykernel, outside the project’s environment, or install them again after you make the lock file.

Check your understanding

ruff check --fix removed an import and sorted the others. How do you know that it did not break anything?

Show the answer

Run the tests: python -m pytest. Formatting and safe fixes do not change what the code does, but the tests prove it, instead of trusting it. That is why the README says to run all three: tests, format and check.

Check your understanding

Module check: what does it mean that the project runs in a fresh environment using only the README, and how do you show it?

Show the answer

A copy without .venv, reports or any setting from your terminal, set up by following the README step by step, with no step from memory. You show it with the output of that run: the program’s 15 rows: 9 valid, 6 rejected., python -m pytest passing, and ruff check . passing, all in the fresh copy. Each gap that you found is now a step in the README.

Recap

  • A formatter makes the layout standard. A linter finds patterns that are often bugs. ruff does both: ruff format . and ruff check ..
  • After any automatic change, run the tests.
  • A clean layout has only the files that the project needs. .gitignore lists what stays private or is made again.
  • The README says what it does, what you need, how to set up and run it, and what you see when it works.
  • Make the lock file in a new environment. Test the README on a fresh copy, and add every missing step.

You can now build, test and hand over a Python project. The final assignment puts it all together.

Next: Final assignment