Level 3 · Real Programs

Style, Linting and Type Hints 🎨

Working code is the first goal. Code that a stranger can change without breaking it is the real one, and most of the way there is automatic.

PEP 8, the shared agreement

PEP 8 is Python's official style guide, written in 2001 and followed almost universally. Its value is not that its choices are objectively best; it is that everybody made the same choices, so you can read anyone's code without adjusting.

RuleYesNo
Four spaces per indent, never tabs x = 1a tab character
snake_case for variables and functionstotal_paytotalPay
CapWords for classesclass ShipLog:class ship_log:
ALL_CAPS for constantsMAX_CREW = 12maxCrew = 12
Spaces around operatorsx = a + bx=a+b
No space inside bracketsf(a, b)f( a, b )
Two blank lines between top-level functions
Lines under 88 characters or soa 200 character line
Imports at the top, one per line, groupedimport os, sys

Stop arguing: let a tool do it

# before
def  calc( a,b ):
    result=a+b
    if result>10 :
        return  "big"
    else :
        return "small"

print(calc(5,6))
big

Run a formatter over that and it becomes:

def calc(a, b):
    result = a + b
    if result > 10:
        return "big"
    else:
        return "small"


print(calc(5, 6))
big
# the two options, either is fine
pip install black && black .

pip install ruff && ruff format .

Both reformat your entire project in under a second and have almost no configuration on purpose. black calls itself "the uncompromising formatter" and its central insight is that the fastest way to end a style argument is to remove the choice. ruff does the same job (and linting too) and is written in Rust, which is why it is fast enough to run on every keystroke.

⚙️ Set format-on-save and never think about it again

VS Code: install the Ruff or Black extension, then Settings, search 'format on save', tick it. From that moment your code is correctly formatted forever and you have stopped spending attention on it.

Linting: the tool that reads your code critically

A formatter fixes how code looks. A linter points out things that look like mistakes: unused imports, variables assigned but never used, shadowed built-ins, comparisons that are always true.

$ ruff check .

app.py:1:8: F401 [*] `os` imported but unused
app.py:12:5: F841 Local variable `total` is assigned to but never used
app.py:20:1: E741 Ambiguous variable name: `l`
app.py:34:12: SIM108 Use ternary operator instead of if-else block

Found 4 errors.
[*] 1 fixable with the `--fix` option.

Nearly all of those are real bugs in embryo. An unused import means you deleted the code that needed it; an assigned-but-unused variable very often means you typed the name slightly differently on the line that uses it.

Type hints

def greet(name: str, times: int = 1) -> str:
    """Greet someone, possibly repeatedly."""
    return " ".join([f"Hello, {name}!"] * times)


print(greet("Guybrush"))
print(greet("Elaine", 2))
print(greet.__annotations__)
Hello, Guybrush!
Hello, Elaine! Hello, Elaine!
{'name': <class 'str'>, 'times': <class 'int'>, 'return': <class 'str'>}
🎭 Python does not enforce them

greet(42) runs perfectly happily and returns 'Hello, 42!'. Type hints are documentation that tools can read, not a runtime check. This surprises people coming from Java or Rust, where the compiler refuses.

So why bother? Three excellent reasons:

The notation you need

def process(
    names: list[str],
    scores: dict[str, int],
    limit: int | None = None,
    tags: tuple[str, ...] = (),
) -> list[tuple[str, int]]:
    """Pair names with scores, optionally limited."""
    pairs = [(n, scores.get(n, 0)) for n in names]
    return pairs[:limit] if limit else pairs


print(process(["Guybrush", "Elaine"], {"Guybrush": 95}, limit=1))
[('Guybrush', 95)]
HintMeans
str, int, float, boolThe obvious ones
list[str]A list of strings
dict[str, int]String keys, integer values
tuple[str, int]Exactly two items, in that order
tuple[str, ...]Any number of strings
int | NoneEither an int or None (the modern way, 3.10+)
-> NoneReturns nothing
AnyGive up (from typing). Use sparingly

What mypy catches

def total_price(items: list[float]) -> float:
    return sum(items)


