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.
| Rule | Yes | No |
|---|---|---|
| Four spaces per indent, never tabs | x = 1 | a tab character |
| snake_case for variables and functions | total_pay | totalPay |
| CapWords for classes | class ShipLog: | class ship_log: |
| ALL_CAPS for constants | MAX_CREW = 12 | maxCrew = 12 |
| Spaces around operators | x = a + b | x=a+b |
| No space inside brackets | f(a, b) | f( a, b ) |
| Two blank lines between top-level functions | ||
| Lines under 88 characters or so | a 200 character line | |
| Imports at the top, one per line, grouped | import 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.
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'>}
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:
- Your editor gets smarter. Autocomplete knows what methods exist, and
it underlines
name.uppr()before you run anything. - A checker can prove things. mypy reads your whole project and finds type errors without running it. On a large codebase this catches real bugs in code paths nobody has exercised yet.
- They are the best documentation there is, because they cannot drift out of date silently the way a comment can.
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)]
| Hint | Means |
|---|---|
str, int, float, bool | The 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 | None | Either an int or None (the modern way, 3.10+) |
-> None | Returns nothing |
Any | Give 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
| Situation | Advice |
|---|---|
| A twenty-line script for yourself | A formatter. That is all |
| A project you will still be using next year | Formatter, linter, and hints on the public functions |
| Anything with other people in it | All of the above, running automatically in CI |
| A library other people import | All of the above plus hints everywhere, since they are your documentation |
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.
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.0Add 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_scoreReveal 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.
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 . && pytestThat 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.
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.