Yiwei Shi

Python Review 2026: 21 Essential Examples

Python Examples Reference
Section 01

For Loops with Index

Python's enumerate() is the idiomatic way to get both the index and the value while iterating. You can also use range(len(...)), but it's less Pythonic.

# Method 1: enumerate (preferred)
fruits = ["apple", "banana", "cherry"]

for i, fruit in enumerate(fruits):
    print(f"{i}: {fruit}")

# Start index at 1
for i, fruit in enumerate(fruits, start=1):
    print(f"{i}. {fruit}")
# 1. apple
# 2. banana
# 3. cherry
# Method 2: range(len(...)) — works but less idiomatic
for i in range(len(fruits)):
    print(f"{i}: {fruits[i]}")

# Method 3: zip with range for parallel iteration
names = ["Alice", "Bob", "Charlie"]
scores = [95, 82, 88]

for i, (name, score) in enumerate(zip(names, scores)):
    print(f"{i}: {name} scored {score}")
# 0: Alice scored 95
# 1: Bob scored 82
# 2: Charlie scored 88
Section 02

Arrays in Python

Python doesn't have a built-in array type the way Java or C do. The list is the default go-to. For typed, memory-efficient arrays Python has array.array in the stdlib, and numpy.ndarray for numerical work.

# Lists — the default "array" in Python (heterogeneous, dynamic)
my_list = [1, 2, 3, "hello", 3.14]
my_list.append(4)
print(my_list[0])       # 1
print(my_list[-1])      # 4
print(my_list[1:3])     # [2, 3]
# array.array — typed, compact storage (single type only)
from array import array

int_arr = array("i", [10, 20, 30])   # "i" = signed int
int_arr.append(40)
print(int_arr[2])  # 30

# int_arr.append("hello")  # TypeError — enforces the type code
# numpy.ndarray — for numerical / scientific work
import numpy as np

arr = np.array([1, 2, 3, 4])
print(arr * 2)         # [2 4 6 8]  — element-wise
print(arr.mean())      # 2.5
Rule of thumb: Use list unless you need performance with millions of homogeneous numbers, in which case reach for array.array or numpy.
Section 03

Access Modifiers

Python has no enforced public/private/protected keywords like Java. Instead it uses naming conventions: a single underscore _name signals "internal — don't touch", and a double underscore __name triggers name mangling. Everything is technically accessible.

class Account:
    def __init__(self, owner, balance):
        self.owner = owner            # "public" — no underscore
        self._currency = "USD"       # "protected" — single underscore (convention)
        self.__balance = balance       # "private" — double underscore (name-mangled)

    def get_balance(self):
        return self.__balance

acc = Account("Alice", 1000)

print(acc.owner)           # Alice          — fully accessible
print(acc._currency)       # USD             — accessible but "please don't"
print(acc.get_balance())   # 1000            — via getter

# print(acc.__balance)     # AttributeError!
print(acc._Account__balance)  # 1000 — name mangling lets you bypass
Python philosophy: "We are all consenting adults here." Access control is advisory, not enforced.
Section 04

Name Mangling

When you prefix an attribute with double underscores (e.g. __name), Python internally renames it to _ClassName__name. This is called name mangling. Its purpose is to avoid accidental name collisions in subclasses, not to enforce privacy.

class Person:
    def __init__(self, name):
        self.__name = name   # stored as _Person__name internally

p = Person("Alice")

# Direct access fails
try:
    print(p.__name)
except AttributeError as e:
    print(f"Error: {e}")
    # Error: 'Person' object has no attribute '__name'

# Name-mangled access works
print(p._Person__name)   # Alice

# You can see the mangled name in __dict__
print(p.__dict__)
# {'_Person__name': 'Alice'}
# WHY it exists — avoiding collisions in subclasses
class Base:
    def __init__(self):
        self.__value = 10    # _Base__value

class Child(Base):
    def __init__(self):
        super().__init__()
        self.__value = 20    # _Child__value (separate!)

c = Child()
print(c._Base__value)    # 10 — parent's version
print(c._Child__value)   # 20 — child's version
Section 05

Generators

