Python basics · Lesson 10 of 11 · about 30 minutes

Classes: types of your own

How to make types of your own with classes: __init__ and self, methods, and why a plain class prints badly and compares wrongly. Then dataclasses, which fix both in one line, an error type of your own, and when not to use a class at all. Your checker's sites and results become classes, and nothing that reads results.json notices.

By the end you'll know

  • What a class is, and how it differs from a dictionary
  • How __init__ and self work, 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-checker folder, with its virtual environment.
  • Where commands run: in your terminal, in site-checker, with the environment active: cd site-checker, then source .venv/bin/activate (.venv\Scripts\activate on Windows).
  • Quick check: python3 -m pytest -q should say 5 passed. If it can't find requests or pytest, 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:

BASH
python3 -c 'from checker import load_sites; site = load_sites("sites.csv")[0]; print(site); print(site["nmae"])'
TEXT
{'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:

PYTHON
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)
BASH
python3 first_class.py
TEXT
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 a Site. Site("github.com", "https://github.com") calls it with those two values.
  • self is the copy being filled in. self.name = name writes the name into this copy's box. Every function inside a class gets self first, and you never pass it yourself: Python does.
  • github.name reads 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 through self. You've used methods since Lesson 2: "hello".upper() is the upper method of str.

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 a Site, 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, so False.
  • The typo is still caught only when that line runs, but the error now says exactly what's wrong: a Site has no attribute nmae. And your code editor knows a Site's attributes, so it can underline github.nmae before 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:

PYTHON
class Site:
    def describe():
        return "a site"


Site().describe()
TEXT
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:

PYTHON
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"))
TEXT
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'
  • @dataclass is 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: str lists 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 reads url.

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:

PYTHON
from dataclasses import dataclass


@dataclass
class Batch:
    sites: list = []
BASH
python3 list_default.py 2>&1 | tail -1
TEXT
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:

PYTHON
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}")
TEXT
(<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 on Exception: it gets everything an exception can do, and its own name. pass means "nothing to add".
  • __mro__ lists a class and everything it's built on, in order. ValueError is built on Exception, and so is SitesFileError: they're the same kind of thing. That's why except Exception catches both, and except SitesFileError catches only yours.
  • raise throws it (Lesson 11 uses raise too), and as error gives 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:

PYTHON
from dataclasses import dataclass

Step 2. Add the two types and the error, just above is_up():

PYTHON
@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
  • status is "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 = None means "an int, or None", with None as the default. A site that never answered has no status code, so CheckResult("no such site", "error") leaves them out.
  • to_dict() turns a result into exactly the dictionary results.json has 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:

PYTHON
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:

PYTHON
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:

PYTHON
    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:

BASH
python3 checker.py; echo "exit: $?"
TEXT
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:

TEXT
[
  {
    "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:

PYTHON
import pytest

from checker import CheckResult, Site, SitesFileError, is_up, load_sites, to_ms
PYTHON
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_path is a gift from pytest: name it as an argument, and the test gets a fresh, empty folder that's thrown away afterwards. Your real sites.csv is never touched.
  • load_sites(path) == [Site(...)] only works because a dataclass compares by contents. With the plain class from earlier, it would always be False.
  • with pytest.raises(SitesFileError): passes only if the code inside raises that error.
  • The last test guards results.json. If someone renames status one day, this fails before Lesson 11's API breaks.
BASH
python3 -m pytest -q
TEXT
........                                                                 [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() and to_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:

PYTHON
@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:

BASH
printf 'name,address\nexample,https://example.com\n' > bad.csv
python3 checker.py --sites bad.csv; echo "exit: $?"
rm bad.csv
TEXT
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

  1. What's the difference between a class and an object?

  2. A method gives: takes 0 positional arguments but 1 was given. What's wrong?

  3. Two dataclass Sites with the same name and url. What does == give?

  4. Site(name=42, url=None) worked on a dataclass with name: str. Why?

  5. Why does a dataclass refuse sites: list = []?

  6. 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__ and self: 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