GeezerVisonThe World Wide Web, restored to 800 pixels of glory. | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Home|About|Gaming cabinet|Archives|How it works|Starter sites | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
GeezerVison is showing shikharkumar13.github.io — Writing Reliable Code · Python Programming Series This guide covers four things that help, and none of them are about making the program work. It already works. They're about what happens next:
Analogy: think of a car. Getting it to start is "making the program work". But a car you can rely on also has a manual (type hints and docstrings), a dashboard with warning lights (logging), an inspection that checks the brakes before every long trip (tests), and a mechanic's diagnostic tool for when something goes wrong anyway (tracebacks and the debugger). None of those make the engine run. All of them make the car trustworthy. Chapter 9 introduced Here is what we will cover:
Setting UpThis chapter writes a few small files and tests them, so it needs a folder to work in. python
Part 1: Type HintsA type hint says what kind of value a function expects and what kind it gives back. It goes after a colon for each argument, and after an arrow ( python Read it as: " Apart from the fields of chapter 15's dataclass, everything in this course has been written without them, and it all ran fine. So what are they for? Python Doesn't Check ThemThis is the first thing to understand, and it surprises a lot of people: python None of those calls match the hint exactly:
And Python didn't care. Type hints are not checked when the code runs. Python stores them on the function and otherwise ignores them: python
Analogy: a type hint is like the "Fragile: this way up" label on a parcel. The label doesn't stop anyone from turning the box upside down. It tells people what the box expects, and a careful inspector at the depot (the type checker in Part 2) can spot when the instructions are being ignored. So a type hint isn't a safety net. It's two other things. It's documentation that stays up to date. A comment saying "pass a list of floats" goes stale the moment someone changes the function. A hint sits right in the function's first line, where the change happens. It can be checked by a tool. That's the next section, and the main reason hints are worth writing. (There's a third, everyday benefit too: code editors like VS Code and PyCharm read hints to give you better autocomplete and to underline mistakes as you type.) The Hints You'll Actually See
That "or" hint is written
python The three calls show the three possibilities:
The first line of the function now tells you what the output already showed: this function can give you
In older code you'll often see Part 2: A Tool That Does Check
Analogy: To try it we need real files. notebook cell And here's a file that uses it, with two real mistakes in it: notebook cell
python The arguments say: run Each error tells you the file, the line number, what's wrong, and a code in square brackets (like Two mistakes, reported as three errors, because the first bad call has two bad items in its list. If you did run it, the first one would crash inside You run terminal Hints are optional, and you can add as many or as few as you like. Add them to the functions other code calls, skip them in a three-line script, and don't let anyone tell you it's all or nothing. One thing to know about that "add as few as you like" freedom: by default, Part 3: DocstringsYou met docstrings in chapter 13: a string written as the first statement of a function, class or module. Python stores it, python
How to Write a Good OneThe first line is a one-sentence summary, written as a command: "Return the number", not "This function returns the number". Then a blank line, then anything a caller needs to know that the function's first line doesn't already say. Analogy: a docstring is like the label on a medicine bottle. The first line says what it's for. The rest covers what you need to know to use it safely: the dose, the side effects, when not to take it. It doesn't explain the chemistry of how it was made. What belongs in one, and what doesn't:
A docstring that says Part 4: LoggingChapter 9 introduced the
Analogy: The Five LevelsThey exist so you can turn the volume down without deleting anything.
python The Pieces of the SetupThere are three objects involved, and it helps to know which does what:
The rest of the setup, line by line:
Turning the Volume DownThe level works as a threshold. Raise it, and the less important messages disappear without any code changing: python With the level at Analogy: it's like the volume knob on a radio. Every station is still broadcasting, but you only hear the ones above the level you've set. Turn it back up while debugging, down again in production. This is the whole argument in one example. Every One Logger per ModuleThe usual line at the top of a module is: python
Logging an ExceptionAs chapter 9 showed, python The traceback is in the log, and the program carried on. (The file name in it is the temporary one Jupyter gives each notebook cell, which is why this is one of the few examples whose output will look different on your machine. Newer Python versions also draw the
Compare that with Setting It Up in a ScriptThe setup above shows each piece separately. A real script calls python This sets the level to Two rules worth following from the start:
(Why Part 5:
|
| Use | For | Because |
|---|---|---|
assert |
things you believe are already true | it documents an assumption, and may vanish |
raise ValueError |
checking input from a user, a file, or a network | it always runs |
Here's the same function, written properly:
def withdraw(balance: float, amount: float) -> float:
if amount <= 0:
raise ValueError(f"amount must be positive, got {amount}")
return balance - amount
try:
withdraw(100, -50)
except ValueError as e:
print("ValueError:", e)
ValueError: amount must be positive, got -50
An if with raise is ordinary code, so -O can't remove it. And the error message now includes the actual bad value (got -50), which makes it much easier to track down.
Where assert does belong is inside tests, which is the next section, and as an internal check on something you've already validated.
pytestA test is a small function that runs your code and checks the answer. pytest is a tool that finds your tests and runs them for you.
Analogy: tests are like the answer key a teacher uses to mark homework. Every time you change your code, you hand it in again, and the answer key instantly tells you which questions it now gets wrong. Without one, you'd have to re-check every answer by hand after every change, and you'd soon stop bothering.
Its naming rules are the whole setup:
test_*.py,test_*,assert for each check.There's no class to inherit from and nothing to register. pytest finds anything that follows those names.
Here's a test file for the prices.py we wrote in Part 2:
%%writefile reliable_demo/test_prices.py
"""Tests for prices.py. Run with: pytest"""
from prices import to_price
def test_plain_number():
assert to_price("12.50") == 12.50
def test_strips_currency_and_separators():
assert to_price("£1,299.00") == 1299.0
def test_missing_price_is_none():
assert to_price("") is None
assert to_price("free") is None
def test_none_input_is_none():
assert to_price(None) is None
Writing reliable_demo/test_prices.py
Each test checks one behaviour, and its name says which: a plain number, stripping currency symbols and commas, a missing price, and None as input. If one fails, the name alone tells you what broke.
Now let's run it. The helper below runs pytest the same way you would in a terminal:
import re
def run_pytest(*args):
result = subprocess.run([sys.executable, "-m", "pytest",
"--no-header", "--color=no",
"-p", "no:cacheprovider", *args],
cwd="reliable_demo", capture_output=True, text=True,
env={**os.environ, "COLUMNS": "80"})
# the duration is different every run, so blank it out
print(re.sub(r"in \d+\.\d+s", "in Xs", result.stdout))
run_pytest("test_prices.py", "-q")
.... [100%]
4 passed in Xs
--no-header and --color=no keep the output plain.-p no:cacheprovider stops it leaving a cache folder behind.COLUMNS=80 fixes the width of the output.pytest also prints how long it took, which changes every run, so the helper uses chapter 16's re.sub to hide that one number.-q means "quiet": one character per test.Four dots, four passing tests. In a terminal, that's just:
pytest
Look at the last test: to_price(None). But to_price's hint says text: str, not str | None. So why didn't mypy complain about this file? Let's ask it, with and without one extra option:
for extra in ([], ["--check-untyped-defs"]):
result = subprocess.run(
[sys.executable, "-m", "mypy", "test_prices.py",
"--no-color-output", "--no-error-summary", *extra],
cwd="reliable_demo", capture_output=True, text=True)
label = " ".join(extra) or "plain mypy"
print(f"{label:<22} ->", result.stdout.strip() or "no errors reported")
plain mypy -> no errors reported
--check-untyped-defs -> test_prices.py:19: error: Argument 1 to "to_price" has incompatible type "None"; expected "str" [arg-type]
Plain mypy reported nothing, because the test functions have no hints of their own, and by default mypy doesn't look inside unhinted functions (Part 2). With --check-untyped-defs, it checks them anyway, and spots the mismatch.
Both tools are telling the truth. The test proves to_price copes with None (thanks to text or ""), and mypy points out that the hint doesn't admit it. The honest fix would be to change the hint to text: str | None, so the first line of the function says what the code really accepts. Hints and tests check different things, which is why reliable code uses both.
A passing test doesn't tell you much. A failing one is where the useful output is, so here's a test file with a deliberately wrong expectation in it:
%%writefile reliable_demo/test_broken.py
from prices import to_price, total
def test_total_of_two_prices():
assert total([12.50, 8.00]) == 20.50
def test_price_keeps_the_pence():
assert to_price("£19.99") == 19.0 # wrong on purpose
Writing reliable_demo/test_broken.py
run_pytest("test_broken.py", "-q")
.F [100%]
=================================== FAILURES ===================================
__________________________ test_price_keeps_the_pence __________________________
def test_price_keeps_the_pence():
> assert to_price("£19.99") == 19.0 # wrong on purpose
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E AssertionError: assert 19.99 == 19.0
E + where 19.99 = to_price('£19.99')
test_broken.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_broken.py::test_price_keeps_the_pence - AssertionError: assert 19...
1 failed, 1 passed in Xs
How to read that output:
.F means the first test passed (.) and the second failed (F).pytest shows the test function, with > pointing at the line that failed.E lines explain why: assert 19.99 == 19.0. The function returned 19.99, and the test expected 19.0.+ where 19.99 = to_price('£19.99') even shows which call produced that value.test_broken.py:9 is the file and line number.pytest showing the actual values on both sides of == is a big reason people use it, and all it needs is a plain assert.
Here, the test is wrong (19.99 is the right answer). In real life it's usually the code, but a failing test always means "the code and the expectation disagree", and you decide which one to fix.
The other half of testing is checking that bad input gets rejected. A test file can only import functions that live in a .py file, so Part 5's checked withdraw gets a file of its own first. Notice its docstring has a Raises section, from Part 3:
%%writefile reliable_demo/banking.py
"""The validated withdraw from section 5, in a module so it can be tested."""
def withdraw(balance: float, amount: float) -> float:
"""Return the balance after withdrawing amount.
Raises:
ValueError: if amount is zero or negative.
"""
if amount <= 0:
raise ValueError(f"amount must be positive, got {amount}")
return balance - amount
Writing reliable_demo/banking.py
Then pytest.raises works as a context manager, from chapter 14:
%%writefile reliable_demo/test_raises.py
import pytest
from banking import withdraw
def test_rejects_a_negative_amount():
with pytest.raises(ValueError):
withdraw(100, -50)
def test_says_what_was_wrong():
with pytest.raises(ValueError, match="must be positive"):
withdraw(100, 0)
def test_a_normal_withdrawal_works():
assert withdraw(100, 30) == 70
Writing reliable_demo/test_raises.py
with pytest.raises(ValueError): means "the code inside must raise ValueError". If it does, the test passes. If it doesn't, the test fails.match="must be positive" also checks the error message contains that text (it's a regex search, from chapter 16).run_pytest("test_raises.py", "-q")
... [100%]
3 passed in Xs
These tests pass when the error is raised, and fail when it isn't. That's the opposite of everywhere else in this course, where an exception meant something went wrong.
Writing the same test five times with different numbers is a quick way to get bored of testing. @pytest.mark.parametrize is a decorator (chapter 13) that runs one test once for each case:
%%writefile reliable_demo/test_many.py
import pytest
from prices import to_price
@pytest.mark.parametrize("text, expected", [
("12.50", 12.50),
("£12.50", 12.50),
(" 12.50 ", 12.50),
("£1,299.00", 1299.0),
("USD 45", 45.0),
("", None),
("free", None),
(None, None),
])
def test_to_price(text, expected):
assert to_price(text) == expected
Writing reliable_demo/test_many.py
"text, expected", names the test's parameters.pytest calls test_to_price eight times, once with each pair.run_pytest("test_many.py", "-v")
============================= test session starts ==============================
collecting ... collected 8 items
test_many.py::test_to_price[12.50-12.5] PASSED [ 12%]
test_many.py::test_to_price[\xa312.50-12.5] PASSED [ 25%]
test_many.py::test_to_price[ 12.50 -12.5] PASSED [ 37%]
test_many.py::test_to_price[\xa31,299.00-1299.0] PASSED [ 50%]
test_many.py::test_to_price[USD 45-45.0] PASSED [ 62%]
test_many.py::test_to_price[-None] PASSED [ 75%]
test_many.py::test_to_price[free-None] PASSED [ 87%]
test_many.py::test_to_price[None-None] PASSED [100%]
============================== 8 passed in Xs ===============================
-v means "verbose": one line per test. Eight cases, one test function, and each case is named in the output (in square brackets), so a failure tells you exactly which input broke. The \xa3 in two of those names is pytest writing £ in a safe, plain-text form.
Analogy: parametrize is like a mail merge: one letter template, many names and addresses, and a separate letter printed for each.
With no filename, pytest finds and runs every test_*.py file in the folder. That includes the broken one, on purpose:
run_pytest("-q")
.F............... [100%]
=================================== FAILURES ===================================
__________________________ test_price_keeps_the_pence __________________________
def test_price_keeps_the_pence():
> assert to_price("£19.99") == 19.0 # wrong on purpose
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E AssertionError: assert 19.99 == 19.0
E + where 19.99 = to_price('£19.99')
test_broken.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_broken.py::test_price_keeps_the_pence - AssertionError: assert 19...
1 failed, 16 passed in Xs
17 tests in total: four in test_prices.py, two in test_broken.py, three in test_raises.py and eight in test_many.py. Three files pass, one has a failure, and the summary at the bottom tells you exactly where to look.
You can't test everything, and trying to is how people give up on testing. Aim at:
None, zero, a missing field. Chapter 18's missing price is a test waiting to be written.What not to test: the standard library (it's already well tested), the network (fake it or use a saved response, as chapter 17 did with sample_data/), and things whose answer keeps changing, like the current time.
A traceback is the report Python prints when an error stops the program. Read it from the bottom up. The bottom line is the error. Above it is the call that caused it, and above that, the call that made that call.
Here's a small program with a hidden problem:
%%writefile reliable_demo/report.py
from prices import to_price
def load_prices(rows):
return [to_price(row["price"]) for row in rows]
def report(rows):
prices = load_prices(rows)
return sum(prices) / len(prices)
print(report([{"price": "£12.50"}, {"price": "free"}]))
Writing reliable_demo/report.py
And here's what happens when it runs:
result = subprocess.run([sys.executable, "report.py"],
cwd="reliable_demo", capture_output=True, text=True)
# Python reports absolute paths; shorten them so this reads like your own screen
print(result.stderr.replace(str(Path.cwd()) + "/", ""))
Traceback (most recent call last):
File "reliable_demo/report.py", line 13, in <module>
print(report([{"price": "£12.50"}, {"price": "free"}]))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "reliable_demo/report.py", line 10, in report
return sum(prices) / len(prices)
^^^^^^^^^^^
TypeError: unsupported operand type(s) for +: 'float' and 'NoneType'
(The cell shortens the file paths so the output looks like your own screen. Newer Python versions also show this traceback in colour and draw the markers under the code slightly differently, but the content is the same.)
Read it in the order it happened:
unsupported operand type(s) for +: 'float' and 'NoneType'. Something tried to add a float and None.File ... line ... pair is called a frame) shows sum(prices), so prices contains a None. Traceback (most recent call last):
File "report.py", line 13, in <module> ← 3. who called report()
File "report.py", line 10, in report ← 2. the line that crashed: sum(prices)
TypeError: ... 'float' and 'NoneType' ← 1. START HERE: what went wrong
not shown: load_prices() ── it already finished, after putting None in the list
Now notice what's not there. load_prices doesn't appear in the traceback at all, and load_prices is where the None came from. to_price("free") returned None, exactly as its docstring said it would, and load_prices put it in the list and finished without any problem.
That's worth remembering about tracebacks: they show you where the program crashed, not where the mistake was made. The frames tell you which calls were still running. The mistake is often in a function that had already finished.
Analogy: a traceback is like finding a flat tyre. It tells you exactly where the car stopped. It doesn't tell you where you drove over the nail, which might have been ten kilometres earlier.
Two habits make tracebacks much easier:
site-packages).breakpoint()Putting print everywhere works. Pausing the program and looking around works better. breakpoint() stops the program at that line and opens Python's debugger, called pdb:
def report(rows):
prices = load_prices(rows)
breakpoint() # execution stops here
return sum(prices) / len(prices)
The debugger waits for you to type commands, so it isn't included as a runnable example here: an automated run would just sit there waiting. In a script run from a terminal, you get a (Pdb) prompt, with the program paused and every local variable available to look at. (In Jupyter, the same prompt appears as an input box under the cell.)
| Command | Does |
|---|---|
p prices |
print an expression |
n |
run the next line |
s |
step into the function being called |
c |
continue until the next breakpoint or the end |
l |
list the code around where you are |
w |
show the stack, the same frames as a traceback |
q |
quit |
Typing p prices at that prompt would have shown [12.5, None] straight away, and that's the whole investigation done.
Analogy: print debugging is like putting security cameras around a building and watching the footage afterwards. A debugger is like pausing time and walking around the building yourself, opening any drawer you want.
In VS Code or PyCharm, the same thing is a red dot you click in the margin (a breakpoint), and the variables appear in a panel. Either way, the skill is the same: stop close to the problem and look at the actual values.
Remember to remove breakpoint() calls when you're done. (Setting the environment variable PYTHONBREAKPOINT=0 also switches them all off, which is a useful safety net.)
PEP 8 is Python's official style guide. ("PEP" stands for Python Enhancement Proposal: the documents in which changes and conventions for Python are written down.) Following it matters for one main reason: code that looks like every other Python project is code other people can read quickly.
Analogy: it's like the rules of the road. Driving on the left or the right isn't better in itself, but everyone agreeing on one makes traffic flow. Consistent style lets readers focus on what the code does, not on how it's laid out.
The parts that come up all the time:
| Topic | Rule |
|---|---|
| Indentation | 4 spaces, never tabs |
| Line length | 79 characters, or whatever your team agreed |
| Functions and variables | lower_case_with_underscores |
| Classes | CapWords |
| Constants | ALL_CAPS |
| Imports | standard library, then third party, then yours, each group separated |
| Blank lines | two between top-level definitions, one between methods |
| Spaces | x = 1 and f(a, b), not x=1 or f( a,b ) |
Nobody applies this by hand. black reformats a file into a consistent style, and ruff finds problems (unused imports, undefined names, style issues), and both are very fast:
python3 -m pip install black ruff
black your_package/
ruff check your_package/
Run them before you commit and you'll never argue about formatting again. The naming rules are the part worth memorising, because no formatter will rename anything for you.
| Mistake | What happens | Fix |
|---|---|---|
| Expecting hints to be enforced | A wrong type runs happily | Run mypy |
assert for validating input |
-O deletes the check in production |
raise ValueError |
print for diagnostics |
Nothing recorded, no levels, no source | logging |
except: pass |
The failure leaves no trace at all | logger.exception(...) |
| Logging a key or a token | Secrets end up in files and log services | Log the request, never the credential |
logger.error("failed") |
True, and useless | Log the values you will want |
| Testing only the happy path | The empty and None cases break in production |
Test the edges first |
| Tests that call the network | Slow, and they fail when the site is down | Use a saved response |
| Reading a traceback top down | You start in library code you did not write | Last line first, then your own frames |
Type hints
def to_price(text: str) -> float | None: ...
def parse(rows: list[dict[str, str]]) -> dict[str, float]: ...
def save(path: str, rows: list[dict]) -> None: ...
python3 -m pip install mypy
mypy your_package/ # hints are checked here, never when the code runs
Docstring
def to_price(text: str) -> float | None:
"""Return the number in a price string, or None if there is not one.
Strips currency symbols and separators, so "£1,299.00" becomes 1299.0.
Raises:
TypeError: if text is neither a string nor None.
"""
Logging
import logging
logger = logging.getLogger(__name__)
logger.debug("detail while developing")
logger.info("normal progress")
logger.warning("odd, but carried on")
logger.error("this piece of work failed")
logger.exception("same as error, plus the traceback") # inside except only
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s | %(message)s")
assert versus raise
assert len(rows) > 0 # an assumption. -O deletes this.
if not rows: # a check. always runs.
raise ValueError("no rows")
pytest
# test_prices.py
import pytest
from prices import to_price
from banking import withdraw
def test_plain_number():
assert to_price("12.50") == 12.50
def test_rejects_negatives():
with pytest.raises(ValueError, match="must be positive"):
withdraw(100, -50)
@pytest.mark.parametrize("text, expected", [("12.50", 12.5), ("free", None)])
def test_cases(text, expected):
assert to_price(text) == expected
pytest # everything
pytest -v # one line per test
pytest test_prices.py # one file
pytest -k price # tests whose name contains "price"
pytest -x # stop at the first failure
Debugging
breakpoint() # then: p name, n, s, c, l, w, q
Read tracebacks from the bottom up. Last line first, then your own frames.
Style
black your_package/ # reformat
ruff check your_package/ # find problems
Reliable code is code that tells the truth about itself and leaves evidence behind: type hints and docstrings say what a function expects and returns (and mypy checks the hints, because Python won't), logging records what happened at the right level of detail, raise guards your inputs where assert might vanish, pytest proves the behaviour you care about and keeps proving it after every change, and a traceback read from the bottom up (plus breakpoint() when that isn't enough) shows you where to look. Next up, chapter 20 is the final project. One program, built from everything in chapters 1 to 19: it walks a shop's pages with a generator, cleans the fields with regex, rejects bad rows with custom exceptions, models books with a dataclass, and is logged, type-hinted and tested. It ends with a tidy dataset, ready for the NumPy and pandas course that comes next.