A generator is a function that yields values one at a time using yield, producing items lazily instead of building the entire collection in memory.

# Basic generator function
def countdown(n):
    while n > 0:
        yield n
        n -= 1

for num in countdown(5):
    print(num, end=" ")
# 5 4 3 2 1
# Generator expression (like a list comprehension, but lazy)
squares = (x**2 for x in range(1_000_000))
print(next(squares))   # 0
print(next(squares))   # 1
print(next(squares))   # 4
# Practical: reading a huge file line by line
def read_lines(filepath):
    with open(filepath) as f:
        for line in f:
            yield line.strip()

# Infinite generator
def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

fib = fibonacci()
print([next(fib) for _ in range(10)])
# [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
Section 06

Map API

Python has the built-in map() function that applies a function to every element of an iterable. It returns a lazy iterator.

# map() basics
nums = [1, 2, 3, 4]
doubled = map(lambda x: x * 2, nums)
print(list(doubled))   # [2, 4, 6, 8]

# With a named function
def to_upper(s):
    return s.upper()

names = ["alice", "bob", "charlie"]
print(list(map(to_upper, names)))
# ['ALICE', 'BOB', 'CHARLIE']
# map() with multiple iterables
a = [1, 2, 3]
b = [10, 20, 30]
print(list(map(lambda x, y: x + y, a, b)))
# [11, 22, 33]
# Often a list comprehension is more Pythonic:
result = [x * 2 for x in nums]   # same as map(lambda x: x*2, nums)

# dict: Python's built-in hash map / dictionary
d = {"name": "Alice", "age": 30}
d["email"] = "alice@example.com"
print(d.get("name"))   # Alice
Section 07

Inheritance

Python supports single, multiple, and multilevel inheritance. All classes implicitly inherit from object.

# Single inheritance
class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        return "..."

class Dog(Animal):
    def speak(self):
        return f"{self.name} says Woof!"

class Cat(Animal):
    def speak(self):
        return f"{self.name} says Meow!"

print(Dog("Rex").speak())   # Rex says Woof!
print(isinstance(Dog("Rex"), Animal))  # True
# Multiple inheritance
class Flyable:
    def fly(self):
        return f"{self.name} is flying"

class Swimmable:
    def swim(self):
        return f"{self.name} is swimming"

class Duck(Animal, Flyable, Swimmable):
    def speak(self):
        return f"{self.name} says Quack!"

donald = Duck("Donald")
print(donald.speak())   # Donald says Quack!
print(donald.fly())     # Donald is flying
print(donald.swim())    # Donald is swimming
# super() for calling parent methods
class Puppy(Dog):
    def __init__(self, name, toy):
        super().__init__(name)
        self.toy = toy

    def speak(self):
        return super().speak() + " (tiny woof)"

p = Puppy("Tiny", "ball")
print(p.speak())  # Tiny says Woof! (tiny woof)
Section 08

MRO & C3 Linearization

MRO (Method Resolution Order) is the order Python searches classes when you call a method. Python uses the C3 linearization algorithm to compute a consistent, deterministic MRO for multiple inheritance, respecting both local precedence and monotonicity.

class A:
    def who(self): return "A"

class B(A):
    def who(self): return "B"

class C(A):
    def who(self): return "C"

class D(B, C):
    pass

# Check the MRO
print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)

# D has no who(), so Python walks: D → B → found "B"
print(D().who())  # B
# C3 algorithm rules (simplified):
# 1. The class itself comes first
# 2. Then merge the MROs of parents (left to right)
# 3. A class only appears once, and before its parents
# 4. If ordering is ambiguous/inconsistent → TypeError

# Diamond problem resolved cleanly:
class Base:
    def setup(self): print("Base.setup")

class Left(Base):
    def setup(self):
        print("Left.setup")
        super().setup()

class Right(Base):
    def setup(self):
        print("Right.setup")
        super().setup()

class Child(Left, Right):
    def setup(self):
        print("Child.setup")
        super().setup()

Child().setup()
# Child.setup
# Left.setup
# Right.setup
# Base.setup    ← called only ONCE thanks to C3
Section 09

Type Enforcement

