By the end you'll know
- What a class is, and how it differs from a dictionary
- How
__init__andselfwork, and what a method is - How a dataclass saves you writing the same code every time
- How to make an error type of your own, and why it's a class
- When a class is the wrong tool
Before you start
- You need: the checker as Lesson 9 left it, in your
site-checkerfolder, with its virtual environment. - Where commands run: in your terminal, in
site-checker, with the environment active:cd site-checker, thensource .venv/bin/activate(.venv\Scripts\activateon Windows). - Quick check:
python3 -m pytest -qshould say5 passed. If it can't findrequestsorpytest, the environment isn't active (Lesson 6).
Where your checker is
Since Lesson 5, every site has been a dictionary: site["name"], site["url"]. And every result has been a word: "up", "down" or "error". That works, and it's how many small scripts stay forever. But a dictionary will hold any keys at all, so a typo isn't caught until the exact moment that line runs:
python3 -c 'from checker import load_sites; site = load_sites("sites.csv")[0]; print(site); print(site["nmae"])'
{'name': 'github.com', 'url': 'https://github.com'}
Traceback (most recent call last):
File "<string>", line 1, in <module>
from checker import load_sites; site = load_sites("sites.csv")[0]; print(site); print(site["nmae"])
~~~~^^^^^^^^
KeyError: 'nmae'
(python3 -c runs a line of Python straight from the terminal, without a file.) Nothing in the dictionary says a site must have a name and a url, and only those. That rule lives in your head. Lesson 2 promised that classes are how you make types of your own. This lesson makes two: a Site, and a CheckResult.
A class is a blank form
A paper form has the same boxes on every copy: name here, address there. The form itself isn't anyone's details; each filled-in copy is. A class is the blank form. An object made from it, also called an instance, is one filled-in copy. str and int are classes too, which is why Lesson 2's type() printed <class 'str'>.
Make a folder called try inside site-checker, for experiments that aren't part of the checker, and a file called first_class.py in it:
class Site:
def __init__(self, name, url):
self.name = name
self.url = url
def describe(self):
return f"{self.name} at {self.url}"
github = Site("github.com", "https://github.com")
print(github.name)
print(github.describe())
print(type(github))
print(github)
print(github == Site("github.com", "https://github.com"))
print(github.nmae)
python3 first_class.py
github.com
github.com at https://github.com
<class '__main__.Site'>
<__main__.Site object at 0x102f6a900>
False
Traceback (most recent call last):
File "/Users/you/site-checker/try/first_class.py", line 16, in <module>
print(github.nmae)
^^^^^^^^^^^
AttributeError: 'Site' object has no attribute 'nmae'
Here's what's new:
class Site:starts the blank form. Class names use a capital for each word,Site,CheckResult, so they stand out from functions.__init__(two underscores each side, said "dunder init") is the function Python runs every time you make aSite.Site("github.com", "https://github.com")calls it with those two values.selfis the copy being filled in.self.name = namewrites the name into this copy's box. Every function inside a class getsselffirst, and you never pass it yourself: Python does.github.namereads a box, called an attribute, with a dot instead of square brackets.describe()is a method: a function that belongs to the class, and works on one copy's details throughself. You've used methods since Lesson 2:"hello".upper()is theuppermethod ofstr.
And three things to notice, because they're why the next section exists:
print(github)is useless.<__main__.Site object at 0x102f6a900>says it's aSite, and where it lives in memory, and nothing about which site.- Two identical sites aren't equal.
==on a plain class asks "is this the same copy?", not "do they hold the same details?". They're two copies, soFalse. - The typo is still caught only when that line runs, but the error now says exactly what's wrong: a
Sitehas no attributenmae. And your code editor knows aSite's attributes, so it can underlinegithub.nmaebefore you ever run it. It can't do that for a dictionary key.
The mistake everyone makes once
Leave out self, in no_self.py:
class Site:
def describe():
return "a site"
Site().describe()
Traceback (most recent call last):
File "/Users/you/site-checker/try/no_self.py", line 6, in <module>
Site().describe()
~~~~~~~~~~~~~~~^^
TypeError: Site.describe() takes 0 positional arguments but 1 was given
"1 was given", though the brackets are empty? Site().describe() quietly passes the object itself as the first argument, which is self. With no self there to receive it, there's one argument too many. When you see takes 0 positional arguments but 1 was given from a method, the method is missing self.
Dataclasses: the form, without the paperwork
Most classes start the same way: an __init__ that copies each value into self, one line per box. And they all print and compare as badly as the one above unless you write more methods to fix it. Python's standard library has a shortcut, dataclasses. In with_dataclass.py:
from dataclasses import dataclass
@dataclass
class Site:
name: str
url: str
github = Site("github.com", "https://github.com")
print(github)
print(github == Site("github.com", "https://github.com"))
print(Site(name=42, url=None))
print(Site("pypi.org"))
Site(name='github.com', url='https://github.com')
True
Site(name=42, url=None)
Traceback (most recent call last):
File "/Users/you/site-checker/try/with_dataclass.py", line 14, in <module>
print(Site("pypi.org"))
~~~~^^^^^^^^^^^^
TypeError: Site.__init__() missing 1 required positional argument: 'url'
@dataclassis a decorator: a line starting with@, just above a class or function, that adds something to it. This one reads the boxes you list and writes__init__, the printing and the==for you.name: strlists a box, with a type hint after the colon: "this should be a str".- It prints usefully,
Site(name='github.com', url='https://github.com'), and compares by contents,True. - Forget a box, and it says so straight away, at the moment you make the
Site, not later when something readsurl.
And look at the third line: Site(name=42, url=None) was accepted. Type hints are notes for people and editors; Python doesn't check them when the program runs. A dataclass makes sure every box is filled, not that it's filled with the right kind of thing. Lesson 11 meets a class that does check, and that difference is the whole reason it exists.
One trap. A box can have a default, like ms: int | None = None. But not a list. In list_default.py:
from dataclasses import dataclass
@dataclass
class Batch:
sites: list = []
python3 list_default.py 2>&1 | tail -1
ValueError: mutable default <class 'list'> for field sites is not allowed: use default_factory
A list is mutable: it can be changed in place. A default [] would be one single list, shared by every Batch you ever make, so adding a site to one would add it to all of them. Plain functions have the same trap, silently; dataclasses refuse it outright. The fix is the one in the message, sites: list = field(default_factory=list), with field imported from dataclasses: "make a new empty list for each copy".
Errors are classes too
Every error you've met, KeyError, ValueError, FileNotFoundError, is a class. So you can make your own, for problems that are specific to your program. In own_error.py:
class SitesFileError(Exception):
pass
print(ValueError.__mro__)
print(SitesFileError.__mro__)
try:
raise SitesFileError("sites.csv has no url column")
except Exception as error:
print(f"caught {type(error).__name__}: {error}")
(<class 'ValueError'>, <class 'Exception'>, <class 'BaseException'>, <class 'object'>)
(<class '__main__.SitesFileError'>, <class 'Exception'>, <class 'BaseException'>, <class 'object'>)
caught SitesFileError: sites.csv has no url column
class SitesFileError(Exception):makes a new class built onException: it gets everything an exception can do, and its own name.passmeans "nothing to add".__mro__lists a class and everything it's built on, in order.ValueErroris built onException, and so isSitesFileError: they're the same kind of thing. That's whyexcept Exceptioncatches both, andexcept SitesFileErrorcatches only yours.raisethrows it (Lesson 11 usesraisetoo), andas errorgives you the error to look at.
Why bother? Because except SitesFileError can then catch exactly "the sites file is wrong", and nothing else. A bare KeyError could come from anywhere in your program.
The checker, with classes
Three changes to checker.py. None of them changes what it does, only how it describes things.
Step 1. Add the import, at the end of the standard-library imports:
from dataclasses import dataclass
Step 2. Add the two types and the error, just above is_up():
@dataclass
class Site:
name: str
url: str
@dataclass
class CheckResult:
name: str
status: str
status_code: int | None = None
ms: int | None = None
def to_dict(self):
return {"name": self.name, "result": self.status}
class SitesFileError(Exception):
pass
statusis"up","down"or"error", as before. Now it travels with the status code and the time, which used to be logged and then lost.int | None = Nonemeans "an int, orNone", withNoneas the default. A site that never answered has no status code, soCheckResult("no such site", "error")leaves them out.to_dict()turns a result into exactly the dictionaryresults.jsonhas always held. Anything that reads that file, like Lesson 11's API, never knows anything changed.
Step 3. Make load_sites() build Sites, and say clearly when the file is wrong:
def load_sites(path):
with open(path, newline="") as file:
rows = list(csv.DictReader(file))
sites = []
for row in rows:
try:
sites.append(Site(row["name"], row["url"]))
except KeyError as error:
raise SitesFileError(f"{path} has no {error} column") from error
return sites
The KeyError is caught and turned into your own error, with a message about the file. from error keeps the original attached, so nothing is hidden from anyone debugging it.
Then use the types. In check_site(), site["name"] becomes site.name, site["url"] becomes site.url, and each return hands back a CheckResult instead of a word:
def check_site(site, timeout=5):
response = fetch(site.url, timeout=timeout)
if response is None:
logging.error(f"{site.name}: no answer")
return CheckResult(site.name, "error")
status_code = response.status_code
response_ms = to_ms(response.elapsed.total_seconds())
if is_up(status_code):
logging.info(f"{site.name}: up, {status_code} in {response_ms} ms")
return CheckResult(site.name, "up", status_code, response_ms)
logging.warning(f"{site.name}: DOWN, {status_code} in {response_ms} ms")
return CheckResult(site.name, "down", status_code, response_ms)
And in main(), catch the new error when loading, and read result.status in the loop:
try:
sites = load_sites(args.sites)
except SitesFileError as error:
logging.error(error)
return 2
logging.info(f"Checking {len(sites)} sites from {args.sites}")
down = 0
errors = 0
results = []
for site in sites:
result = check_site(site, timeout=timeout)
results.append(result.to_dict())
if result.status == "down":
down = down + 1
elif result.status == "error":
errors = errors + 1
return 2 is a new exit code: 1 already means "a site has a problem", and a broken sites file is a different problem, with nothing checked at all (Lesson 7).
Run it:
python3 checker.py; echo "exit: $?"
2026-10-30 18:09:46,832 INFO Checking 4 sites from sites.csv
2026-10-30 18:09:46,918 INFO github.com: up, 200 in 58 ms
2026-10-30 18:09:46,980 INFO pypi.org: up, 200 in 61 ms
2026-10-30 18:09:47,172 WARNING missing page: DOWN, 404 in 183 ms
2026-10-30 18:09:47,177 WARNING attempt 1 failed for https://example.invalid
2026-10-30 18:09:48,187 WARNING attempt 2 failed for https://example.invalid
2026-10-30 18:09:49,195 WARNING attempt 3 failed for https://example.invalid
2026-10-30 18:09:50,199 ERROR no such site: no answer
2026-10-30 18:09:50,201 INFO 1 of 4 sites down, 1 unreachable
2026-10-30 18:09:50,201 INFO Results saved to results.json
2026-10-30 18:09:50,201 WARNING NTFY_TOPIC isn't set, so no alert was sent
exit: 1
Exactly what Lesson 9's checker said, and results.json holds exactly what it did:
[
{
"name": "github.com",
"result": "up"
},
{
"name": "pypi.org",
"result": "up"
},
{
"name": "missing page",
"result": "down"
That's the sign of a good change to how code is organised: everything outside sees no difference. It's called refactoring.
Tests for the new types
Add these to test_checker.py, and change its import line to bring in the new names:
import pytest
from checker import CheckResult, Site, SitesFileError, is_up, load_sites, to_ms
def test_load_sites_makes_sites(tmp_path):
path = tmp_path / "sites.csv"
path.write_text("name,url\nexample,https://example.com\n")
assert load_sites(path) == [Site("example", "https://example.com")]
def test_missing_column_is_a_sites_file_error(tmp_path):
path = tmp_path / "sites.csv"
path.write_text("name,address\nexample,https://example.com\n")
with pytest.raises(SitesFileError):
load_sites(path)
def test_result_keeps_the_results_json_shape():
result = CheckResult("example", "down", 503, 120)
assert result.to_dict() == {"name": "example", "result": "down"}
tmp_pathis a gift from pytest: name it as an argument, and the test gets a fresh, empty folder that's thrown away afterwards. Your realsites.csvis never touched.load_sites(path) == [Site(...)]only works because a dataclass compares by contents. With the plain class from earlier, it would always beFalse.with pytest.raises(SitesFileError):passes only if the code inside raises that error.- The last test guards
results.json. If someone renamesstatusone day, this fails before Lesson 11's API breaks.
python3 -m pytest -q
........ [100%]
8 passed in 0.05s
The original five, and three new ones.
When not to use a class
Classes are a tool, not a goal. Some honest rules of thumb:
- One function does one job? Leave it a function.
is_up()andto_ms()don't need a class, and wrapping them in one makes them harder to use and test. - Data you pass straight through? A dictionary is fine. If you read JSON and hand it on unchanged, a class adds a step for nothing.
- Reach for a class when the same shape turns up again and again, like every site, every result, every request, and especially when getting the shape wrong would be a real bug.
- Start with a dataclass. Write a plain class with your own
__init__only when you need something a dataclass can't do.
Real code looks like this. CivicGrid's API has a dataclass describing whoever's calling it, attached to every request once their API key checks out:
@dataclass
class AuthContext:
"""Authentication info attached to request.state.auth on success."""
key_id: int
key_prefix: str
user_email: str
tier: str
The same shape for every caller, used all over the code, where a typo would be a security bug. That's exactly when a class earns its place. (The """...""" line is a docstring: a description, in the class itself, that editors show when you hover over it.)
Break it on purpose
A sites file with the wrong column. Make one, run the checker with it, and remove it:
printf 'name,address\nexample,https://example.com\n' > bad.csv
python3 checker.py --sites bad.csv; echo "exit: $?"
rm bad.csv
2026-10-30 18:09:50,476 ERROR bad.csv has no 'url' column
exit: 2
One clear line, and exit code 2, before anything is checked. Lesson 9's checker had no such check: it would have got as far as check_site() and stopped there with a KeyError traceback.
Ask for a box that isn't there. In first_class.py, you've already seen AttributeError: 'Site' object has no attribute 'nmae'. Add print(github.nmae) to the end of with_dataclass.py, above the line that fails, and see what a dataclass says.
Forget a box. Site("pypi.org") with the dataclass gave missing 1 required positional argument: 'url'. With a dictionary, a missing url would have waited until fetch() asked for it.
Where you are now
Your checker has types of its own: a Site, a CheckResult and a SitesFileError, and it says clearly when its sites file is wrong. You can write a class with __init__, self and methods, let a dataclass do the paperwork, make your own errors, and decide when a class isn't worth it. In Lesson 11, the last, you'll build an API for your checker with FastAPI, and meet Pydantic: a library whose classes look just like your dataclasses, but check every value that comes in.
Check yourself
What's the difference between a class and an object?
A method gives: takes 0 positional arguments but 1 was given. What's wrong?
Two dataclass Sites with the same name and url. What does == give?
Site(name=42, url=None) worked on a dataclass with name: str. Why?
Why does a dataclass refuse sites: list = []?
Why make SitesFileError instead of letting the KeyError through?
Key terms
- Class: a template for a kind of thing, a type of your own.
- Object, or instance: one thing made from a class, with its own details.
- Attribute: one detail of an object, read with a dot:
site.name. - Method: a function that belongs to a class, and works on one object through
self. __init__andself: the function run when an object is made, and the object it's filling in.- Dataclass: a class whose
__init__, printing and==are written for you, from the boxes you list. - Decorator: an
@line above a class or function that adds to it, like@dataclass. - Refactoring: changing how code is organised without changing what it does.
Go deeper
- dataclasses (opens in a new tab) in the Python documentation, including
field(),frozen=Truefor objects that can't be changed, andasdict(). - The Python tutorial's chapter on classes (opens in a new tab), for inheritance and everything this lesson left out.
- Try
@dataclass(frozen=True)onSite, thengithub.name = "x", and read the error. - Lesson 4, Functions and errors, for
tryandexcept, which your own error builds on.