# mypy reports: Argument 1 has incompatible type "list[str]";
#               expected "list[float]"
result = total_price(["12.50", "3.99"])
$ mypy shop.py
shop.py:7: error: Argument 1 to "total_price" has incompatible type
    "list[str]"; expected "list[float]"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

That program runs without hints and produces '12.503.99', a string, which then breaks something else three functions away. mypy found it in a second without running anything.

How much of this to adopt

SituationAdvice
A twenty-line script for yourselfA formatter. That is all
A project you will still be using next yearFormatter, linter, and hints on the public functions
Anything with other people in itAll of the above, running automatically in CI
A library other people importAll of the above plus hints everywhere, since they are your documentation
RHETORIC[Medium: Success]

There is a failure mode at both ends. One is the codebase with no conventions, where every file is a different dialect and reading it is exhausting.

The other is the team that spends three weeks configuring linters and arguing about line length instead of shipping. Turn on the defaults, accept them, and go back to work. The defaults are fine. That is the entire point of defaults.

One config file

# pyproject.toml
[project]
name = "crew-manager"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["requests>=2.31"]

[tool.ruff]
line-length = 88

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

[tool.mypy]
python_version = "3.13"
warn_unused_ignores = true

[tool.pytest.ini_options]
testpaths = ["tests"]

pyproject.toml is the modern single home for project configuration: packaging, formatting, linting, type checking and testing all in one file. Lesson 50 uses it to publish a package.

Exercise 1

Clean it up

Fix everything PEP 8 would complain about, and name each problem.

import os, sys
def CalcTotal( Items ):
    Total=0
    for i in Items :
        Total=Total+i
    if Total>100 :
        return Total*0.9
    else :
        return Total
print(CalcTotal([50,60]))
Reveal solution

Problems: two imports on one line and both unused; CapWords function name; CapWords variable names; no spaces around operators; spaces inside brackets; space before the colon; no blank lines around the function; the else after a return is redundant.

def calculate_total(items: list[float]) -> float:
    """Sum the items, applying a 10 percent discount over 100."""
    total = sum(items)
    if total > 100:
        return total * 0.9
    return total


print(calculate_total([50, 60]))
99.0
Exercise 2

Add type hints

Annotate this fully, including the return type.

def find_best(scores, minimum=0):
    best_name = None
    best_score = minimum
    for name, score in scores.items():
        if score > best_score:
            best_name, best_score = name, score
    return best_name, best_score
Reveal solution
def find_best(
    scores: dict[str, int],
    minimum: int = 0,
) -> tuple[str | None, int]:
    """Return the highest scorer above minimum, or (None, minimum)."""
    best_name: str | None = None
    best_score = minimum
    for name, score in scores.items():
        if score > best_score:
            best_name, best_score = name, score
    return best_name, best_score


print(find_best({"Guybrush": 95, "Otis": 42}))
print(find_best({"Otis": 42}, minimum=50))
('Guybrush', 95)
(None, 50)

The interesting part is str | None in the return type. Writing it forces you to notice that this function can return None, which is exactly the sort of thing callers forget to handle.

Exercise 3

Set up a real project

On your own machine, in a project folder, install and run the whole toolchain once. Look at what each tool says.

Reveal solution
python3 -m venv .venv && source .venv/bin/activate
pip install ruff mypy pytest

ruff format .          # reformat everything
ruff check . --fix     # fix what can be fixed automatically
mypy .                 # type check
pytest                 # run the tests

# and the one command that does all of it before you commit
ruff format . && ruff check . && mypy . && pytest

That last line is what a continuous integration pipeline runs. When it passes locally, it passes in CI, and you stop discovering problems after pushing. Lesson 50 wires it into GitHub Actions so it runs automatically.

🎉 That is Level 3

Files, exceptions, JSON and CSV, dates, regular expressions, virtual environments, command-line tools, debugging and testing, and the tooling that keeps it all readable. You can now write software rather than scripts. Take the Level 3 quiz, then build something substantial in the workshop before Level 4 shows you how Python programmers actually write Python.

+100 XP