Python is dynamically typed by default. You can add type hints (PEP 484) for documentation and static analysis, but they're not enforced at runtime unless you use tools like mypy or runtime checkers like pydantic or beartype.

# Type hints — NOT enforced at runtime
def greet(name: str, times: int) -> str:
    return name * times

print(greet("Hi", 3))    # HiHiHi
print(greet(42, 2))      # 84 — no error! Python doesn't enforce it
# Manual enforcement with isinstance
def add(a, b):
    if not isinstance(a, (int, float)) or not isinstance(b, (int, float)):
        raise TypeError("Both arguments must be numbers")
    return a + b
# Runtime enforcement with dataclasses + __post_init__
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

    def __post_init__(self):
        if not isinstance(self.x, (int, float)):
            raise TypeError(f"x must be a number, got {type(self.x)}")
# Pydantic — the gold standard for runtime validation
from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int
    email: str

user = User(name="Alice", age=30, email="a@b.com")   # OK
# User(name="Alice", age="not a number", email="a@b.com")  # ValidationError!
Section 10

Exception Rethrow

Yes. Use bare raise to re-raise the current exception, preserving the original traceback. You can also chain exceptions with raise ... from ....

# Basic rethrow with bare raise
def process(data):
    try:
        result = 100 / data
    except ZeroDivisionError:
        print("Logging: division by zero occurred")
        raise   # re-raises the ZeroDivisionError with original traceback

try:
    process(0)
except ZeroDivisionError as e:
    print(f"Caught: {e}")
# Logging: division by zero occurred
# Caught: division by zero
# Exception chaining: raise ... from ...
class AppError(Exception):
    pass

def load_config(path):
    try:
        with open(path) as f:
            return f.read()
    except FileNotFoundError as e:
        raise AppError(f"Config missing: {path}") from e
        # Original exception is preserved as __cause__
# Suppress original chain: raise ... from None
try:
    int("abc")
except ValueError:
    raise RuntimeError("Invalid input") from None
    # Clean traceback without "During handling of..." message
Section 11

HashSet

Python's built-in set is a hash set — an unordered collection of unique, hashable elements with O(1) average-case lookups.

# Creating sets
s = {1, 2, 3, 3, 2}
print(s)           # {1, 2, 3} — duplicates removed

empty = set()      # NOT {} — that's an empty dict

from_list = set([1, 2, 2, 3])
print(from_list)    # {1, 2, 3}
# Common operations
s.add(4)
s.discard(2)       # remove (no error if missing)
print(3 in s)       # True — O(1) membership test

# Set operations
a = {1, 2, 3}
b = {2, 3, 4}
print(a | b)   # {1, 2, 3, 4}  — union
print(a & b)   # {2, 3}        — intersection
print(a - b)   # {1}           — difference
print(a ^ b)   # {1, 4}        — symmetric difference
# Practical: removing duplicates while preserving order
items = ["b", "a", "c", "a", "b"]
seen = set()
unique = []
for item in items:
    if item not in seen:
        seen.add(item)
        unique.append(item)
print(unique)  # ['b', 'a', 'c']
frozenset is the immutable variant — it's hashable itself, so you can use frozensets as dict keys or put them inside other sets.
Section 12

Closures

A closure is a nested function that captures and remembers variables from its enclosing scope, even after that scope has finished executing.

def make_multiplier(factor):
    def multiply(x):
        return x * factor   # 'factor' is captured from the enclosing scope
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(5))   # 10
print(triple(5))   # 15

# The closure "remembers" factor even after make_multiplier returned
print(double.__closure__[0].cell_contents)  # 2
# Closure with mutable state (use nonlocal)
def make_counter():
    count = 0
    def increment():
        nonlocal count
        count += 1
        return count
    return increment

counter = make_counter()
print(counter())  # 1
print(counter())  # 2
print(counter())  # 3
Section 13

Closure Use Cases

Closures shine when you need lightweight, stateful functions without creating a full class.

# 1. Function factories
def make_formatter(prefix, suffix):
    def fmt(text):
        return f"{prefix}{text}{suffix}"
    return fmt

html_bold = make_formatter("<b>", "</b>")
print(html_bold("hello"))   # <b>hello</b>
# 2. Callbacks / event handlers
def make_handler(action):
    def handler(event):
        print(f"Handling {action} for event: {event}")
    return handler

on_click = make_handler("click")
on_click("button_submit")
# Handling click for event: button_submit
# 3. Memoization / caching
def memoize(func):
    cache = {}
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper

@memoize
def fib(n):
    if n < 2: return n
    return fib(n - 1) + fib(n - 2)

print(fib(50))  # 12586269025 — instant with memoization
# 4. Configuration / partial application
def make_logger(level):
    def log(message):
        print(f"[{level.upper()}] {message}")
    return log

warn = make_logger("warning")
warn("disk space low")
# [WARNING] disk space low
Section 14

Decorators

A decorator is a function that wraps another function to extend its behavior. It's syntactic sugar built on closures. The @decorator syntax is equivalent to func = decorator(func).

# Basic decorator
import time
from functools import wraps

def timer(func):
    @wraps(func)     # preserves func.__name__, __doc__, etc.
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timer
def slow_add(a, b):
    time.sleep(0.1)
    return a + b

print(slow_add(2, 3))
# slow_add took 0.1003s
# 5
# Decorator WITH arguments
def repeat(n):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(n):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def say_hello(name):
    print(f"Hello, {name}!")

say_hello("Alice")
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!
# Stacking decorators
def bold(func):
    @wraps(func)
    def wrapper(*a, **kw):
        return f"<b>{func(*a, **kw)}</b>"
    return wrapper

def italic(func):
    @wraps(func)
    def wrapper(*a, **kw):
        return f"<i>{func(*a, **kw)}</i>"
    return wrapper

@bold
@italic
def greet(name):
    return f"Hello, {name}"

print(greet("World"))
# <b><i>Hello, World</i></b>
Section 15

Multithreading & Multiprocessing

Threading uses OS threads sharing the same memory space — good for I/O-bound work but limited by the GIL for CPU-bound tasks. Multiprocessing spawns separate processes with their own memory — true parallelism for CPU-bound work.

# Threading — good for I/O-bound tasks
import threading
import time

def download(url):
    print(f"Downloading {url}...")
    time.sleep(1)   # simulate I/O
    print(f"Done: {url}")

urls = ["page1.html", "page2.html", "page3.html"]
threads = [threading.Thread(target=download, args=(u,)) for u in urls]

for t in threads: t.start()
for t in threads: t.join()
# All 3 run concurrently — total ≈ 1s, not 3s
# Multiprocessing — good for CPU-bound tasks
from multiprocessing import Pool

def heavy_math(n):
    return sum(i * i for i in range(n))

with Pool(4) as pool:
    results = pool.map(heavy_math, [10_000_000] * 4)
    print(results)  # 4 results computed in parallel
# asyncio — async/await for high-concurrency I/O
import asyncio

async def fetch(name, delay):
    print(f"Start {name}")
    await asyncio.sleep(delay)
    print(f"Done {name}")
    return name

async def main():
    results = await asyncio.gather(
        fetch("A", 2),
        fetch("B", 1),
        fetch("C", 1.5),
    )
    print(results)  # ['A', 'B', 'C']

asyncio.run(main())
# All 3 run concurrently — total ≈ 2s
Section 16

GIL Removal

The GIL (Global Interpreter Lock) has been Python's biggest concurrency limitation. As of Python 3.13 (2024), an experimental free-threaded build (PEP 703) ships that can disable the GIL. It's opt-in and not yet the default.

# The GIL means only one thread executes Python bytecode at a time.
# This is why threading doesn't speed up CPU-bound code:

import threading, time

def count(n):
    total = 0
    for i in range(n):
        total += i
    return total

# Even with 4 threads, this is NOT faster than sequential
# because the GIL serializes the Python bytecode execution.
# Use multiprocessing for CPU-bound work instead.

# Python 3.13+ free-threaded mode:
# Install: python3.13t (the "t" suffix)
# Run:     python3.13t -Xgil=0 script.py
# Or set:  PYTHON_GIL=0
Status (3.13): Experimental. Many C extensions don't yet support free-threading. The plan is to make it the default over several releases. For now, multiprocessing and asyncio remain the practical solutions.
Section 17

Recent Python Features (3.8 – 3.13)

A high-level tour of the most impactful additions across recent Python versions.

# 3.8 — Walrus operator (:=)
data = [1, 2, 3, 4, 5, 6]
big = [x for x in data if (sq := x**2) > 10]
print(big)  # [4, 5, 6]

# 3.8 — Positional-only parameters (see section 21)
def f(a, b, /, c):  ...

# 3.9 — Dict merge operators
a = {"x": 1}; b = {"y": 2}
merged = a | b            # {'x': 1, 'y': 2}
a |= b                    # in-place merge

# 3.9 — Built-in generics (no more typing.List)
def first(items: list[int]) -> int:
    return items[0]
# 3.10 — Structural pattern matching (see section 21)
match command:
    case "quit":  ...
    case "go", direction:  ...

# 3.10 — Union type with |
def square(n: int | float) -> int | float:
    return n ** 2

# 3.11 — Exception groups & except*
try:
    raise ExceptionGroup("errors", [ValueError("a"), TypeError("b")])
except* ValueError as eg:
    print("Caught ValueErrors:", eg.exceptions)
# 3.12 — Type parameter syntax (see section 19)
def first[T](items: list[T]) -> T:
    return items[0]

# 3.12 — Improved f-strings (nested quotes allowed)
matrix = [[1, 2], [3, 4]]
print(f"Flat: {[x for row in matrix for x in row]}")

# 3.13 — Free-threaded mode (experimental, see section 16)
# 3.13 — Improved error messages, better REPL
Section 18

f-strings

f-strings (formatted string literals, PEP 498, Python 3.6+) let you embed expressions inside string literals using {} brackets. They're fast, readable, and the preferred way to format strings.

# Basics
name = "Alice"
age = 30
print(f"{name} is {age} years old")
# Alice is 30 years old

# Expressions inside braces
print(f"{name.upper()} will be {age + 1} next year")
# ALICE will be 31 next year
# Format specifiers
pi = 3.14159265
print(f"Pi ≈ {pi:.2f}")          # Pi ≈ 3.14
print(f"Big: {1000000:,}")         # Big: 1,000,000
print(f"Percent: {0.856:.1%}")    # Percent: 85.6%
print(f"Padded: {42:05d}")        # Padded: 00042
print(f"Left:  '{name:<10}'")      # Left:  'Alice     '
print(f"Right: '{name:>10}'")      # Right: '     Alice'
# Debug format (3.8+) — prints expression AND value
x = 42
print(f"{x = }")              # x = 42
print(f"{x * 2 = }")          # x * 2 = 84

# Multiline f-strings
record = {"name": "Alice", "dept": "Engineering"}
msg = (
    f"Employee: {record['name']}\n"
    f"Department: {record['dept']}"
)
print(msg)
Section 19

New Type-Parameter Syntax (3.12)

Python 3.12 introduced PEP 695 — a cleaner syntax for generics. Instead of importing TypeVar and declaring it separately, you declare type parameters inline with square brackets.

# OLD way (pre-3.12) — verbose
from typing import TypeVar, Generic

T = TypeVar("T")

def first_old(items: list[T]) -> T:
    return items[0]

class Stack_Old(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []
    def push(self, item: T) -> None:
        self._items.append(item)
    def pop(self) -> T:
        return self._items.pop()
# NEW way (3.12+) — clean and inline
def first[T](items: list[T]) -> T:
    return items[0]

class Stack[T]:
    def __init__(self) -> None:
        self._items: list[T] = []
    def push(self, item: T) -> None:
        self._items.append(item)
    def pop(self) -> T:
        return self._items.pop()

s = Stack[int]()
s.push(1)
# Bounded type parameters
def max_val[T: (int, float)](a: T, b: T) -> T:
    return a if a > b else b

# Type aliases (also new syntax)
type Point = tuple[float, float]
type Matrix[T] = list[list[T]]
Section 20

Recap of 3.8 / 3.9 / 3.10

Python 3.8 highlights:

# Walrus operator (:=) — assign inside expressions
while (line := input("> ")) != "quit":
    print(f"You said: {line}")

# Positional-only parameters
def pow(base, exp, /, mod=None):
    ...   # base and exp must be positional

# f-string =  for debugging
x = 10
print(f"{x = }, {x**2 = }")  # x = 10, x**2 = 100

# typing.Final and typing.Literal
from typing import Final, Literal
MAX: Final = 100
def direction(d: Literal["N", "S", "E", "W"]): ...

Python 3.9 highlights:

# Built-in generics — no more from typing import List, Dict
def process(items: list[int]) -> dict[str, int]:
    return {str(i): i for i in items}

# Dictionary merge (|) and update (|=)
defaults = {"color": "blue", "size": "M"}
custom = {"size": "L", "qty": 2}
config = defaults | custom    # {'color': 'blue', 'size': 'L', 'qty': 2}

# String removeprefix / removesuffix
"HelloWorld".removeprefix("Hello")   # "World"
"test_file.py".removesuffix(".py")    # "test_file"

Python 3.10 highlights:

# Structural pattern matching (full examples in section 21)
match status:
    case 200: print("OK")
    case 404: print("Not Found")
    case _:   print("Other")

# Union type shorthand: int | str instead of Union[int, str]
def display(val: int | str) -> None:
    print(val)

# Parenthesized context managers
with (
    open("in.txt") as src,
    open("out.txt", "w") as dst,
):
    dst.write(src.read())

# Improved error messages
# "Did you mean 'append'?" when you typo 'lst.apend()'
Section 21

Positional-Only Parameters & Pattern Matching

Positional-only parameters (3.8+) — parameters before / in the signature can only be passed positionally, never as keyword arguments.

# Syntax:  def f(pos_only, /, normal, *, kw_only)

def divide(a, b, /, *, round_result=False):
    result = a / b
    return round(result) if round_result else result

divide(10, 3)                        # OK
divide(10, 3, round_result=True)     # OK
# divide(a=10, b=3)                   # TypeError! a and b are positional-only
# Why use it?
# 1. Parameter names become an implementation detail — you can rename freely
# 2. Avoids conflicts when **kwargs collects keyword arguments
# 3. Matches how builtins work (len, range, etc.)

def my_func(name, /, **kwargs):
    print(name, kwargs)

my_func("Alice", name="override")
# Alice {'name': 'override'}
# No conflict! 'name' as positional vs 'name' in kwargs

Pattern matching (3.10+) — Python's match/case goes far beyond a switch statement. It destructures, binds variables, and matches against types and structures.

# Literal matching
def http_status(code):
    match code:
        case 200:
            return "OK"
        case 301 | 302:            # OR pattern
            return "Redirect"
        case 404:
            return "Not Found"
        case _:                    # wildcard (default)
            return "Unknown"
# Sequence destructuring
def handle_command(command):
    match command.split():
        case ["go", direction]:
            print(f"Moving {direction}")
        case ["pick", "up", item]:
            print(f"Picking up {item}")
        case ["attack", *targets]:
            print(f"Attacking: {', '.join(targets)}")

handle_command("go north")          # Moving north
handle_command("pick up sword")     # Picking up sword
handle_command("attack orc dragon") # Attacking: orc, dragon
# Mapping (dict) patterns
def process_event(event):
    match event:
        case {"type": "click", "x": x, "y": y}:
            print(f"Click at ({x}, {y})")
        case {"type": "keypress", "key": k}:
            print(f"Key pressed: {k}")

process_event({"type": "click", "x": 50, "y": 100})
# Click at (50, 100)
# Class patterns with guards
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

def describe(point):
    match point:
        case Point(x=0, y=0):
            return "Origin"
        case Point(x=x, y=0):
            return f"On x-axis at {x}"
        case Point(x=x, y=y) if x == y:       # guard clause
            return f"On diagonal at {x}"
        case Point(x=x, y=y):
            return f"Point({x}, {y})"

print(describe(Point(0, 0)))     # Origin
print(describe(Point(5, 0)))     # On x-axis at 5
print(describe(Point(3, 3)))     # On diagonal at 3
print(describe(Point(1, 7)))     # Point(1, 7)